diff --git a/.gitignore b/.gitignore index 6baa3721..b76a0197 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,13 @@ assets/pipeline_overview.png # Local planning notes OPEN_SOURCE_ALLOWLIST.md OPEN_SOURCE_TODO.md + +# --------------------------------------------------------------------------- +# WebUI (added on top of upstream): user-scoped secrets & per-project data +# --------------------------------------------------------------------------- +webui/webui_config.json +webui/backend/.key +webui/projects/ +webui/frontend/node_modules/ +webui/frontend/dist/ +*.log diff --git a/README-WebUI-zh.md b/README-WebUI-zh.md new file mode 100644 index 00000000..51e18749 --- /dev/null +++ b/README-WebUI-zh.md @@ -0,0 +1,240 @@ +> 英文版见 [README-WebUI.md](./README-WebUI.md)。 + +# Resource2Skill · WebUI 项目手册(领域无关 / 通用) + +> 本仓库在 Microsoft `Resource2Skill` 内核之上套了一层本地可视壳(FastAPI + Vue3)。 +> 本手册讲**通用 WebUI 这条线**——引擎与 UI 完全领域无关,领域行为 100% 来自 +> `domain.yaml` + 该域的 `mcp_server/server.py`。仓库自带若干官方示例领域(`ppt` / `web` / +> `excel` / `blender` / `reaper`,见 `domains/`),可克隆、可删除、可替换。上游通用说明在 `README.md`。 + +整体由三部分组成: + +- **后端**:FastAPI(`webui/backend/`),任务队列单 worker 串行,蒸馏 / Agent 共用。 +- **前端**:Vue3 + Element Plus(`webui/frontend/`),纯本地 `localhost` 单人使用。 +- **数据隔离**:引擎代码只有一份;"项目"是独立数据目录(`webui/projects/<项目>/`,含 `domains/ fixtures/ skills_library/ output/ + project.json`)。任务执行时把 `cwd` 切到项目根,R2S 相对路径自然生效,**不重构 core**。 +- **领域驱动**:每个域自带一个 `domain.yaml`(persona / categories / mcp / agent 提示词)和一个 MCP server(`domains/<域>/mcp_server/server.py`),把蒸馏出的技能暴露成 Agent 可调用的工具。**换领域 = 换这两份配置,UI 零改动。** + +--- + +## 1. 环境准备 + +### 1.1 Python(后端 + 蒸馏 + Agent) + +```bash +# 用 3.11/3.12 即可(Blender 域才强制 3.11,一般领域不需要) +cd Resource2Skill +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt # 含 mcp>=1.26、python-pptx、openpyxl 等 +``` + +> ⚠️ `mcp` 必须 `>=1.26`。1.10.x 会让本仓库所有 MCP server 在 import 时崩溃 +> (`issubclass() arg 1 must be a class`,因为 server 用了 `from __future__ import annotations`)。 + +### 1.2 Node(前端) + +```bash +cd webui/frontend +npm install +``` + +### 1.3 LLM 接入(蒸馏与 Agent 都依赖它) + +蒸馏会真实调用 LLM 来抽取 / 切片 / 生成技能;Agent 也会调用。先准备好一个兼容 +OpenAI 协议的 endpoint(DeepSeek / Azure OpenAI / 本地 Ollama / 自建 OpenAI-compatible 均可)。 + +- 在 WebUI 的 **「LLM 配置」** 页新增模板:`provider`(azure/openai/deepseek/ollama/custom)、 + `endpoint`、`api_key`、`model`,点 **「测试」** 验证连通。 +- 或用 API:`POST /api/llm`,body 见 `webui/backend/models.py` 的 `LLMTemplate`。 + +> 不想接 LLM?蒸馏可勾 **dry-run**:只验证「抽取 → 切片 → 写技能库」管线,不调用模型。 + +--- + +## 2. 启动 + +### 2.1 后端(FastAPI,端口 8000) + +```bash +cd webui +python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 +``` + +> 后端用**相对导入**,必须从 `webui/` 目录启动(`backend.main:app`),不能裸 `uvicorn main:app`。 +> 健康自检:`GET http://127.0.0.1:8000/api/health`。 + +### 2.2 前端(Vite dev,端口 5172) + +```bash +cd webui/frontend +npm run dev +``` + +浏览器打开 `http://localhost:5172`(dev server 只绑 `localhost` / IPv6 `::1`,用 `localhost` 而非 `127.0.0.1`)。 + +> **Windows 路径坑(必读)**:本机 `C:\Users\...` 若为到 `D:\workbuddy\...` 的 junction, +> 从 `C:` 路径跑 `vite build` 会报 `fileName absolute path` 错误。 +> 解决:从**真实 D: 路径**启动(`webui/frontend/`);若 HMR 异常,先 `taskkill` 掉残留的 +> `vite preview` 或占用 5172/5173 的旧进程,再重启 dev server。 + +--- + +## 3. 蒸馏教程(把素材变成可执行技能) + +蒸馏入口只有一个:**`POST /api/projects/{name}/domains/{domain}/distill`**,由 WebUI 的 +**「蒸馏工作台」** 或 **「素材管理」** 触发。底层走 `core/collector.py` → 按 `domain.yaml` +的 `categories` 分类 → 写 `项目根/skills_library//index.json` + 每技能一个目录。 + +> 蒸馏与领域无关:换 domain 只是换 `categories` 与提示词,**引擎代码不变**。 + +### 3.1 步骤一览(WebUI) + +1. **新建项目**:「项目与Domain管理」→ 新建(如 `demo`)。项目数据落在 `webui/projects/demo/`。 +2. **新建 / 克隆域**:可新建空白 `` 域,或「从已有域克隆」(仓库自带示例域如 `ppt` 可直接克隆)。 +3. **上传素材**:进该域的「素材管理」,上传 PDF/Word/MD/TXT/PPTX 等。上传后写入 + `fixtures//manifest.json`,默认 `enabled: true`。 + - 只有 `enabled: true` 的素材会参与蒸馏;可在素材列表里启用 / 停用。 +4. **配置 LLM**:在「LLM 配置」选好活动模板(见 1.3)。 +5. **跑蒸馏**:在「蒸馏工作台」点 **「蒸馏」**。首次建议先勾 **dry-run** 验证管线;再取消勾跑真实蒸馏。 +6. **看进度 / 重跑**:「任务中心」实时看日志。`task_id` 记下了参数,**「重跑」** 按钮可一键用相同参数复跑, + 不用重新填表。 + +### 3.2 用 API 蒸馏 + +```bash +# dry-run(不调 LLM,验证管线) +curl -X POST http://127.0.0.1:8000/api/projects/demo/domains//distill \ + -H 'Content-Type: application/json' \ + -d '{"dry_run": true}' + +# 真实蒸馏 +curl -X POST http://127.0.0.1:8000/api/projects/demo/domains//distill \ + -H 'Content-Type: application/json' \ + -d '{"dry_run": false}' +# -> {"ok": true, "task_id": "xxxxxxxx"} +``` + +> Git Bash 注意:单引号 JSON 里含中文会被 locale 改写导致 `400 error parsing the body`。 +> 用 ASCII 字段或 `-d @file`(把 body 写进文件再 `-d @body.json`)避免。 + +轮询任务状态: + +```bash +curl http://127.0.0.1:8000/api/tasks/ +``` + +### 3.3 蒸馏产物长什么样 + +``` +webui/projects/demo/ +└── skills_library// + ├── index.json # 总索引:{updated_at, total, skills:[{skill_id,skill_name,category,source_document,source_title,detail_path}]} + ├── /... + ├── /... + └── ... +``` + +- `index.json` 是入口,每条技能含 `skill_id / skill_name / category / source_document / source_title / detail_path`。 +- 每个技能是一个目录,里面有 `skill.json` 及正文(.md/.json/.txt)。 +- 幂等:已蒸馏的源文件记录在 `source_document`,重跑只补新文件、跳过已有的。 + +--- + +## 4. 使用蒸馏出来的技能(教程) + +蒸馏出的技能通过 **MCP server** 被 Agent 消费。每个域的 server 在 +`webui/projects/<项目>/domains/<域>/mcp_server/server.py`,用 `FastMCP` 以 stdio 传输。 +server 通过环境变量 `R2S_DOMAIN` / `R2S_SKILLS_DIR` / `R2S_WORKSPACE` 感知当前领域与隔离路径 +(不写死领域名,因此同一份代码可服务任意域)。 + +### 4.1 方式一:WebUI Agent 执行台(最常用,自动拉起 MCP) + +1. 进 **「Agent 执行台」**,选域(如 `ppt`),填任务文本。 +2. 可选:模型、`reasoning`、`max_iter`、`n_skills`、`top_k`、`dry_run`。 +3. 点 **「运行」**。后端按 `domain.yaml` 的 `mcp:` 块自动拉起 MCP server(cwd=项目根), + Agent 通过它调用技能与阶段工具;执行日志与产物实时可见。 + +API 等价调用: + +```bash +curl -X POST http://127.0.0.1:8000/api/projects/demo/agent/run \ + -H 'Content-Type: application/json' \ + -d '{"domain":"","task":"用本域技能完成 XXX","max_iter":12,"n_skills":5,"top_k":20}' +``` + +### 4.2 示例域(ppt)的 MCP 工具 + +仓库自带的 **示例域 `ppt`** 在 `domains/ppt/mcp_server/server.py` 注册了若干个 `@mcp.tool` +(知识召回、交付物生成等,具体清单见该源码)。 +**换成别的域,工具集由该域的 `server.py` 决定**——UI 与引擎不关心具体工具有哪些。 +所有工具内部按 `R2S_DOMAIN` 识别领域并读写本项目的技能库 / 产物目录,因此同一份 server 代码 +可服务任意域。 + +> 这些示例域工具是该域的能力,不是 UI 的一部分。要做一个新域,只需写该域的 +> `domain.yaml` + `mcp_server/server.py`(参考 `domains/ppt/`),无需改任何前端/后端代码。 + +### 4.3 方式二:手动跑 MCP server(给外部 Agent / 调试) + +server 用 `__file__` 反推项目根(`parents[2]`),并通过 `R2S_DOMAIN` 环境变量识别领域, +所以**直接运行即可**,无需手动切 cwd: + +```bash +cd Resource2Skill/webui/projects/demo +R2S_DOMAIN=ppt python domains/ppt/mcp_server/server.py # stdio 传输,等待 MCP 客户端连 +``` + +任何兼容 MCP 的客户端(Claude Desktop / 自建 Agent / `mcp` CLI inspector)把它配成 stdio server 即可。 +它读的是**本项目**的 `skills_library/`(不是仓库根的),与蒸馏产物一一对应。 + +### 4.4 产物仓库(看交付物) + +Agent 产出的文件落在 `webui/projects/<项目>/output/_workspace/<产品>/...`。 +WebUI **「产物仓库」** 页可直接浏览 / 下载;等价 API: + +```bash +curl "http://127.0.0.1:8000/api/projects/demo/repo?domain=" +curl "http://127.0.0.1:8000/api/projects/demo/repo/file?kind=output&domain=&rel_path=<产品>/<阶段>/" +``` + +--- + +## 5. 新增一个自己的领域(泛化用法) + +1. `POST /api/projects/<项目>/domains` `{"domain":"<你的域>","seed_from":"ppt"}` 克隆示例域作为起点; + 或留空 `seed_from` 新建空白域(`mcp: null`,后续自己配)。 +2. 编辑 `webui/projects/<项目>/domains/<你的域>/domain.yaml`: + - `persona` / `categories` / `query_pool` / `agent_initial_prompt` 改成你的领域语言; + - `mcp.command` / `mcp.args[0]` 指向你的 `mcp_server/server.py`,`mcp.env` 会自动注入 `R2S_DOMAIN` 等隔离变量。 +3. 写 `mcp_server/server.py`:用 `@mcp.tool()` 注册该域的技能工具(参考 `domains/ppt/mcp_server/server.py`, + 用 `R2S_DOMAIN` 区分领域、用 `_SKILLS_DIR` / `_WORKSPACE` 读写,不要写死领域名)。 +4. 上传该域素材 → 蒸馏 → Agent 执行台选该域运行。 + +引擎对这一切**零特殊分支**,ppt 只是被这样配置出来的一个例子。 + +--- + +## 6. 实用 tips + +- **先 dry-run 再真跑**:蒸馏 / Agent 都有 `dry_run`,不调 LLM,先验证管线不踩坑。 +- **重跑不复填表**:「任务中心」里终态任务带「重跑」按钮,复制原参数一键复跑(蒸馏 / Agent 均支持)。 +- **代理坑**:蒸馏时若模板未填代理,代码会**显式清除**从 shell 继承的 `HTTP_PROXY/HTTPS_PROXY` 走直连。 +- **数据隔离**:多项目互不相通,靠 `webui_config.json` 的 `projects_root` 决定读哪个 `webui/projects`。 + LLM Key 用 Fernet 加密落 `webui_config.json`(key 在 `webui/backend/.key`),日志脱敏。 + +--- + +## 7. 目录速查 + +``` +webui/ +├── backend/ # FastAPI:main.py(API) models.py(请求体) tasks.py(任务队列) distill.py(蒸馏入口) agent.py(Agent 调度) projects.py(项目/域+通用 mcp 重写) +├── frontend/ # Vue3 + Element Plus(领域无关,UI 不写死任何域) +└── projects/<项目>/ + ├── domains/<域>/domain.yaml # 域定义 + mcp 块(Agent 据此拉起 MCP server) + ├── domains/<域>/mcp_server/server.py # 该域技能的消费面(MCP 工具,按 R2S_DOMAIN 参数化) + ├── fixtures/<域>/manifest.json # 素材清单(enabled 开关) + ├── skills_library/<域>/ # 蒸馏产物(index.json + 每技能一目录) + └── output/<域>_workspace/ # Agent 交付物落点(<域> 由 R2S_DOMAIN 决定) + +domains/<域>/mcp_server/server.py # 仓库自带示例域(如 ppt);新建域时复制此结构 +``` diff --git a/README-WebUI.md b/README-WebUI.md new file mode 100644 index 00000000..506ca947 --- /dev/null +++ b/README-WebUI.md @@ -0,0 +1,287 @@ +# Resource2Skill · WebUI Manual (Domain-Agnostic / Generic) + +> This repository wraps a local visual shell (FastAPI + Vue3) around the Microsoft +> `Resource2Skill` engine. This manual covers **the generic WebUI line** — the engine and +> UI are fully domain-agnostic; all domain behavior comes 100% from `domain.yaml` plus that +> domain's `mcp_server/server.py`. The repo ships several official example domains +> (`ppt` / `web` / `excel` / `blender` / `reaper`, see `domains/`) that can be cloned, +> deleted, or replaced. Upstream generic docs live in `README.md`. + +The whole thing has three parts: + +- **Backend**: FastAPI (`webui/backend/`), a single-worker serial task queue shared by + distillation and the Agent. +- **Frontend**: Vue3 + Element Plus (`webui/frontend/`), pure local `localhost`, single-user. +- **Data isolation**: there is only one copy of the engine code; a "project" is an + independent data directory (`webui/projects//`, containing + `domains/ fixtures/ skills_library/ output/ + project.json`). At task execution time the + `cwd` is switched to the project root, so R2S relative paths take effect naturally — + **no need to refactor `core`**. +- **Domain-driven**: each domain ships its own `domain.yaml` (persona / categories / mcp / + agent prompts) and an MCP server (`domains//mcp_server/server.py`) that exposes the + distilled skills as tools the Agent can call. **Switching domains = swapping these two + config files; the UI needs zero changes.** + +--- + +## 1. Environment Setup + +### 1.1 Python (backend + distillation + Agent) + +```bash +# 3.11/3.12 works fine (only the Blender domain forces 3.11; most domains don't need it) +cd Resource2Skill +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt # includes mcp>=1.26, python-pptx, openpyxl, etc. +``` + +> ⚠️ `mcp` must be `>=1.26`. On 1.10.x every MCP server in this repo crashes at import +> (`issubclass() arg 1 must be a class`), because the servers use +> `from __future__ import annotations`. + +### 1.2 Node (frontend) + +```bash +cd webui/frontend +npm install +``` + +### 1.3 LLM access (distillation and the Agent both depend on it) + +Distillation really calls an LLM to extract / chunk / generate skills; the Agent calls it +too. First prepare an OpenAI-compatible endpoint (DeepSeek / Azure OpenAI / local Ollama / +any self-hosted OpenAI-compatible server all work). + +- In the WebUI **"LLM Configuration"** page, add a template: `provider` + (azure/openai/deepseek/ollama/custom), `endpoint`, `api_key`, `model`, then click + **"Test"** to verify connectivity. +- Or via API: `POST /api/llm`, body schema in `webui/backend/models.py` (`LLMTemplate`). + +> Don't want to wire up an LLM? Distillation supports **dry-run**: it only validates the +> "extract → chunk → write skill library" pipeline without calling the model. + +--- + +## 2. Launch + +### 2.1 Backend (FastAPI, port 8000) + +```bash +cd webui +python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 +``` + +> The backend uses **relative imports**, so it must be started from the `webui/` directory +> (`backend.main:app`), not a bare `uvicorn main:app`. +> Health check: `GET http://127.0.0.1:8000/api/health`. + +### 2.2 Frontend (Vite dev, port 5172) + +```bash +cd webui/frontend +npm run dev +``` + +Open `http://localhost:5172` in the browser (the dev server binds only `localhost` / IPv6 +`::1`; use `localhost` rather than `127.0.0.1`). + +> **Windows path gotcha (must read)**: if your local `C:\Users\...` is a junction to +> `D:\workbuddy\...`, running `vite build` from the `C:` path raises a +> `fileName absolute path` error. Fix: launch from the **real D: path** +> (`webui/frontend/`); if HMR misbehaves, first `taskkill` any leftover `vite preview` +> or old process occupying 5172/5173, then restart the dev server. + +--- + +## 3. Distillation Tutorial (turn materials into executable skills) + +There is exactly one distillation entry point: +**`POST /api/projects/{name}/domains/{domain}/distill`**, triggered from the WebUI's +**"Distillation Workbench"** or **"Materials Management"**. Under the hood it goes through +`core/collector.py` → categorizes by `domain.yaml`'s `categories` → writes +`project_root/skills_library//index.json` plus one directory per skill. + +> Distillation is domain-agnostic: switching domains only swaps `categories` and prompts — +> **the engine code does not change**. + +### 3.1 Steps at a glance (WebUI) + +1. **Create a project**: "Project & Domain Management" → New (e.g. `demo`). Project data + lands in `webui/projects/demo/`. +2. **Create / clone a domain**: you can create a blank ``, or "Clone from existing + domain" (the repo's built-in example domains such as `ppt` can be cloned directly). +3. **Upload materials**: go to that domain's "Materials Management" and upload + PDF/Word/MD/TXT/PPTX, etc. After upload they are written to + `fixtures//manifest.json`, default `enabled: true`. + - Only materials with `enabled: true` participate in distillation; you can enable/disable + them from the materials list. +4. **Configure LLM**: pick the active template in "LLM Configuration" (see 1.3). +5. **Run distillation**: in the "Distillation Workbench" click **"Distill"**. For the first + run, tick **dry-run** to validate the pipeline, then untick it for a real run. +6. **Watch progress / re-run**: "Task Center" shows logs in real time. With the `task_id` + recording the parameters, the **"Re-run"** button re-executes with the same parameters + in one click — no need to refill the form. + +### 3.2 Distill via API + +```bash +# dry-run (no LLM call, validates the pipeline) +curl -X POST http://127.0.0.1:8000/api/projects/demo/domains//distill \ + -H 'Content-Type: application/json' \ + -d '{"dry_run": true}' + +# real distillation +curl -X POST http://127.0.0.1:8000/api/projects/demo/domains//distill \ + -H 'Content-Type: application/json' \ + -d '{"dry_run": false}' +# -> {"ok": true, "task_id": "xxxxxxxx"} +``` + +> Git Bash note: a single-quoted JSON containing Chinese gets mangled by locale, causing +> `400 error parsing the body`. Use ASCII fields or `-d @file` (write the body to a file +> then `-d @body.json`) to avoid this. + +Poll task status: + +```bash +curl http://127.0.0.1:8000/api/tasks/ +``` + +### 3.3 What distillation produces + +``` +webui/projects/demo/ +└── skills_library// + ├── index.json # master index: {updated_at, total, skills:[{skill_id,skill_name,category,source_document,source_title,detail_path}]} + ├── /... + ├── /... + └── ... +``` + +- `index.json` is the entry point; each skill entry has `skill_id / skill_name / category / + source_document / source_title / detail_path`. +- Each skill is a directory containing `skill.json` and the body (.md/.json/.txt). +- Idempotent: already-distilled source files are recorded in `source_document`; a re-run only + adds new files and skips existing ones. + +--- + +## 4. Using the distilled skills (tutorial) + +Distilled skills are consumed by the Agent through an **MCP server**. Each domain's server +lives at `webui/projects//domains//mcp_server/server.py` and uses `FastMCP` +over stdio transport. The server learns the current domain and isolation paths via the +environment variables `R2S_DOMAIN` / `R2S_SKILLS_DIR` / `R2S_WORKSPACE` (the domain name is +never hard-coded, so the same code serves any domain). + +### 4.1 Option 1: WebUI Agent Console (most common, auto-spawns MCP) + +1. Go to **"Agent Console"**, pick a domain (e.g. `ppt`), and enter the task text. +2. Optional: model, `reasoning`, `max_iter`, `n_skills`, `top_k`, `dry_run`. +3. Click **"Run"**. The backend auto-spawns the MCP server per the `mcp:` block in + `domain.yaml` (cwd = project root); the Agent calls skills and stage tools through it; + execution logs and artifacts are visible in real time. + +Equivalent API call: + +```bash +curl -X POST http://127.0.0.1:8000/api/projects/demo/agent/run \ + -H 'Content-Type: application/json' \ + -d '{"domain":"","task":"Complete XXX using this domain skills","max_iter":12,"n_skills":5,"top_k":20}' +``` + +### 4.2 MCP tools of the example domain (`ppt`) + +The repo's built-in **example domain `ppt`** registers several `@mcp.tool`s in +`domains/ppt/mcp_server/server.py` (knowledge recall, deliverable generation, etc. — see the +source for the exact list). **For other domains, the toolset is decided by that domain's +`server.py`** — the UI and engine don't care which tools exist. All tools internally +identify the domain via `R2S_DOMAIN` and read/write this project's skill library / artifact +directory, so the same server code serves any domain. + +> These example-domain tools are that domain's capabilities, not part of the UI. To build a +> new domain you only write that domain's `domain.yaml` + `mcp_server/server.py` (reference +> `domains/ppt/`) — no frontend/backend code changes needed. + +### 4.3 Option 2: run the MCP server manually (for external Agents / debugging) + +The server derives the project root from `__file__` (`parents[2]`) and identifies the domain +via the `R2S_DOMAIN` env var, so **you can run it directly** without manually switching cwd: + +```bash +cd Resource2Skill/webui/projects/demo +R2S_DOMAIN=ppt python domains/ppt/mcp_server/server.py # stdio transport, waits for an MCP client +``` + +Any MCP-compatible client (Claude Desktop / a self-built Agent / the `mcp` CLI inspector) +can be configured to use it as a stdio server. It reads **this project's** +`skills_library/` (not the repo root's), matching the distillation output one-to-one. + +### 4.4 Artifact repository (view deliverables) + +Agent-produced files land in +`webui/projects//output/_workspace//...`. The WebUI +**"Artifact Repository"** page can browse / download them directly; equivalent APIs: + +```bash +curl "http://127.0.0.1:8000/api/projects/demo/repo?domain=" +curl "http://127.0.0.1:8000/api/projects/demo/repo/file?kind=output&domain=&rel_path=//" +``` + +--- + +## 5. Add your own domain (generic usage) + +1. `POST /api/projects//domains` `{"domain":"","seed_from":"ppt"}` + clones the example domain as a starting point; or leave `seed_from` empty to create a + blank domain (`mcp: null`, configure later). +2. Edit `webui/projects//domains//domain.yaml`: + - Change `persona` / `categories` / `query_pool` / `agent_initial_prompt` to your + domain's language; + - Point `mcp.command` / `mcp.args[0]` at your `mcp_server/server.py`; `mcp.env` will + auto-inject isolation vars like `R2S_DOMAIN`. +3. Write `mcp_server/server.py`: register that domain's skill tools with `@mcp.tool()` + (reference `domains/ppt/mcp_server/server.py`; use `R2S_DOMAIN` to distinguish domains + and `_SKILLS_DIR` / `_WORKSPACE` to read/write — never hard-code the domain name). +4. Upload that domain's materials → distill → pick that domain in the Agent Console to run. + +The engine has **zero special-casing** for any of this; `ppt` is just one example configured +this way. + +--- + +## 6. Practical tips + +- **Dry-run before the real run**: both distillation and the Agent have `dry_run` that skips + the LLM and validates the pipeline first. +- **Re-run without refilling the form**: terminal tasks in "Task Center" carry a "Re-run" + button that copies the original parameters for a one-click re-run (supported for both + distillation and the Agent). +- **Proxy gotcha**: during distillation, if the template leaves the proxy unset, the code + **explicitly clears** `HTTP_PROXY/HTTPS_PROXY` inherited from the shell and goes direct. +- **Data isolation**: projects are mutually isolated, decided by `projects_root` in + `webui_config.json` (which `webui/projects` to read). LLM keys are Fernet-encrypted into + `webui_config.json` (key in `webui/backend/.key`), and logs are desensitized. + +--- + +## 7. Directory quick reference + +``` +webui/ +├── backend/ # FastAPI: main.py(API) models.py(request bodies) tasks.py(task queue) distill.py(distill entry) agent.py(Agent scheduler) projects.py(project/domain + generic mcp rewrite) +├── frontend/ # Vue3 + Element Plus (domain-agnostic; UI hard-codes no domain) +└── projects// + ├── domains//domain.yaml # domain definition + mcp block (Agent spawns MCP server from this) + ├── domains//mcp_server/server.py # that domain's skill consumption surface (MCP tools, parameterized by R2S_DOMAIN) + ├── fixtures//manifest.json # materials manifest (enabled switch) + ├── skills_library// # distillation output (index.json + one dir per skill) + └── output/_workspace/ # Agent deliverable landing zone ( decided by R2S_DOMAIN) + +domains//mcp_server/server.py # repo's built-in example domains (e.g. ppt); copy this structure for new domains +``` + +--- + +A Chinese version of this manual is available at [README-WebUI-zh.md](./README-WebUI-zh.md). diff --git a/TUTORIAL-new-domain.md b/TUTORIAL-new-domain.md new file mode 100644 index 00000000..8a1309b6 --- /dev/null +++ b/TUTORIAL-new-domain.md @@ -0,0 +1,175 @@ +# 教学实战:从零创建一个自定义领域(Resource2Skill WebUI) + +> **本教程的目的**:演示本项目的核心设计 —— **领域驱动**:**不改任何前端 / 后端代码**, +> 就能新增一个自己的领域,并把素材蒸馏成 Agent 可调用的技能。整个系统的"性格"由两份配置决定 +> (`domain.yaml` + 该域的 `mcp_server/server.py`),UI 和引擎对具体领域一无所知。跟着做一遍即可体会。 + +--- + +## 0. 你会学到什么 + +1. 领域驱动设计在这个项目里是怎么落地的(换域 = 换两份配置,UI/引擎零改动)。 +2. 怎么用 API 或 WebUI 新建一个域(克隆自带示例域 `ppt` 作为起点)。 +3. 怎么改 `domain.yaml`,把示例域"变成"你自己的领域。 +4. 怎么加素材、蒸馏、再用 Agent 调用技能,跑通端到端闭环。 +5. **引擎为什么能做到领域无关** —— 原理小讲(§7)。 + +--- + +## 1. 前置条件 + +- 后端 8000 + 前端 5172 已起(步骤见 `README-WebUI.md` §2)。 +- 已配好一个 LLM 模板(蒸馏要真调 LLM;或先全程用 dry-run 验证管线)。 +- 已有一个项目(教程用自带的 `demo` 项目;没有就先在「项目与Domain管理」新建一个)。 + +--- + +## 2. 第一步:新建领域(克隆示例域) + +领域通过 `POST /api/projects/<项目>/domains` 创建。`seed_from` 指定从哪个已有域克隆, +克隆会把该域的 `domain.yaml` + `mcp_server` 复制过来作为起点。 + +### 方式 A:API + +```bash +curl -X POST http://127.0.0.1:8000/api/projects/demo/domains \ + -H 'Content-Type: application/json' \ + -d '{"domain":"marketing","seed_from":"ppt"}' +``` + +> 这个调用在后端会触发 `_rewrite_mcp_for_project`:把克隆出来的 `domain.yaml` 的 `mcp` 块改写为 +> **本项目隔离**形态 —— `mcp.cwd` = 项目根,`mcp.env` 注入 `R2S_DOMAIN=marketing`,并追加 +> `--workspace <项目>/output/marketing_workspace` 与 `--skills-dir <项目>/skills_library/marketing`。 +> 引擎完全由 `R2S_DOMAIN` 决定当前域,不写死任何特定领域名。 + +### 方式 B:WebUI + +「项目与Domain管理」→ 选中 `demo` → 「新建域」→ 域名填 `marketing` → 来源选 `ppt`(克隆)。 + +**预期结果**: + +- 左侧领域列表出现 `marketing`。 +- 文件系统生成 `webui/projects/demo/domains/marketing/`,内含 `domain.yaml` 和 `mcp_server/server.py`。 +- 头部「当前领域」可切到 `marketing`。 + +--- + +## 3. 第二步:把示例域改成你的领域 + +编辑 `webui/projects/demo/domains/marketing/domain.yaml`,把示例域的语言换成你的领域语言: + +```yaml +name: marketing +display_name: 营销方案 +persona: 你是一名资深的营销方案专家,擅长基于资料生成可落地的 launch 方案。 +categories: + market: [市场分析, 用户画像] + competitor: [竞品, 差异化] + strategy: [投放策略, 渠道] + copy: [文案, 卖点] +agent_initial_prompt: 请基于已蒸馏的营销技能,为新产品撰写一份 launch 营销方案。 +query_pool: + - 为新产品写一份 launch 营销方案 + - 分析竞品的营销策略差异 +mcp: + command: python + args: + - domains/marketing/mcp_server/server.py + - --workspace + - output/marketing_workspace + - --skills-dir + - skills_library/marketing + env: + R2S_DOMAIN: marketing +``` + +> **原理**:上面这些字段 100% 驱动 UI 标签、蒸馏的分类维度、Agent 的提示词。 +> **改这里就改了整个领域的"性格",前端一行不用动。** 这就是领域驱动——引擎只负责"读配置、跑流程"。 + +--- + +## 4. 第三步:加素材 + +进 `marketing` 的「素材管理」,上传你的 PDF / Word / MD(如《2024 营销白皮书.docx》《竞品分析.pdf》)。 +上传后写入 `fixtures/marketing/manifest.json`,默认 `enabled: true`。 + +> 只有 `enabled: true` 的素材参与蒸馏;可在素材列表里临时停用某份素材。 + +--- + +## 5. 第四步:蒸馏 + +「蒸馏工作台」选 `marketing` → **先勾 dry-run** 验证管线(不调 LLM,确认"抽取→切片→写技能库"通) +→ 取消勾,跑真实蒸馏。 + +产物落到 `webui/projects/demo/skills_library/marketing/index.json`,结构与其他域一致: + +```json +{ + "updated_at": "...", + "total": 42, + "skills": [ + {"skill_id":"...","skill_name":"...","category":"market", + "source_document":"2024 营销白皮书.docx","source_title":"...", + "detail_path":"market/xxx/skill.json"} + ] +} +``` + +**验证点**: + +- 「任务中心」日志出现"蒸馏完成 / N 条技能"。 +- `index.json` 的 `total` > 0。 +- 「素材管理」里能看到素材已被标记为"已蒸馏"。 + +--- + +## 6. 第五步:用 Agent 调用技能 + +「Agent 执行台」选 `marketing`,填任务如"基于素材为新产品写一份 launch 营销方案",点「运行」。 + +后端按 `domain.yaml` 的 `mcp:` 块自动拉起 `marketing` 的 MCP server(cwd = 项目根、 +`R2S_DOMAIN=marketing`),Agent 通过它召回知识并产出交付物到 +`webui/projects/demo/output/marketing_workspace/<产品>/`。 + +> **关于工具名**:克隆来的 `server.py` 工具函数名来自源域(本例为 `ppt`),例如知识召回类工具。 +> 它们内部按 `R2S_DOMAIN` 读取的是 `skills_library/marketing`,与源域名无关,**功能不受影响**。 +> 想要更"干净"的专属领域,直接编辑 `domains/marketing/mcp_server/server.py` 里的 `@mcp.tool` +> 函数即可——删掉不需要的、或把函数名改成你的领域语言。引擎只认 `R2S_DOMAIN` 隔离路径。 +> +> 这就是领域驱动架构的设计意图:**最小改动,拥有自己的领域**。 + +--- + +## 7. 原理小讲:引擎为什么能做到领域无关 + +- **领域由配置定义**:每个域有一份 `domain.yaml`(persona / categories / mcp / agent 提示词)和一个 + `mcp_server/server.py`。UI 与引擎不写死任何领域逻辑,只负责"读配置、跑流程"。 +- **领域隔离靠环境变量**:`agent.py` 在启动 Agent 时向 MCP 子进程注入 `R2S_DOMAIN` / + `R2S_SKILLS_DIR` / `R2S_WORKSPACE`,把技能库与产物落在本项目、本领域的目录里。 +- **播种时自动改写**:`projects.py` 的 `_rewrite_mcp_for_project` 对**所有带 `mcp` 块的域一视同仁** + 地改写 `domain.yaml`——注入 `R2S_DOMAIN` + 隔离路径,使同一份 server 代码可服务任意域。 +- **server 按领域参数化**:`mcp_server/server.py` 用 `_DOMAIN = os.environ.get("R2S_DOMAIN") or "ppt"` + 推算技能库 / 产物路径(`skills_library/<域>`、`output/<域>_workspace`),不写死领域名。 + +**结论**:新增领域 = 写 `domain.yaml` + (可选)`mcp_server`,**UI / 后端零改动**。 +仓库自带的 `ppt` 只是被这样配置出来的一个示例,不是特殊存在——任意领域都能复制这条路径。 + +--- + +## 8. 排错 + +| 现象 | 排查 | +|------|------| +| 新域 Agent 跑不起来 / MCP 启动即崩 | 检查 `domain.yaml` 的 `mcp.args[0]` 路径存在;`mcp.env.R2S_DOMAIN` 是否等于域名;`mcp>=1.26`(见 README §1.1) | +| 技能召回为空 | 确认蒸馏已完成且 `skills_library/<域>/index.json` 存在、`total>0`;素材 `enabled:true` | +| dry-run 通过但真跑 401 | LLM 模板 key 无效,先在「LLM 配置」点「测试」验证连通 | +| 换域后产物跑到了别的域目录 | 确认 `mcp.env.R2S_DOMAIN` 与该域一致(由 `_rewrite_mcp_for_project` 自动注入,一般无需手改) | + +--- + +## 9. 小结 + +你刚刚完成了一条**完全领域无关**的端到端链路:建域 → 配 `domain.yaml` → 加素材 → 蒸馏 → Agent 调用技能。 +全程没有碰前端 / 后端一行代码。这正是领域驱动设计的目标 —— **新增领域只需写配置,UI / 后端零改动**, +而 `ppt` 只是仓库自带的一个可被任意领域复制的示例。 diff --git a/core/collector.py b/core/collector.py index 96a79888..c51f0fd0 100644 --- a/core/collector.py +++ b/core/collector.py @@ -1,168 +1,259 @@ -""" -core/collector.py -Search YouTube for tutorial videos and build video manifests. - -Domain-agnostic: queries are passed in by caller or loaded from domain.yaml. - -Usage: - # Search and print results: - python -m core.collector "minecraft building tutorial" - - # Search multiple queries, save manifest: - python -m core.collector \ - "minecraft redstone tutorial" "minecraft auto farm" \ - -n 10 -o manifests/mc_skills_v1.json +"""通用采集器(切片3,领域无关)。 + +与 doc_extract.py 的关系: +- 复用其文本抽取(extract_text)、切片(chunk_text)、技能名解析(parse_skill_name)、 + 归类启发式(categorize)等纯逻辑; +- 但目录**参数化**:素材读项目 fixtures//,技能写项目 skills_library//, + 保持多项目隔离、零 core 全局副作用。doc_extract 本身领域无关,不含任何领域路径。 +- 蒸馏 LLM 传输由调用方注入的 llm_fn 提供(后端用环境变量 + call_azure_openai 实现), + 因此本模块不直接依赖 webui 配置,也不读写全局环境变量。 + +manifest 约定(与 webui/backend/fixtures.py 一致): + fixtures//manifest.json -> {"files": {文件名: {"enabled": bool, ...}}} + 仅 enabled 且磁盘存在的素材参与蒸馏。 """ from __future__ import annotations +import hashlib import json -import subprocess -import argparse +import re from datetime import datetime from pathlib import Path -_MANIFEST_DIR = Path(__file__).resolve().parent.parent / "manifests" - +# 复用 doc_extract 的纯逻辑(领域无关:抽取 / 切片 / 技能名解析 / 归类) +from doc_extract import ( + extract_text, + chunk_text, + parse_skill_name, + categorize, +) + +GENERIC_SYSTEM_PROMPT = """你是一名资深的知识蒸馏专家。下面是一段来自企业资料《{domain}》领域的原文节选。 +请将其蒸馏为一条"过程知识技能",必须严格按如下 Markdown 结构输出,且全文第一行必须是: + +**Skill Name**: <8-20 字以内的技能名> + +# 概述 +(一两句话说明这是什么知识/方法/模板) +# 适用阶段与场景 +# 关键角色与职责 +(用「角色: 职责」逐条列出,保留原文缩写) +# 标准流程/操作步骤 +(编号清单,要可执行) +# 核心交付物 +(清单) +# 模板与表单要点 +(关键字段/表格名) +# 决策评审/技术评审检查点 +(如适用) +# 常用工具与方法 +# 常见风险与注意事项 +# 来源标注 +(资料名) + +要求:内容必须来自原文、不得编造;保留原文专业术语与中英对照;步骤可执行;篇幅 600-1500 字。""" + + +def _load_manifest(fixtures_dir: Path) -> dict: + p = fixtures_dir / "manifest.json" + if p.exists(): + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("files"), dict): + return data + except Exception: # noqa: BLE001 + pass + return {"files": {}} -def search_youtube( - query: str, - max_results: int = 10, - min_duration: int = 60, - max_duration: int = 3600, -) -> list[dict]: - """Search YouTube and return video metadata. - Args: - query: Search query string. - max_results: Max number of results to fetch. - min_duration: Skip videos shorter than this (seconds). - max_duration: Skip videos longer than this (seconds). +def _extract_text(path: Path) -> str: + """抽取素材纯文本。 - Returns: - List of dicts with video metadata. + doc_extract.extract_text 仅覆盖 PDF/PPTX/DOCX/XLSX;MD/TXT 在其全局 SKIP_EXT + 中被故意跳过。通用采集器需支持文档类,故此处对 MD/TXT 直接读文本(UTF-8→GBK + 兜底),其余委托 doc_extract,保持不修改 core 全局行为。 """ - fetch_n = max_results * 3 - - cmd = [ - "yt-dlp", - f"ytsearch{fetch_n}:{query}", - "--flat-playlist", - "--no-download", - "--print", "%(id)s\t%(title)s\t%(duration)s\t%(view_count)s\t%(upload_date)s\t%(channel)s", - ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - if result.returncode != 0: - raise RuntimeError(f"yt-dlp failed: {result.stderr[:500]}") - - videos = [] - for line in result.stdout.strip().split("\n"): - if not line.strip(): + ext = path.suffix.lower() + if ext in (".md", ".txt"): + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError: + return path.read_text(encoding="gbk", errors="ignore") + return extract_text(path) + + +def iter_enabled_fixtures(fixtures_dir: Path) -> list[Path]: + """返回 manifest 中 enabled 且磁盘确实存在的素材,按文件名排序。""" + manifest = _load_manifest(fixtures_dir) + files = manifest.get("files", {}) + trash = fixtures_dir / ".trash" + out = [] + for p in sorted(fixtures_dir.iterdir()): + if not p.is_file() or p.name == "manifest.json": continue - parts = line.split("\t") - if len(parts) < 6: + if str(p).startswith(str(trash)): continue + meta = files.get(p.name, {}) + if meta.get("enabled", True): + out.append(p) + return out + + +def _read_system_prompt(domain_dir: Path, domain: str) -> str: + dp = domain_dir / "distiller_prompt.md" + if dp.exists(): + txt = dp.read_text(encoding="utf-8", errors="ignore").strip() + if txt: + return txt + return GENERIC_SYSTEM_PROMPT.format(domain=domain) + + +def _store_skill(skills_dir: Path, analysis: str, *, title: str, category: str, + rel_path: str, idx: int, total: int, domain: str, index: dict) -> bool: + skill_name = parse_skill_name(analysis, fallback=title) + base = re.sub(r"[^a-z0-9]+", "_", title.lower())[:24] + sid = f"{base}_{idx}_{hashlib.md5((title + analysis[:120]).encode()).hexdigest()[:6]}" + detail = { + "skill_id": sid, + "skill_name": skill_name, + "domain": domain, + "category": category, + "source": {"type": "document", "path": rel_path, "title": title, + "chunk": idx, "chunks": total}, + "extracted_at": datetime.now().isoformat(), + "analysis": analysis, + "frames": [], + } + cat_dir = skills_dir / category / sid + cat_dir.mkdir(parents=True, exist_ok=True) + (cat_dir / "skill.json").write_text( + json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8" + ) + existing = {s["skill_id"] for s in index["skills"]} + if sid not in existing: + index["skills"].append({ + "skill_id": sid, + "skill_name": skill_name, + "category": category, + "source_document": rel_path, + "source_title": title, + "detail_path": f"{category}/{sid}/skill.json", + }) + return True - vid, title, dur_str, views_str, upload_date, channel = parts - try: - duration = int(float(dur_str)) if dur_str and dur_str != "NA" else 0 - except (ValueError, TypeError): - duration = 0 +def _save_index(skills_dir: Path, index: dict) -> None: + skills_dir.mkdir(parents=True, exist_ok=True) + index["updated_at"] = datetime.now().isoformat() + index["total"] = len(index["skills"]) + (skills_dir / "index.json").write_text( + json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8" + ) - if duration < min_duration or duration > max_duration: - continue +def _load_index(skills_dir: Path) -> dict: + p = skills_dir / "index.json" + if p.exists(): try: - views = int(views_str) if views_str and views_str != "NA" else 0 - except (ValueError, TypeError): - views = 0 - - videos.append({ - "url": f"https://www.youtube.com/watch?v={vid}", - "video_id": vid, - "title": title, - "duration_sec": duration, - "views": views, - "upload_date": upload_date or "", - "channel": channel or "", - "query": query, - }) - - if len(videos) >= max_results: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict) and "skills" in data: + return data + except Exception: # noqa: BLE001 + pass + return {"updated_at": "", "total": 0, "skills": []} + + +def distill_document( + path: Path, + *, + skills_dir: Path, + domain: str, + system_prompt: str, + llm_fn, + index: dict, + on_log=print, + dry_run: bool = False, + model: str = "deepseek-v4-pro", + max_tokens: int = 6000, + reasoning: str = "low", +) -> dict: + """蒸馏单个素材文件(可能多段),写入技能库。返回 {added, skipped}。""" + rel = f"fixtures/{domain}/{path.name}" + category = categorize(path) + text = _extract_text(path) + chunks = chunk_text(text) + if not chunks: + on_log(f"[跳过] {path.name}:无可用文本(可能需 OCR / 不支持格式)") + return {"added": 0, "skipped": 1} + total = len(chunks) + on_log(f"[素材] {path.name} → 类别={category},切片 {total} 段") + added = 0 + for i, ch in enumerate(chunks, 1): + user = ( + f"【资料来源】《{path.stem}》\n【归类类别】{category}\n" + f"【节选段落】第 {i}/{total} 段\n\n以下为原文节选:\n\n{ch}" + ) + if dry_run: + analysis = ( + f"**Skill Name**: {path.stem} 草稿(dry-run 未调用 LLM)\n\n" + "# 概述\n(dry-run 占位,未调用 LLM)\n# 适用阶段与场景\n# 关键角色与职责\n" + "# 标准流程/操作步骤\n# 核心交付物\n# 模板与表单要点\n# 决策评审/技术评审检查点\n" + "# 常用工具与方法\n# 常见风险与注意事项\n# 来源标注\n" + path.name + ) + else: + try: + msg = llm_fn( + [{"role": "system", "content": system_prompt}, + {"role": "user", "content": user}], + model=model, max_tokens=max_tokens, reasoning_effort=reasoning, + ) + analysis = (msg.get("content") if isinstance(msg, dict) else str(msg) or "").strip() + except Exception as e: # noqa: BLE001 + on_log(f"[错误] {path.name} 段{i}/{total} 蒸馏失败:{e}") + continue + if not analysis: + on_log(f"[警告] {path.name} 段{i}/{total} 返回空,跳过") + continue + _store_skill(skills_dir, analysis, title=path.stem, category=category, rel_path=rel, + idx=i, total=total, domain=domain, index=index) + added += 1 + on_log(f"[OK] {path.name} 段{i}/{total} → {parse_skill_name(analysis, path.stem)}") + return {"added": added, "skipped": 0} + + +def run_collect( + *, + fixtures_dir: Path, + skills_dir: Path, + domain_dir: Path, + domain: str, + llm_fn, + on_log=print, + dry_run: bool = False, + model: str = "deepseek-v4-pro", + max_tokens: int = 6000, + reasoning: str = "low", + stop_check=None, +) -> dict: + """通用采集主流程。返回 {added, skipped, total_skills}。""" + system_prompt = _read_system_prompt(domain_dir, domain) + index = _load_index(skills_dir) + sources = iter_enabled_fixtures(fixtures_dir) + on_log(f"通用采集:发现 enabled 素材 {len(sources)} 个(领域={domain})") + added = 0 + skipped = 0 + for path in sources: + if stop_check and stop_check(): + on_log("[停止] 收到终止信号,提前结束") break - - return videos - - -def multi_search( - queries: list[str], - max_per_query: int = 10, - **kwargs, -) -> list[dict]: - """Run multiple queries and deduplicate results by video ID.""" - seen_ids = set() - all_videos = [] - - for q in queries: - print(f"Searching: {q!r}") - results = search_youtube(q, max_results=max_per_query, **kwargs) - for v in results: - if v["video_id"] not in seen_ids: - seen_ids.add(v["video_id"]) - all_videos.append(v) - print(f" Found {len(results)} results ({len(all_videos)} total unique)") - - all_videos.sort(key=lambda v: v["views"], reverse=True) - return all_videos - - -def save_manifest(videos: list[dict], path: str | Path) -> Path: - """Save a video list as a JSON manifest.""" - p = Path(path) - p.parent.mkdir(parents=True, exist_ok=True) - manifest = { - "created_at": datetime.now().isoformat(), - "total": len(videos), - "videos": videos, - } - p.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8") - return p - - -def main(): - ap = argparse.ArgumentParser(description="Search YouTube for tutorial videos") - ap.add_argument("queries", nargs="+", help="Search queries") - ap.add_argument("-n", "--max-results", type=int, default=10, help="Max results per query") - ap.add_argument("--min-duration", type=int, default=60, help="Min video duration (sec)") - ap.add_argument("--max-duration", type=int, default=3600, help="Max video duration (sec)") - ap.add_argument("-o", "--output", default=None, help="Save manifest to file") - args = ap.parse_args() - - videos = multi_search( - args.queries, - max_per_query=args.max_results, - min_duration=args.min_duration, - max_duration=args.max_duration, - ) - - print(f"\n{'='*60}") - print(f"Total unique videos: {len(videos)}") - print(f"{'='*60}") - for i, v in enumerate(videos, 1): - dur_min = v['duration_sec'] // 60 - dur_sec = v['duration_sec'] % 60 - print(f" {i:2d}. [{dur_min}:{dur_sec:02d}] ({v['views']:>10,} views) {v['title']}") - print(f" {v['url']}") - - if args.output: - p = save_manifest(videos, args.output) - print(f"\nManifest saved: {p}") - else: - default_path = _MANIFEST_DIR / f"search_{datetime.now():%Y%m%d_%H%M%S}.json" - p = save_manifest(videos, default_path) - print(f"\nManifest saved: {p}") - - -if __name__ == "__main__": - main() + res = distill_document( + path, skills_dir=skills_dir, domain=domain, system_prompt=system_prompt, + llm_fn=llm_fn, index=index, on_log=on_log, dry_run=dry_run, + model=model, max_tokens=max_tokens, reasoning=reasoning, + ) + added += res["added"] + skipped += res["skipped"] + _save_index(skills_dir, index) + _save_index(skills_dir, index) + on_log(f"采集完成:新增技能 {added} 条,跳过 {skipped} 个,技能库现有 {len(index['skills'])} 条") + return {"added": added, "skipped": skipped, "total_skills": len(index["skills"])} diff --git a/core/llm.py b/core/llm.py index 9fa32538..11d0c918 100644 --- a/core/llm.py +++ b/core/llm.py @@ -75,6 +75,25 @@ "gpt-5.4-pro": ("medium", "high", "xhigh"), } +# --------------------------------------------------------------------------- +# Provider selection: Azure OpenAI vs OpenAI-compatible backends (DeepSeek…) +# --------------------------------------------------------------------------- +# Set LLM_PROVIDER=deepseek (or "openai_compatible" / "openai") to route all +# chat/completions calls through the OpenAI-compatible shape instead of the +# Azure-specific /openai/deployments/{dep}/chat/completions?api-version= path. +# When LLM_PROVIDER is unset, behaviour is 100% Azure (unchanged). +_DEFAULT_PROVIDER = "azure" + + +def _provider() -> str: + """Return the active LLM provider ('azure' by default).""" + return os.environ.get("LLM_PROVIDER", _DEFAULT_PROVIDER).strip().lower() + + +def _is_azure() -> bool: + """True when using Azure OpenAI (the default).""" + return _provider() == "azure" + def _model_env_suffix(model: str) -> str: """Map "gpt-5.5" -> "55", "gpt-5.4-pro" -> "54PRO" for per-model env vars.""" @@ -176,7 +195,11 @@ def _resolve_headers(model: str | None = None) -> dict: "AZURE_OPENAI_API_KEY not set. " "Set it or use AZURE_OPENAI_USE_AAD=1 for AAD auth." ) - headers["api-key"] = api_key + if _is_azure(): + headers["api-key"] = api_key + else: + # OpenAI-compatible backends (DeepSeek, etc.) use Bearer auth. + headers["Authorization"] = f"Bearer {api_key}" return headers @@ -461,20 +484,63 @@ def call_azure_openai( api_version = _resolve_api_version(model) headers = _resolve_headers(model) - url = f"{endpoint}openai/deployments/{deployment}/chat/completions?api-version={api_version}" - - payload: dict[str, Any] = { - "messages": messages, - "max_completion_tokens": max_completion_tokens, - } - - if tools: - payload["tools"] = tools - payload["tool_choice"] = tool_choice or "auto" - payload["parallel_tool_calls"] = False # Force sequential, one at a time + if _is_azure(): + url = f"{endpoint}openai/deployments/{deployment}/chat/completions?api-version={api_version}" + payload: dict[str, Any] = { + "messages": messages, + "max_completion_tokens": max_completion_tokens, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = tool_choice or "auto" + payload["parallel_tool_calls"] = False # Force sequential, one at a time + if reasoning_effort and reasoning_effort != "none": + payload["reasoning_effort"] = reasoning_effort + else: + # OpenAI-compatible backend (DeepSeek V4, local proxies, …): + # - chat/completions endpoint. DeepSeek uses /chat/completions + # WITHOUT the /v1 prefix; generic OpenAI-compatible servers use + # /v1/chat/completions. Override with OAI_COMPAT_CHAT_PATH. + # - Bearer auth (set in _resolve_headers) + # - "model" goes in the body, not the URL + # - param "max_completion_tokens" -> "max_tokens" (clamped to a cap) + # - DeepSeek V4 supports reasoning_effort + thinking (unlike the + # retired deepseek-chat), so we forward them. + provider = _provider() + if os.environ.get("OAI_COMPAT_CHAT_PATH"): + chat_path = os.environ["OAI_COMPAT_CHAT_PATH"] + elif provider == "deepseek": + chat_path = "chat/completions" # DeepSeek: no /v1 prefix + else: + chat_path = "v1/chat/completions" # generic OpenAI-compatible + url = f"{endpoint.rstrip('/')}/{chat_path}" - if reasoning_effort and reasoning_effort != "none": - payload["reasoning_effort"] = reasoning_effort + # DeepSeek V4 can emit very large outputs (up to 384K tokens); raise + # the cap for it. Other backends keep a conservative default. + if provider == "deepseek": + cap = int(os.environ.get("OAI_COMPAT_MAX_TOKENS", "32768")) + else: + cap = int(os.environ.get("OAI_COMPAT_MAX_TOKENS", "8192")) + + payload: dict[str, Any] = { + "model": deployment, + "messages": messages, + "max_tokens": min(max_completion_tokens, cap), + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = tool_choice or "auto" + # NB: we deliberately do NOT send parallel_tool_calls to DeepSeek; + # its chat/completions rejects unknown params in some modes. + + # DeepSeek V4: reasoning_effort is supported (low/medium map to high, + # xhigh maps to max server-side). thinking defaults to enabled. + if reasoning_effort and reasoning_effort != "none": + payload["reasoning_effort"] = reasoning_effort + payload["thinking"] = {"type": "enabled"} + else: + # explicit "none" => disable thinking (non-reasoning mode) + payload["thinking"] = {"type": "disabled"} last_error = "" for attempt in range(1, max_retries + 1): diff --git a/core/skill_wiki/registry.py b/core/skill_wiki/registry.py index 7c0c361f..2d672eb2 100644 --- a/core/skill_wiki/registry.py +++ b/core/skill_wiki/registry.py @@ -22,7 +22,17 @@ from __future__ import annotations import errno -import fcntl +try: + import fcntl +except ImportError: # fcntl is POSIX-only; provide a no-op shim for Windows. + class _FcntlShim: + LOCK_EX = 0 + LOCK_UN = 0 + + @staticmethod + def flock(fd, operation): # noqa: D401 + return None + fcntl = _FcntlShim() # type: ignore[assignment] import json import os import re diff --git a/doc_extract.py b/doc_extract.py new file mode 100644 index 00000000..565591d9 --- /dev/null +++ b/doc_extract.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""doc_extract.py — generic document text extraction & chunking helpers (domain-agnostic). + +This is the domain-neutral extraction layer used by the generic collector +(core/collector.py) and the WebUI fixture-preview endpoint. It deliberately +contains NO domain-specific logic (no hard-coded domain paths, keyword tables, or prompts) — +domain behaviour lives in the caller (e.g. core/collector.py's +GENERIC_SYSTEM_PROMPT, or a domain's own distiller_prompt.md). + +Supported source formats: .pdf .pptx .docx .xlsx +""" +from __future__ import annotations + +import re +import logging +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("doc_extract") + +CHUNK = 16000 +OVERLAP = 1200 +MAX_CHUNKS_PER_FILE = 30 + +# Formats that cannot be reliably extracted in this environment / are not sources. +SKIP_EXT = {".doc", ".ppt", ".vsd", ".jpg", ".jpeg", ".png", ".gif", ".bmp", + ".tif", ".vsdx", ".md", ".xls"} + + +def _slug(s: str) -> str: + """ASCII/CJK-safe slug used for category folder names.""" + s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "_", s.lower()).strip("_") + return s[:24] or "general" + + +def categorize(path: Path) -> str: + """Best-effort category for a source file, fully domain-agnostic. + + Uses the parent folder name (e.g. a sub-folder organising fixtures) as the + category; falls back to "general". No domain keyword tables. + """ + parent = path.parent.name + if parent and parent.lower() not in ("fixtures", "resources", ""): + return _slug(parent) + return "general" + + +# --------------------------------------------------------------------------- +# text extraction +# --------------------------------------------------------------------------- + +def extract_pdf(path: Path) -> str: + import fitz # PyMuPDF + out = [] + doc = fitz.open(str(path)) + for page in doc: + out.append(page.get_text()) + return "\n".join(out) + + +def extract_pptx(path: Path) -> str: + from pptx import Presentation + p = Presentation(str(path)) + out = [] + for slide in p.slides: + for shape in slide.shapes: + if shape.has_table: + for row in shape.table.rows: + out.append(" | ".join(c.text for c in row.cells)) + if shape.has_text_frame: + txt = shape.text_frame.text.strip() + if txt: + out.append(txt) + return "\n".join(out) + + +def extract_docx(path: Path) -> str: + # python-docx parses paragraphs/tables robustly, including OCR-generated + # malformed XML; zipfile+regex is a fallback. + try: + from docx import Document + d = Document(str(path)) + parts = [p.text for p in d.paragraphs] + for tb in d.tables: + for row in tb.rows: + parts.append(" | ".join(c.text for c in row.cells)) + return "\n".join(parts) + except Exception as e: # noqa: BLE001 + log.warning("python-docx 抽取失败 %s,回退 zipfile: %s", path.name, e) + try: + import zipfile + z = zipfile.ZipFile(str(path)) + xml = z.read("word/document.xml").decode("utf-8", "ignore") + texts = re.findall(r"]*>(.*?)", xml, re.S) + return "".join(texts) + except Exception as e2: # noqa: BLE001 + log.warning("zipfile 回退也失败 %s: %s", path.name, e2) + return "" + + +def extract_xlsx(path: Path) -> str: + from openpyxl import load_workbook + wb = load_workbook(str(path), read_only=True, data_only=True) + out = [] + for ws in wb.worksheets: + for row in ws.iter_rows(values_only=True): + cells = ["" if c is None else str(c) for c in row] + line = " | ".join(cells).strip() + if line: + out.append(line) + return "\n".join(out) + + +EXTRACTORS = {".pdf": extract_pdf, ".pptx": extract_pptx, + ".docx": extract_docx, ".xlsx": extract_xlsx} + + +def extract_text(path: Path) -> str: + ext = path.suffix.lower() + fn = EXTRACTORS.get(ext) + if not fn: + return "" + try: + return fn(path) + except Exception as e: # noqa: BLE001 + log.warning("抽取失败 %s: %s", path.name, e) + return "" + + +def chunk_text(text: str, max_chunks: int = MAX_CHUNKS_PER_FILE) -> list[str]: + text = re.sub(r"\s+\n", "\n", text).strip() + if len(text) <= CHUNK: + return [text] if text else [] + chunks = [] + start = 0 + while start < len(text) and len(chunks) < max_chunks: + end = min(start + CHUNK, len(text)) + chunks.append(text[start:end]) + if end == len(text): + break + start = end - OVERLAP + return chunks + + +def parse_skill_name(text: str, fallback: str) -> str: + m = re.search(r"\*\*Skill Name\*\*[::]\s*(.+)", text) + if m: + name = re.sub(r"[*`\[\]]+", "", m.group(1)).strip() + return name or fallback + return fallback diff --git a/webui/.gitignore b/webui/.gitignore new file mode 100644 index 00000000..5a1094fc --- /dev/null +++ b/webui/.gitignore @@ -0,0 +1,7 @@ +backend/.key +backend/__pycache__/ +webui_config.json +projects/ +frontend/node_modules/ +frontend/dist/ +*.pyc diff --git a/webui/__init__.py b/webui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/webui/backend/__init__.py b/webui/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/webui/backend/agent.py b/webui/backend/agent.py new file mode 100644 index 00000000..0788d307 --- /dev/null +++ b/webui/backend/agent.py @@ -0,0 +1,177 @@ +"""Agent 执行台(切片4)。 + +设计要点: +- 复用 tasks.py 单 worker 串行队列 + distill._llm_env 的 LLM 注入模式。 +- run_agent 内部直接调用 core.llm.call_azure_openai(不接收外部 llm_fn), + 且会 spawn MCP 子进程(子进程从 os.environ 读取 LLM 凭证)。因此必须: + 1) 在「整段 agent 运行期间」保持 LLM 环境变量(用 with _llm_env 包裹 run_agent), + 这样 agent 循环与 MCP 子进程都能正确拿到 endpoint/key; + 2) 把 cwd 切到项目根(与 create_domain 已重写的 mcp.cwd 协同)。 +- 对所有领域注入 R2S_DOMAIN / R2S_SKILLS_DIR / R2S_WORKSPACE 环境变量(领域无关), + 实现产物隔离;MCP server 据此把技能库与产物落到本项目数据目录(output/_workspace)。 +- 把 run_agent 的 library_dir 指向项目 skills_library/,使 agent 自身技能召回隔离。 +- 通过 logging.Handler 把 agent_executor 的日志实时灌入 task.log(前端轮询即可看到流式进度)。 +""" +from __future__ import annotations + +import logging +import os +import json +from pathlib import Path + +from core import load_domain +from core.agent_executor import run_agent + +from . import config_store, projects +from .distill import _llm_env + + +class _TaskLogHandler(logging.Handler): + """把 agent_executor logger 的输出实时写入 task.log。""" + + def __init__(self, task): + super().__init__() + self._task = task + + def emit(self, record: logging.LogRecord) -> None: + try: + self._task.append_log(self.format(record)) + except Exception: # noqa: BLE001 + pass + + +def _safe_domain_config(project: str, domain: str) -> dict: + """加载项目 domains//domain.yaml(优先),失败回退仓库域。""" + proj_domains = projects.project_dir(project) / "domains" + try: + return load_domain(domain, domains_dir=proj_domains) + except Exception: + # 项目未播种该领域时回退仓库自带领域配置 + return load_domain(domain) + + +def run_agent_task( + task, + project: str, + domain: str, + *, + task_text: str, + model: str, + reasoning: str, + max_iter: int, + n_skills: int, + top_k: int, + dry_run: bool, +) -> None: + cfg = config_store.load() + template = cfg.templates.get(cfg.active_llm) + pdir = projects.project_dir(project) + skills_dir = pdir / "skills_library" / domain + + if not dry_run: + if not template or not template.api_key or not template.endpoint or not template.model: + task.append_log("[错误] 未配置有效 LLM(endpoint/api_key/model 缺失)。请先在「LLM 配置」页配置并测试。") + raise RuntimeError("LLM 未配置") + + task.append_log(f"[Agent] 项目={project} 领域={domain} dry_run={dry_run}") + task.append_log(f"[Agent] 技能库={skills_dir}") + + try: + domain_config = _safe_domain_config(project, domain) + except Exception as e: + task.append_log(f"[错误] 加载领域配置失败:{type(e).__name__}: {e}") + raise + + skills_dir.mkdir(parents=True, exist_ok=True) + + # 选用模型/推理档位:请求指定优先,否则用 LLM 模板值 + resolved_model = model or (template.model if template else "gpt-5.4") + resolved_reasoning = reasoning or (template.reasoning if template else "low") + + # 捕获 agent_executor 日志 -> task.log + agent_logger = logging.getLogger("agent_executor") + handler = _TaskLogHandler(task) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%H:%M:%S")) + agent_logger.addHandler(handler) + agent_logger.setLevel(logging.INFO) + + saved_cwd = os.getcwd() + r2s_env = ("R2S_DOMAIN", "R2S_SKILLS_DIR", "R2S_WORKSPACE") + saved_env = {k: os.environ.get(k) for k in r2s_env} + try: + os.chdir(str(pdir)) + # 领域无关:所有域都注入 R2S_DOMAIN / 技能库 / 产物工作区, + # MCP server 据此把技能库与产物落到本项目数据目录(output/_workspace)。 + os.environ["R2S_DOMAIN"] = domain + os.environ["R2S_SKILLS_DIR"] = str(skills_dir) + os.environ["R2S_WORKSPACE"] = str(pdir / "output" / f"{domain}_workspace") + + # 预检连通(非 dry_run):避免无效 key 触发 agent 内长时间重试空耗 + if not dry_run and template: + with _llm_env(template): + from core.llm import call_azure_openai + + call_azure_openai( + [{"role": "user", "content": "ping"}], + model=template.model, + max_completion_tokens=5, + reasoning_effort="none", + timeout=30, + ) + + # 整段 agent 运行期间保持 LLM 环境(agent 循环 + MCP 子进程均依赖 os.environ) + with _llm_env(template): + result = run_agent( + task_text, + domain_config, + skills_dir, + model=resolved_model, + reasoning_effort=resolved_reasoning, + max_iterations=max_iter, + n_skills=n_skills, + top_k=top_k, + dry_run=dry_run, + ) + + task.append_log("=== Agent 完成 ===") + task.append_log(result.summary()) + if result.final_message: + fm = result.final_message if isinstance(result.final_message, str) else str(result.final_message) + task.append_log(f"Final: {fm[:800]}{'...' if len(fm) > 800 else ''}") + + def _clip(v, n=400): + if v is None: + return "" + if isinstance(v, str): + return v[:n] + try: + return json.dumps(v, ensure_ascii=False)[:n] + except Exception: # noqa: BLE001 + return str(v)[:n] + + tool_calls = [ + { + "tool": tc.tool_name, + "success": bool(tc.success), + "args": _clip(tc.arguments), + "error": _clip(tc.error), + } + for tc in result.tool_calls + ] + task.extra = { + "success": result.success, + "iterations": result.iterations, + "tool_calls": tool_calls, + "final_message": result.final_message, + "domain": domain, + "project": project, + } + task.append_log(f"[工具调用] 共 {len(tool_calls)} 次") + finally: + os.chdir(saved_cwd) + agent_logger.removeHandler(handler) + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v diff --git a/webui/backend/config_store.py b/webui/backend/config_store.py new file mode 100644 index 00000000..75d4c3da --- /dev/null +++ b/webui/backend/config_store.py @@ -0,0 +1,47 @@ +import json +from pathlib import Path + +from .models import LLMTemplate, WebuiConfig +from .security import encrypt, decrypt + +CONFIG_PATH = Path(__file__).resolve().parent.parent / "webui_config.json" +DEFAULT_PROJECTS_ROOT = Path(__file__).resolve().parent.parent / "projects" + + +def mask_key(key: str) -> str: + if not key: + return "" + if len(key) <= 8: + return "****" + return key[:3] + "*" * (len(key) - 7) + key[-4:] + + +def load() -> WebuiConfig: + if CONFIG_PATH.exists(): + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + for t in data.get("templates", {}).values(): + enc = t.pop("api_key_enc", None) + t["api_key"] = decrypt(enc) if enc else "" + return WebuiConfig(**data) + cfg = WebuiConfig(projects_root=str(DEFAULT_PROJECTS_ROOT)) + save(cfg) + return cfg + + +def save(cfg: WebuiConfig) -> None: + data = cfg.model_dump() + for t in data.get("templates", {}).values(): + if t.get("api_key"): + t["api_key_enc"] = encrypt(t["api_key"]) + t.pop("api_key", None) + CONFIG_PATH.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +def public_template(name: str, t: LLMTemplate, active: str) -> dict: + d = t.model_dump() + d.pop("api_key", None) + d["api_key_masked"] = mask_key(t.api_key) + d["is_active"] = name == active + return d diff --git a/webui/backend/distill.py b/webui/backend/distill.py new file mode 100644 index 00000000..7a439b72 --- /dev/null +++ b/webui/backend/distill.py @@ -0,0 +1,128 @@ +"""蒸馏任务(切片3)。 + +把 WebUI 配置的 active LLM 模板,通过环境变量注入喂给 core.llm.call_azure_openai, +再调用 core.collector.run_collect 完成通用采集蒸馏。dry_run 模式跳过 LLM,仅验证管线。 +""" +from __future__ import annotations + +import contextlib +import os +from pathlib import Path + +from core import collector +from core.llm import call_azure_openai + +from . import config_store, projects + +# 需要临时写入并事后还原的环境变量(call_azure_openai 仅从这些读配置) +_ENV_KEYS = [ + "LLM_PROVIDER", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_KEY", + "OAI_COMPAT_CHAT_PATH", "HTTPS_PROXY", "HTTP_PROXY", +] + + +@contextlib.contextmanager +def _llm_env(template): + saved = {k: os.environ.get(k) for k in _ENV_KEYS} + try: + provider = (template.provider or "azure").lower() + os.environ["AZURE_OPENAI_API_KEY"] = template.api_key or "" + os.environ["AZURE_OPENAI_ENDPOINT"] = template.endpoint or "" + if provider == "azure": + os.environ["LLM_PROVIDER"] = "azure" + elif provider == "deepseek": + os.environ["LLM_PROVIDER"] = "deepseek" + else: + # openai / ollama / custom 等:按 OpenAI 兼容处理, + # 用户 endpoint 需自带 /v1(如 https://api.openai.com/v1 或 http://host:11434/v1) + os.environ["LLM_PROVIDER"] = "openai_compatible" + os.environ["OAI_COMPAT_CHAT_PATH"] = "chat/completions" + if template.proxy: + os.environ["HTTPS_PROXY"] = template.proxy + os.environ["HTTP_PROXY"] = template.proxy + else: + # 模板未指定代理 → 走直连。显式清除可能从启动 shell 继承的系统代理 + # (如 HTTP_PROXY=127.0.0.1:10810),否则请求会被强制路由到本地代理, + # 代理挂掉时整条 LLM 链路直接 ProxyError 失败。已验证 deepseek 直连可达。 + for _pk in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"): + os.environ.pop(_pk, None) + os.environ["NO_PROXY"] = "*" + yield + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def run_distill_task(task, project: str, domain: str, dry_run: bool) -> None: + cfg = config_store.load() + template = cfg.templates.get(cfg.active_llm) + fixtures_dir = projects.project_dir(project) / "fixtures" / domain + skills_dir = projects.project_dir(project) / "skills_library" / domain + domain_dir = projects.project_dir(project) / "domains" / domain + + if not fixtures_dir.exists(): + raise FileNotFoundError(f"素材目录不存在:{domain}(请先上传素材)") + + if dry_run: + task.append_log("[dry-run] 不调用 LLM,仅验证 抽取→切片→写技能库 管线") + llm_fn = lambda *a, **k: {"content": "x"} + model = "dry-run" + max_tokens = 6000 + reasoning = "low" + else: + if not template or not template.api_key or not template.endpoint or not template.model: + task.append_log("[错误] 未配置有效 LLM(endpoint/api_key/model 缺失)。可勾选 dry-run 仅验证管线。") + raise RuntimeError("LLM 未配置") + task.append_log(f"[LLM] 模板={cfg.active_llm} 模型={template.model} provider={template.provider}") + + # 预检:先用一次极简 ping 验证 endpoint/key 连通,避免无效 key 触发长时间重试空耗。 + def _ping(): + with _llm_env(template): + return call_azure_openai( + [{"role": "user", "content": "ping"}], + model=template.model, + max_completion_tokens=5, + reasoning_effort="none", + timeout=30, + ) + try: + _ping() + except Exception as e: # noqa: BLE001 + task.append_log(f"[错误] LLM 连通性预检失败:{type(e).__name__}: {e}") + task.append_log("[提示] 请先在「LLM 配置」页用「测试」按钮验证连通,再开始蒸馏。") + raise + + def llm_fn(messages, **_kw): + with _llm_env(template): + return call_azure_openai( + messages, + model=template.model, + max_completion_tokens=template.max_tokens, + reasoning_effort=template.reasoning, + timeout=180, + ) + + model = template.model + max_tokens = template.max_tokens + reasoning = template.reasoning + + summary = collector.run_collect( + fixtures_dir=fixtures_dir, + skills_dir=skills_dir, + domain_dir=domain_dir, + domain=domain, + llm_fn=llm_fn, + on_log=task.append_log, + dry_run=dry_run, + model=model, + max_tokens=max_tokens, + reasoning=reasoning, + stop_check=task.should_stop, + ) + task.summary = summary + task.append_log( + f"[完成] 新增 {summary['added']} 条,跳过 {summary['skipped']} 个,技能库现有 {summary['total_skills']} 条" + ) diff --git a/webui/backend/fixtures.py b/webui/backend/fixtures.py new file mode 100644 index 00000000..af17a320 --- /dev/null +++ b/webui/backend/fixtures.py @@ -0,0 +1,178 @@ +"""素材管理(切片2)。 + +设计原则(与切片1一致,零 core 重构): +- 素材落地到项目的 fixtures//。 +- 启用/停用由本模块引入的 manifest.json 记录(R2S core 本身不消费 fixtures, + 该 manifest 是 WebUI 侧的契约,后续切片3 通用 collect 会读取它来筛选素材)。 +- 删除走软删除(移动到 fixtures//.trash/),沙箱禁止硬删,且可恢复。 +- 预览复用 doc_extract.extract_text 的抽取逻辑(不跑蒸馏,仅抽纯文本), + MD/TXT 直接读文本;无抽取器或抽取为空时返回友好提示。 +- 文件名一律做 Path.name 归一 + 路径穿越校验,避免 ../ 攻击。 +""" +from __future__ import annotations + +import json +import shutil +from datetime import datetime +from pathlib import Path + +from . import config_store, projects + +ALLOWED_EXT = {".pdf", ".docx", ".pptx", ".xlsx", ".md", ".txt"} +PREVIEW_LIMIT = 8000 + + +def _safe_name(name: str) -> str: + base = Path(name).name + if not base or base != name or "/" in name or "\\" in name or name.startswith(".."): + raise ValueError(f"非法文件名:{name}") + return base + + +def fixtures_dir(project: str, domain: str) -> Path: + return projects.project_dir(project) / "fixtures" / domain + + +def manifest_path(project: str, domain: str) -> Path: + return fixtures_dir(project, domain) / "manifest.json" + + +def _load_manifest(project: str, domain: str) -> dict: + p = manifest_path(project, domain) + if p.exists(): + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("files"), dict): + return data + except Exception: # noqa: BLE001 + pass + return {"updated_at": "", "files": {}} + + +def _save_manifest(project: str, domain: str, data: dict) -> None: + data["updated_at"] = datetime.now().isoformat(timespec="seconds") + manifest_path(project, domain).write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +def list_fixtures(project: str, domain: str) -> list[dict]: + fdir = fixtures_dir(project, domain) + if not fdir.exists(): + return [] + manifest = _load_manifest(project, domain) + files = manifest.setdefault("files", {}) + # 目录真实文件(排除 manifest 与 .trash) + on_disk = [ + p for p in sorted(fdir.iterdir()) + if p.is_file() and p.name != "manifest.json" + ] + trash = fdir / ".trash" + on_disk = [p for p in on_disk if not str(p).startswith(str(trash))] + out = [] + seen = set() + for p in on_disk: + seen.add(p.name) + meta = files.get(p.name, {}) + enabled = bool(meta.get("enabled", True)) + files.setdefault(p.name, { + "enabled": enabled, + "size": p.stat().st_size, + "uploaded_at": meta.get("uploaded_at", ""), + }) + out.append({ + "name": p.name, + "ext": p.suffix.lower(), + "size": p.stat().st_size, + "enabled": enabled, + "uploaded_at": files[p.name]["uploaded_at"], + }) + # 清理 manifest 中已不存在的文件条目 + for gone in [k for k in files if k not in seen]: + del files[gone] + _save_manifest(project, domain, manifest) + return out + + +def save_fixture(project: str, domain: str, filename: str, content: bytes) -> dict: + name = _safe_name(filename) + ext = Path(name).suffix.lower() + if ext not in ALLOWED_EXT: + raise ValueError(f"不支持的素材类型:{ext}(允许:{', '.join(sorted(ALLOWED_EXT))})") + fdir = fixtures_dir(project, domain) + if not fdir.exists(): + raise FileNotFoundError(f"领域素材目录不存在:{domain}(请先创建领域)") + dest = fdir / name + dest.write_bytes(content) + manifest = _load_manifest(project, domain) + manifest.setdefault("files", {})[name] = { + "enabled": True, + "size": len(content), + "uploaded_at": datetime.now().isoformat(timespec="seconds"), + } + _save_manifest(project, domain, manifest) + return {"name": name, "size": len(content), "enabled": True} + + +def set_enabled(project: str, domain: str, filename: str, enabled: bool) -> dict: + name = _safe_name(filename) + fdir = fixtures_dir(project, domain) + if not (fdir / name).exists(): + raise FileNotFoundError(f"素材不存在:{name}") + manifest = _load_manifest(project, domain) + meta = manifest.setdefault("files", {}).setdefault(name, {}) + meta["enabled"] = bool(enabled) + _save_manifest(project, domain, manifest) + return {"name": name, "enabled": bool(enabled)} + + +def delete_fixture(project: str, domain: str, filename: str) -> None: + name = _safe_name(filename) + fdir = fixtures_dir(project, domain) + src = fdir / name + if not src.exists(): + raise FileNotFoundError(f"素材不存在:{name}") + # 软删除:移动到 .trash(沙箱禁止 rmtree,且可恢复) + trash = fdir / ".trash" + trash.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d%H%M%S") + dest = trash / f"{name}_{ts}" + i = 1 + while dest.exists(): + dest = trash / f"{name}_{ts}_{i}" + i += 1 + shutil.move(str(src), str(dest)) + # 从 manifest 移除条目 + manifest = _load_manifest(project, domain) + manifest.setdefault("files", {}).pop(name, None) + _save_manifest(project, domain, manifest) + + +def preview_text(project: str, domain: str, filename: str, max_chars: int = PREVIEW_LIMIT) -> dict: + name = _safe_name(filename) + fdir = fixtures_dir(project, domain) + src = fdir / name + if not src.exists(): + raise FileNotFoundError(f"素材不存在:{name}") + ext = src.suffix.lower() + text = "" + if ext in (".md", ".txt"): + try: + raw = src.read_text(encoding="utf-8") + except UnicodeDecodeError: + raw = src.read_text(encoding="gbk", errors="ignore") + text = raw + else: + # 复用 doc_extract 的抽取逻辑(懒导入,避免启动即加载重型依赖) + try: + from doc_extract import extract_text + text = extract_text(src) + except Exception as e: # noqa: BLE001 + return {"name": name, "text": "", "available": False, + "message": f"文本抽取不可用:{e}"} + if not text or not text.strip(): + return {"name": name, "text": "", "available": False, + "message": "该文件无可用文本(可能是扫描版图片 PDF,需先 OCR;或格式不受支持)"} + truncated = len(text) > max_chars + return {"name": name, "text": text[:max_chars], "available": True, + "truncated": truncated, "total_chars": len(text)} diff --git a/webui/backend/main.py b/webui/backend/main.py new file mode 100644 index 00000000..6f9d9997 --- /dev/null +++ b/webui/backend/main.py @@ -0,0 +1,344 @@ +import sys +from pathlib import Path + +# 让后端能 import 同一仓库的 core.*(distill / agent 在后续切片使用) +ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT)) + +from fastapi import FastAPI, HTTPException, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +import httpx + +from . import config_store, projects, fixtures, tasks, distill, agent, repo +from .models import LLMTemplate, ProjectCreate, DomainCreate, DistillRequest, AgentRunRequest + +app = FastAPI(title="Resource2Skill WebUI") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/config") +def get_config(): + # 合并自 B 工作副本:让前端首页 Overview 读取全局激活上下文,无需客户端重算。 + cfg = config_store.load() + tpl = None + if cfg.active_llm and cfg.active_llm in cfg.templates: + tpl = config_store.public_template(cfg.active_llm, cfg.templates[cfg.active_llm], cfg.active_llm) + return { + "active_project": cfg.active_project, + "active_domain": cfg.active_domain, + "active_llm": cfg.active_llm, + "active_llm_template": tpl, + } + + +@app.get("/api/llm") +def list_llm(): + cfg = config_store.load() + templates = [ + config_store.public_template(name, t, cfg.active_llm) + for name, t in cfg.templates.items() + ] + return {"active": cfg.active_llm, "templates": templates} + + +@app.post("/api/llm") +def upsert_llm(t: LLMTemplate): + cfg = config_store.load() + cfg.templates[t.name] = t + config_store.save(cfg) + return {"ok": True, "name": t.name} + + +@app.delete("/api/llm/{name}") +def delete_llm(name: str): + cfg = config_store.load() + if name in cfg.templates: + del cfg.templates[name] + if cfg.active_llm == name: + cfg.active_llm = "" + config_store.save(cfg) + return {"ok": True} + + +@app.post("/api/llm/{name}/active") +def set_active(name: str): + cfg = config_store.load() + if name not in cfg.templates: + raise HTTPException(404, "template not found") + cfg.active_llm = name + config_store.save(cfg) + return {"ok": True, "active": name} + + +# ----------------------------- 项目 / 领域(切片1) ----------------------------- + +@app.get("/api/repo-domains") +def get_repo_domains(): + from core import list_domains as core_list_domains + + return {"domains": core_list_domains()} + + +@app.get("/api/projects") +def get_projects(): + return {"active": config_store.load().active_project, "projects": projects.list_projects()} + + +@app.post("/api/projects") +def post_project(body: ProjectCreate): + try: + meta = projects.create_project(body.name, body.description) + except (ValueError, FileExistsError) as e: + raise HTTPException(400, str(e)) + return {"ok": True, **meta} + + +@app.delete("/api/projects/{name}") +def del_project(name: str): + try: + projects.delete_project(name) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + return {"ok": True} + + +@app.post("/api/projects/{name}/active") +def activate_project(name: str): + try: + projects.set_active_project(name) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + return {"ok": True, "active": name} + + +@app.get("/api/projects/{name}/domains") +def get_domains(name: str): + if not (projects.project_dir(name)).exists(): + raise HTTPException(404, f"项目不存在:{name}") + cfg = config_store.load() + # active_domain 是全局单值,但 set_active_domain 会同步写 active_project, + # 故仅当请求的项目正是激活项目时才返回,避免串域。 + ad = cfg.active_domain if cfg.active_project == name else None + return {"project": name, "domains": projects.list_domains(name), "active_domain": ad} + + +@app.post("/api/projects/{name}/domains") +def post_domain(name: str, body: DomainCreate): + try: + res = projects.create_domain(name, body.domain, body.seed_from) + except (ValueError, FileExistsError, FileNotFoundError) as e: + raise HTTPException(400, str(e)) + return {"ok": True, **res} + + +@app.post("/api/projects/{name}/domains/{domain}/active") +def activate_domain(name: str, domain: str): + try: + projects.set_active_domain(name, domain) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + return {"ok": True, "active_project": name, "active_domain": domain} + + +@app.post("/api/projects/{name}/domains/{domain}/validate") +def validate_project_domain(name: str, domain: str): + try: + res = projects.validate_domain(name, domain) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + return res + + +# ----------------------------- 素材管理(切片2) ----------------------------- + +@app.get("/api/projects/{name}/domains/{domain}/fixtures") +def get_fixtures(name: str, domain: str): + if not (projects.project_dir(name) / "domains" / domain / "domain.yaml").exists(): + raise HTTPException(404, f"领域不存在:{domain}") + return {"project": name, "domain": domain, "fixtures": fixtures.list_fixtures(name, domain)} + + +@app.post("/api/projects/{name}/domains/{domain}/fixtures") +async def upload_fixture(name: str, domain: str, file: UploadFile = File(...)): + if not (projects.project_dir(name) / "domains" / domain / "domain.yaml").exists(): + raise HTTPException(404, f"领域不存在:{domain}") + try: + content = await file.read() + meta = fixtures.save_fixture(name, domain, file.filename or "unnamed", content) + except (ValueError, FileNotFoundError) as e: + raise HTTPException(400, str(e)) + return {"ok": True, **meta} + + +@app.post("/api/projects/{name}/domains/{domain}/fixtures/{filename}/enable") +def enable_fixture(name: str, domain: str, filename: str, body: dict): + try: + res = fixtures.set_enabled(name, domain, filename, bool(body.get("enabled", True))) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + return {"ok": True, **res} + + +@app.delete("/api/projects/{name}/domains/{domain}/fixtures/{filename}") +def del_fixture(name: str, domain: str, filename: str): + try: + fixtures.delete_fixture(name, domain, filename) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + return {"ok": True} + + +@app.get("/api/projects/{name}/domains/{domain}/fixtures/{filename}/preview") +def preview_fixture(name: str, domain: str, filename: str): + try: + return fixtures.preview_text(name, domain, filename) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + + +# ----------------------------- 蒸馏工作台 + 任务中心(切片3) ----------------------------- + +# ----------------------------- 任务提交辅助(普通提交与「复制参数重跑」复用) ----------------------------- + +def _submit_distill(name: str, domain: str, body: DistillRequest): + return tasks.submit( + type="distill", + project=name, + domain=domain, + label=f"蒸馏 {name}/{domain}" + ("(dry-run)" if body.dry_run else ""), + params={"dry_run": body.dry_run}, + runner=lambda t: distill.run_distill_task(t, name, domain, body.dry_run), + ) + + +@app.post("/api/projects/{name}/domains/{domain}/distill") +def post_distill(name: str, domain: str, body: DistillRequest): + if not (projects.project_dir(name) / "domains" / domain / "domain.yaml").exists(): + raise HTTPException(404, f"领域不存在:{domain}") + task = _submit_distill(name, domain, body) + return {"ok": True, "task_id": task.id} + + +@app.get("/api/tasks") +def get_tasks(): + return {"tasks": tasks.list_all()} + + +@app.get("/api/tasks/{task_id}") +def get_task(task_id: str): + t = tasks.get(task_id) + if not t: + raise HTTPException(404, "task not found") + return tasks.public_view(t) + + +@app.post("/api/tasks/{task_id}/stop") +def stop_task(task_id: str): + if not tasks.stop(task_id): + raise HTTPException(404, "task not found") + return {"ok": True} + + +# ----------------------------- 复制参数重跑(无需重新填表,可复现运行) ----------------------------- + +@app.post("/api/tasks/{task_id}/rerun") +def rerun_task(task_id: str): + t = tasks.get(task_id) + if not t: + raise HTTPException(404, "task not found") + params = t.params or {} + if not params: + raise HTTPException(400, "该任务未记录参数,无法重跑(旧任务 / 早期版本)") + if t.type == "distill": + body = DistillRequest(**{k: v for k, v in params.items() if k in DistillRequest.model_fields}) + nt = _submit_distill(t.project, t.domain, body) + elif t.type == "agent": + body = AgentRunRequest(**{k: v for k, v in params.items() if k in AgentRunRequest.model_fields}) + nt = _submit_agent(t.project, body) + else: + raise HTTPException(400, f"不支持重跑的任务类型:{t.type}") + return {"ok": True, "task_id": nt.id, "type": t.type} + + +@app.post("/api/llm/test") +async def test_llm(t: LLMTemplate): + if not t.endpoint or not t.api_key or not t.model: + return {"ok": False, "message": "endpoint / api_key / model 均为必填"} + url = t.endpoint.rstrip("/") + "/chat/completions" + headers = { + "Authorization": f"Bearer {t.api_key}", + "Content-Type": "application/json", + } + body = { + "model": t.model, + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 5, + } + kwargs = {"timeout": t.timeout} + if t.proxy: + kwargs["proxy"] = t.proxy + try: + async with httpx.AsyncClient(**kwargs) as c: + r = await c.post(url, headers=headers, json=body) + ok = r.status_code == 200 + detail = "" if ok else r.text[:300] + return {"ok": ok, "status": r.status_code, "message": detail} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{type(e).__name__}: {e}"} + + +# ----------------------------- Agent 执行台(切片4) ----------------------------- + +def _submit_agent(name: str, body: AgentRunRequest): + return tasks.submit( + type="agent", + project=name, + domain=body.domain, + label=f"Agent {name}/{body.domain}" + ("(dry-run)" if body.dry_run else ""), + params={ + "domain": body.domain, "task": body.task, "model": body.model, + "reasoning": body.reasoning, "max_iter": body.max_iter, + "n_skills": body.n_skills, "top_k": body.top_k, "dry_run": body.dry_run, + }, + runner=lambda t: agent.run_agent_task( + t, name, body.domain, + task_text=body.task, + model=body.model, + reasoning=body.reasoning, + max_iter=body.max_iter, + n_skills=body.n_skills, + top_k=body.top_k, + dry_run=body.dry_run, + ), + ) + + +@app.post("/api/projects/{name}/agent/run") +def post_agent_run(name: str, body: AgentRunRequest): + if not (projects.project_dir(name) / "domains" / body.domain / "domain.yaml").exists(): + raise HTTPException(404, f"领域不存在:{body.domain}") + task = _submit_agent(name, body) + return {"ok": True, "task_id": task.id} + + +# ----------------------------- 产物仓库(切片4) ----------------------------- + +@app.get("/api/projects/{name}/repo") +def get_repo(name: str, domain: str): + return repo.list_repo(name, domain) + + +@app.get("/api/projects/{name}/repo/file") +def get_repo_file(name: str, kind: str, domain: str = "", rel_path: str = ""): + return repo.serve_repo_file(name, kind, domain, rel_path) diff --git a/webui/backend/models.py b/webui/backend/models.py new file mode 100644 index 00000000..a4efb888 --- /dev/null +++ b/webui/backend/models.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel + + +class LLMTemplate(BaseModel): + name: str + provider: str = "deepseek" # openai / azure / deepseek / ollama / custom + endpoint: str = "" + api_key: str = "" + model: str = "" + temperature: float = 0.2 + max_tokens: int = 4096 + reasoning: str = "low" # none / low / medium / high / xhigh + timeout: int = 120 + proxy: str = "" + + +class WebuiConfig(BaseModel): + projects_root: str = "" + active_project: str = "" + active_domain: str = "" + active_llm: str = "" + templates: dict[str, LLMTemplate] = {} + + +class ProjectCreate(BaseModel): + name: str + description: str = "" + + +class DomainCreate(BaseModel): + domain: str + seed_from: str | None = None + + +class DistillRequest(BaseModel): + dry_run: bool = False + + +class AgentRunRequest(BaseModel): + domain: str + task: str + model: str = "" + reasoning: str = "" # none / low / medium / high / xhigh(空则用 LLM 模板值) + max_iter: int = 12 + n_skills: int = 5 + top_k: int = 20 + dry_run: bool = False diff --git a/webui/backend/projects.py b/webui/backend/projects.py new file mode 100644 index 00000000..5b1db9da --- /dev/null +++ b/webui/backend/projects.py @@ -0,0 +1,267 @@ +"""Project / Domain 管理(切片1)。 + +设计原则(多项目隔离,零 core 重构): +- 引擎代码保持单实例(Resource2Skill 仓库)。 +- “项目” = webui/projects// 下的独立数据目录: + domains// 领域配置(domain.yaml + 引用文件) + skills_library// 蒸馏产物(后续切片使用) + fixtures// 素材(后续切片使用) + output/ 产物仓库(后续切片使用) + project.json 项目元数据 +- 校验直接调用 core.validate_domain(name, domains_dir=项目domains目录), + 利用它已支持的 domains_dir 覆盖参数,无需改动 core。 +- 已有同名仓库领域(如 ppt)可“播种”进项目,保证 validate 直接 PASS 且为真实可用领域。 +""" +from __future__ import annotations + +import json +import shutil +from datetime import datetime +from pathlib import Path + +import yaml + +from . import config_store + +# webui/backend -> webui -> Resource2Skill +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_DOMAINS = REPO_ROOT / "domains" + +_SAFE_NAME = lambda s: "".join(c for c in s if c.isalnum() or c in "-_") + + +def projects_root() -> Path: + cfg = config_store.load() + if cfg.projects_root: + return Path(cfg.projects_root) + return Path(__file__).resolve().parent.parent / "projects" + + +def project_dir(name: str) -> Path: + return projects_root() / name + + +# ----------------------------- 项目 ----------------------------- + +def list_projects() -> list[dict]: + root = projects_root() + if not root.exists(): + return [] + cfg = config_store.load() + out = [] + for d in sorted(root.iterdir()): + if not d.is_dir() or d.name == ".trash": + continue + pj = d / "project.json" + meta = json.loads(pj.read_text(encoding="utf-8")) if pj.exists() else {} + dom_dir = d / "domains" + domains = sorted( + x.name for x in dom_dir.iterdir() + if x.is_dir() and (x / "domain.yaml").exists() + ) if dom_dir.exists() else [] + out.append({ + "name": d.name, + "description": meta.get("description", ""), + "created_at": meta.get("created_at", ""), + "is_active": d.name == cfg.active_project, + "domains": domains, + }) + return out + + +def create_project(name: str, description: str = "") -> dict: + name = _SAFE_NAME(name.strip()) + if not name: + raise ValueError("项目名称非法(仅允许字母/数字/-/_)") + root = projects_root() + root.mkdir(parents=True, exist_ok=True) + pdir = root / name + if pdir.exists(): + raise FileExistsError(f"项目已存在:{name}") + pdir.mkdir(parents=True) + for sub in ("domains", "skills_library", "fixtures", "output"): + (pdir / sub).mkdir() + meta = { + "name": name, + "description": description, + "created_at": datetime.now().isoformat(timespec="seconds"), + } + (pdir / "project.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) + # 首个项目自动激活 + cfg = config_store.load() + if not cfg.active_project: + cfg.active_project = name + config_store.save(cfg) + return meta + + +def delete_project(name: str) -> None: + pdir = project_dir(name) + if not pdir.exists(): + raise FileNotFoundError(f"项目不存在:{name}") + # 沙箱禁止 shutil.rmtree 硬删除 -> 软删除:移动到 .trash(可恢复,且不计入列表) + trash_root = projects_root() / ".trash" + trash_root.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d%H%M%S") + dest = trash_root / f"{name}_{ts}" + i = 1 + while dest.exists(): + dest = trash_root / f"{name}_{ts}_{i}" + i += 1 + shutil.move(str(pdir), str(dest)) + cfg = config_store.load() + if cfg.active_project == name: + cfg.active_project = "" + + +def set_active_project(name: str) -> None: + pdir = project_dir(name) + if not pdir.exists(): + raise FileNotFoundError(f"项目不存在:{name}") + cfg = config_store.load() + cfg.active_project = name + config_store.save(cfg) + + +# ----------------------------- 领域 ----------------------------- + +def list_domains(project: str) -> list[str]: + ddir = project_dir(project) / "domains" + if not ddir.exists(): + return [] + return sorted( + x.name for x in ddir.iterdir() + if x.is_dir() and (x / "domain.yaml").exists() + ) + + +def create_domain(project: str, domain: str, seed_from: str | None = None) -> dict: + domain = _SAFE_NAME(domain.strip()) + if not domain: + raise ValueError("领域名称非法(仅允许字母/数字/-/_)") + pdir = project_dir(project) + if not pdir.exists(): + raise FileNotFoundError(f"项目不存在:{project}") + ddir = pdir / "domains" + ddir.mkdir(parents=True, exist_ok=True) + target = ddir / domain + if target.exists(): + raise FileExistsError(f"领域已存在:{domain}") + + src = REPO_DOMAINS / (seed_from or domain) + seeded = False + if src.exists(): + shutil.copytree(src, target) + # 播种时把 mcp.cwd 改写为项目根,并向 mcp.env 注入项目隔离路径(领域无关)。 + # 这样 MCP 子进程(cwd=项目根、args 相对解析到本项目 mcp_server)会把技能库与 + # 产物落到本项目数据目录,且产物位于 output/_workspace 下,便于产物仓库浏览与隔离。 + _rewrite_mcp_for_project(target / "domain.yaml", pdir, domain) + seeded = True + else: + _write_blank_domain(target, domain) + + # 配套目录 + (pdir / "skills_library" / domain).mkdir(parents=True, exist_ok=True) + (pdir / "fixtures" / domain).mkdir(parents=True, exist_ok=True) + return {"domain": domain, "seeded": seeded} + + +def _rewrite_mcp_for_project(yaml_path: Path, project_dir: Path, domain: str) -> None: + """播种领域时:把 mcp.cwd 改写为项目根,并向 mcp.args/mcp.env 注入项目隔离路径。 + + 对所有带 mcp 块(command/args)的领域通用,不再特殊对待任何领域: + - mcp.env 注入 R2S_DOMAIN / R2S_SKILLS_DIR / R2S_WORKSPACE(MCP 子进程兜底读取); + - mcp.args 追加 --workspace / --skills-dir(主通道,server.py 解析后落本项目目录)。 + 这样 MCP 子进程(cwd=项目根、args 相对解析到本项目 mcp_server)会把技能库与产物 + 落到本项目数据目录,且产物位于 output/_workspace,便于产物仓库浏览与隔离。 + """ + if not yaml_path.exists(): + return + try: + cfg = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {} + mcp = cfg.get("mcp") + if isinstance(mcp, dict) and mcp.get("command"): + mcp["cwd"] = str(project_dir) + skills_dir = (project_dir / "skills_library" / domain).resolve() + workspace = (project_dir / "output" / f"{domain}_workspace").resolve() + # 主通道:命令行参数(可靠透传) + raw_args = list(mcp.get("args") or []) + clean = [] + skip = False + for a in raw_args: + if skip: + skip = False + continue + if a in ("--workspace", "--skills-dir"): + skip = True + continue + clean.append(a) + # args[0] 保持领域自带的 server.py 相对路径(cwd=项目根,由本项目 seed 副本承载, + # 单一事实来源 = 本项目的 domains//mcp_server/server.py),不再强指仓库根。 + clean += ["--workspace", str(workspace), "--skills-dir", str(skills_dir)] + mcp["args"] = clean + # 兜底环境变量(MCP 子进程继承) + env = dict(mcp.get("env") or {}) + env["R2S_DOMAIN"] = domain + env["R2S_SKILLS_DIR"] = str(skills_dir) + env["R2S_WORKSPACE"] = str(workspace) + mcp["env"] = env + yaml_path.write_text( + yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + except Exception: # noqa: BLE001 + pass + + +def _write_blank_domain(target: Path, domain: str) -> None: + """生成“空白领域脚手架”。 + + 采用 legacy 形态(不声明 execution_mode),这样 validate_domain 直接 PASS, + mcp 允许为 null。待用户配置 execution_mode / persona / query_pool / + failure_patterns / tools_fallback_file / mcp 后即可用于新 Agent 循环。 + """ + target.mkdir(parents=True, exist_ok=True) + cfg = { + "name": domain, + "display_name": domain, + # 不声明 execution_mode -> legacy 形态,validate 直接通过 + "categories": {"general": [domain]}, + "mcp": None, + "agent_prompt_file": "agent_prompt.md", + } + (target / "domain.yaml").write_text( + yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), encoding="utf-8" + ) + (target / "agent_prompt.md").write_text( + f"# Agent Prompt — {domain}\n\n(空白领域脚手架,请按需补充任务引导。)\n", + encoding="utf-8", + ) + (target / "tools_fallback.json").write_text("[]", encoding="utf-8") + (target / "distiller_prompt.md").write_text( + f"# Distiller Prompt — {domain}\n\n从素材中抽取可复用技能。\n", + encoding="utf-8", + ) + + +def set_active_domain(project: str, domain: str) -> None: + pdir = project_dir(project) + if not (pdir / "domains" / domain / "domain.yaml").exists(): + raise FileNotFoundError(f"领域不存在:{domain}") + cfg = config_store.load() + cfg.active_project = project + cfg.active_domain = domain + config_store.save(cfg) + + +def validate_domain(project: str, domain: str) -> dict: + from core import validate_domain as core_validate + + pdir = project_dir(project) + ddir = pdir / "domains" + if not (ddir / domain / "domain.yaml").exists(): + raise FileNotFoundError(f"领域不存在:{domain}") + errors = core_validate(domain, domains_dir=ddir) + return {"domain": domain, "valid": len(errors) == 0, "errors": errors} diff --git a/webui/backend/repo.py b/webui/backend/repo.py new file mode 100644 index 00000000..c703e2b2 --- /dev/null +++ b/webui/backend/repo.py @@ -0,0 +1,132 @@ +"""产物仓库(切片4)。 + +浏览项目数据目录: +- 技能库:projects//skills_library//(R2S 蒸馏产物,含 skill.json) +- 产物: projects//output/_workspace/(agent 交付物落点,领域无关) + +提供:列举(技能 + 文件树)+ 安全下载/预览(防目录穿越)。 +""" +from __future__ import annotations + +import mimetypes +import os +from pathlib import Path + +from fastapi import HTTPException +from fastapi.responses import FileResponse + +from . import projects + +_TEXT_EXT = {".md", ".txt", ".json", ".csv", ".py", ".yaml", ".yml", ".html", ".htm", + ".xml", ".log", ".toml", ".ini", ".cfg"} +_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"} + + +def _safe_join(base: Path, rel: str) -> Path: + """将相对路径安全拼接到 base 之下,禁止目录穿越。""" + if not rel: + raise HTTPException(400, "路径为空") + rel = rel.replace("\\", "/").strip("/") + if ".." in rel.split("/"): + raise HTTPException(400, "非法路径(含 ..)") + target = (base / rel).resolve() + base_res = base.resolve() + if target != base_res and base_res not in target.parents: + raise HTTPException(400, "非法路径(越界)") + if not target.exists(): + raise HTTPException(404, "文件不存在") + if target.is_dir(): + raise HTTPException(400, "目标是目录,不是文件") + return target + + +def _file_info(rel_path: str, base: Path) -> dict: + p = (base / rel_path).resolve() + try: + st = p.stat() + mtime = st.st_mtime + size = st.st_size + except OSError: + mtime = 0 + size = 0 + ext = p.suffix.lower() + return { + "rel_path": rel_path, + "name": p.name, + "ext": ext.lstrip("."), + "size": size, + "mtime": mtime, + "kind": "image" if ext in _IMAGE_EXT else ("text" if ext in _TEXT_EXT else "binary"), + } + + +def list_repo(project: str, domain: str) -> dict: + """列举项目的技能库与产物目录。""" + pdir = projects.project_dir(project) + if not pdir.exists(): + raise HTTPException(404, f"项目不存在:{project}") + + skills_dir = pdir / "skills_library" / domain + output_dir = pdir / "output" + + skills: list[dict] = [] + files: list[dict] = [] + if skills_dir.exists(): + for skill_json in sorted(skills_dir.rglob("skill.json")): + if ".trash" in skill_json.parts: + continue + rel = skill_json.relative_to(skills_dir).as_posix() + entry: dict = { + "rel_path": rel, + "name": skill_json.parent.name, + "category": skill_json.parent.parent.name, + } + try: + import json as _json + + data = _json.loads(skill_json.read_text(encoding="utf-8")) + entry["skill_name"] = data.get("skill_name", entry["name"]) + entry["updated_at"] = data.get("extracted_at", "") + except Exception: # noqa: BLE001 + entry["skill_name"] = entry["name"] + skills.append(entry) + # 技能库下全部文件(便于直接预览 skill.md / skill.html / frames 等) + for f in sorted(skills_dir.rglob("*")): + if f.is_file(): + files.append(_file_info(f.relative_to(skills_dir).as_posix(), skills_dir)) + + output_files: list[dict] = [] + if output_dir.exists(): + for f in sorted(output_dir.rglob("*")): + if f.is_file(): + output_files.append(_file_info(f.relative_to(output_dir).as_posix(), output_dir)) + + return { + "project": project, + "domain": domain, + "skills_dir": skills_dir.as_posix(), + "output_dir": output_dir.as_posix(), + "skills": skills, + "skill_files": files, + "output_files": output_files, + } + + +def serve_repo_file(project: str, kind: str, domain: str, rel_path: str) -> FileResponse: + """安全返回项目内的某个文件(下载 / 预览)。""" + pdir = projects.project_dir(project) + if not pdir.exists(): + raise HTTPException(404, f"项目不存在:{project}") + if kind == "skills": + base = pdir / "skills_library" / domain + elif kind == "output": + base = pdir / "output" + else: + raise HTTPException(400, "kind 必须为 skills 或 output") + target = _safe_join(base, rel_path) + mime, _ = mimetypes.guess_type(str(target)) + return FileResponse( + str(target), + media_type=mime or "application/octet-stream", + filename=target.name, + ) diff --git a/webui/backend/requirements.txt b/webui/backend/requirements.txt new file mode 100644 index 00000000..0b70471d --- /dev/null +++ b/webui/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +httpx +cryptography +python-multipart diff --git a/webui/backend/security.py b/webui/backend/security.py new file mode 100644 index 00000000..919d7d44 --- /dev/null +++ b/webui/backend/security.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path +from cryptography.fernet import Fernet + +_KEY_FILE = Path(__file__).resolve().parent / ".key" +_FERNET = None + + +def _fernet() -> Fernet: + global _FERNET + if _FERNET is not None: + return _FERNET + if _KEY_FILE.exists(): + key = _KEY_FILE.read_bytes().strip() + else: + key = Fernet.generate_key() + _KEY_FILE.write_bytes(key) + try: + os.chmod(_KEY_FILE, 0o600) + except Exception: + pass + _FERNET = Fernet(key) + return _FERNET + + +def encrypt(plain: str) -> str: + if not plain: + return "" + return _fernet().encrypt(plain.encode("utf-8")).decode("utf-8") + + +def decrypt(token: str) -> str: + if not token: + return "" + try: + return _fernet().decrypt(token.encode("utf-8")).decode("utf-8") + except Exception: + return "" diff --git a/webui/backend/tasks.py b/webui/backend/tasks.py new file mode 100644 index 00000000..4bb96be6 --- /dev/null +++ b/webui/backend/tasks.py @@ -0,0 +1,132 @@ +"""后台任务注册表(切片3 起,蒸馏 / Agent 共用)。 + +设计要点: +- 单 worker 线程串行执行任务。原因:蒸馏时需在进程内环境注入用户 LLM 配置 + (call_azure_openai 从环境变量读取 endpoint/key),串行可避免并发任务互相踩 env。 +- 每个 Task 自带 log 缓冲(封顶长度)与 stop_requested 标志,runner 应通过 + task.should_stop() 周期性检查以支持「终止」。 +- 内存态(localhost 单人用,重启即丢),不做持久化。 +""" +from __future__ import annotations + +import queue +import threading +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Callable, Optional + +_LOG_CAP = 3000 + +_TASKS: dict[str, "Task"] = {} +_Q: "queue.Queue[Optional[Task]]" = queue.Queue() +_WORKER: Optional[threading.Thread] = None +_LOCK = threading.Lock() + + +def _iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + +@dataclass +class Task: + id: str + type: str + project: str + domain: str + status: str = "queued" # queued | running | done | error | stopped + label: str = "" + created_at: str = "" + started_at: str = "" + finished_at: str = "" + summary: dict = field(default_factory=dict) + stop_requested: bool = False + log: list[str] = field(default_factory=list) + extra: dict = field(default_factory=dict) + params: dict = field(default_factory=dict) # 任务提交参数,供「复制参数重跑」复用 + _runner: Callable[["Task"], None] = lambda t: None + + def append_log(self, line: str) -> None: + self.log.append(line) + if len(self.log) > _LOG_CAP: + del self.log[: len(self.log) - _LOG_CAP] + + def should_stop(self) -> bool: + return self.stop_requested + + +def _ensure_worker() -> None: + global _WORKER + if _WORKER is None or not _WORKER.is_alive(): + _WORKER = threading.Thread(target=_worker, daemon=True) + _WORKER.start() + + +def _worker() -> None: + while True: + task = _Q.get() + if task is None: + _Q.task_done() + break + task.status = "running" + task.started_at = _iso() + try: + task._runner(task) + except Exception as e: # noqa: BLE001 + task.append_log(f"[异常] {type(e).__name__}: {e}") + task.status = "error" + else: + task.status = "stopped" if task.stop_requested else "done" + task.finished_at = _iso() + _Q.task_done() + + +def submit(*, type: str, project: str, domain: str, runner: Callable[["Task"], None], + label: str = "", params: dict | None = None) -> Task: + tid = uuid.uuid4().hex[:8] + t = Task(id=tid, type=type, project=project, domain=domain, + label=label, created_at=_iso(), params=params or {}) + t._runner = runner + with _LOCK: + _TASKS[tid] = t + _ensure_worker() + _Q.put(t) + return t + + +def get(task_id: str) -> Optional[Task]: + return _TASKS.get(task_id) + + +def list_all() -> list[dict]: + with _LOCK: + items = list(_TASKS.values()) + items.sort(key=lambda t: t.created_at, reverse=True) + return [ + { + "id": t.id, "type": t.type, "project": t.project, "domain": t.domain, + "status": t.status, "label": t.label, "created_at": t.created_at, + "started_at": t.started_at, "finished_at": t.finished_at, + "summary": t.summary, "extra": t.extra, "params": t.params, + } + for t in items + ] + + +def stop(task_id: str) -> bool: + t = _TASKS.get(task_id) + if not t: + return False + t.stop_requested = True + # queued 任务被 worker 取走后,runner 会在开头检查 should_stop 直接退出 + return True + + +def public_view(t: Task) -> dict: + return { + "id": t.id, "type": t.type, "project": t.project, "domain": t.domain, + "status": t.status, "label": t.label, "created_at": t.created_at, + "started_at": t.started_at, "finished_at": t.finished_at, + "summary": t.summary, "log": t.log, "extra": t.extra, "params": t.params, + } diff --git a/webui/frontend/index.html b/webui/frontend/index.html new file mode 100644 index 00000000..4616bf1a --- /dev/null +++ b/webui/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Resource2Skill WebUI + + +
+ + + diff --git a/webui/frontend/package-lock.json b/webui/frontend/package-lock.json new file mode 100644 index 00000000..c46bf5cb --- /dev/null +++ b/webui/frontend/package-lock.json @@ -0,0 +1,1731 @@ +{ + "name": "r2s-webui-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "r2s-webui-frontend", + "version": "0.0.1", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.0", + "element-plus": "^2.7.0", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "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/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.8", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz", + "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==", + "license": "MIT" + } + } +} diff --git a/webui/frontend/package.json b/webui/frontend/package.json new file mode 100644 index 00000000..fa7ca4e3 --- /dev/null +++ b/webui/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "r2s-webui-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "element-plus": "^2.7.0", + "axios": "^1.7.0", + "@element-plus/icons-vue": "^2.3.1" + }, + "devDependencies": { + "vite": "^5.2.0", + "@vitejs/plugin-vue": "^5.0.0" + } +} diff --git a/webui/frontend/src/App.vue b/webui/frontend/src/App.vue new file mode 100644 index 00000000..fc7089d6 --- /dev/null +++ b/webui/frontend/src/App.vue @@ -0,0 +1,73 @@ + + + diff --git a/webui/frontend/src/api.js b/webui/frontend/src/api.js new file mode 100644 index 00000000..3c93c31e --- /dev/null +++ b/webui/frontend/src/api.js @@ -0,0 +1,62 @@ +import axios from 'axios' + +const http = axios.create({ baseURL: '/api' }) + +export const listLlm = () => http.get('/llm').then((r) => r.data) +export const upsertLlm = (t) => http.post('/llm', t).then((r) => r.data) +export const deleteLlm = (name) => http.delete(`/llm/${name}`).then((r) => r.data) +export const setActive = (name) => http.post(`/llm/${name}/active`).then((r) => r.data) +export const testLlm = (t) => http.post('/llm/test', t).then((r) => r.data) +export const getConfig = () => http.get('/config').then((r) => r.data) + +// ---- 项目 / 领域(切片1) ---- +export const listProjects = () => http.get('/projects').then((r) => r.data) +export const createProject = (name, description) => + http.post('/projects', { name, description }).then((r) => r.data) +export const deleteProject = (name) => http.delete(`/projects/${name}`).then((r) => r.data) +export const activateProject = (name) => + http.post(`/projects/${name}/active`).then((r) => r.data) +export const listRepoDomains = () => http.get('/repo-domains').then((r) => r.data) +export const listDomains = (project) => + http.get(`/projects/${project}/domains`).then((r) => r.data) +export const createDomain = (project, domain, seedFrom) => + http.post(`/projects/${project}/domains`, { domain, seed_from: seedFrom || null }).then((r) => r.data) +export const activateDomain = (project, domain) => + http.post(`/projects/${project}/domains/${domain}/active`).then((r) => r.data) +export const validateDomain = (project, domain) => + http.post(`/projects/${project}/domains/${domain}/validate`).then((r) => r.data) + +// ---- 素材管理(切片2) ---- +export const listFixtures = (project, domain) => + http.get(`/projects/${project}/domains/${domain}/fixtures`).then((r) => r.data) +export const enableFixture = (project, domain, filename, enabled) => + http.post(`/projects/${project}/domains/${domain}/fixtures/${encodeURIComponent(filename)}/enable`, { enabled }).then((r) => r.data) +export const deleteFixture = (project, domain, filename) => + http.delete(`/projects/${project}/domains/${domain}/fixtures/${encodeURIComponent(filename)}`).then((r) => r.data) +export const previewFixture = (project, domain, filename) => + http.get(`/projects/${project}/domains/${domain}/fixtures/${encodeURIComponent(filename)}/preview`).then((r) => r.data) +// el-upload 不走 axios,需完整相对路径(dev server 代理 /api) +export const fixtureUploadUrl = (project, domain) => + `/api/projects/${encodeURIComponent(project)}/domains/${encodeURIComponent(domain)}/fixtures` + +// ---- 蒸馏工作台 + 任务中心(切片3) ---- +export const startDistill = (project, domain, dryRun = false) => + http.post(`/projects/${project}/domains/${domain}/distill`, { dry_run: dryRun }).then((r) => r.data) +export const listTasks = () => http.get('/tasks').then((r) => r.data) +export const getTask = (id) => http.get(`/tasks/${id}`).then((r) => r.data) +export const stopTask = (id) => http.post(`/tasks/${id}/stop`).then((r) => r.data) +export const rerunTask = (id) => http.post(`/tasks/${id}/rerun`).then((r) => r.data) + +// ---- Agent 执行台(切片4) ---- +export const runAgent = (project, body) => + http.post(`/projects/${encodeURIComponent(project)}/agent/run`, body).then((r) => r.data) + +// ---- 产物仓库(切片4) ---- +export const listRepo = (project, domain) => + http.get(`/projects/${encodeURIComponent(project)}/repo`, { params: { domain } }).then((r) => r.data) +export const repoFileUrl = (project, kind, domain, relPath) => { + const params = new URLSearchParams({ kind, rel_path: relPath }) + if (domain) params.set('domain', domain) + return `/api/projects/${encodeURIComponent(project)}/repo/file?${params.toString()}` +} + diff --git a/webui/frontend/src/components/TaskProgress.vue b/webui/frontend/src/components/TaskProgress.vue new file mode 100644 index 00000000..b8e2b4c2 --- /dev/null +++ b/webui/frontend/src/components/TaskProgress.vue @@ -0,0 +1,94 @@ + + + diff --git a/webui/frontend/src/composables/useActiveContext.js b/webui/frontend/src/composables/useActiveContext.js new file mode 100644 index 00000000..ca847bca --- /dev/null +++ b/webui/frontend/src/composables/useActiveContext.js @@ -0,0 +1,65 @@ +// 共享的"当前项目/领域"上下文(模块级 reactive 单例)。 +// 不引入 Pinia:应用小、单用户,模块级单例就是最简形态的 store。 +import { reactive } from 'vue' +import { listProjects, listDomains, activateProject } from '../api.js' + +const state = reactive({ + projects: [], + activeProject: '', + domains: [], + activeDomain: '', + loading: false, + // UI 语言:'zh'(默认)| 'en',持久化到 localStorage 跨刷新粘性 + locale: (() => { + try { return localStorage.getItem('r2s_locale') || 'zh' } catch (e) { return 'zh' } + })(), +}) + +// 切换 UI 语言并持久化 +function setLocale(lang) { + state.locale = lang + try { localStorage.setItem('r2s_locale', lang) } catch (e) { /* 忽略隐私模式等异常 */ } +} + +async function loadProjects() { + const r = await listProjects() + state.projects = r.projects || [] + const active = state.projects.find((p) => p.is_active) + state.activeProject = active ? active.name : state.projects[0]?.name || '' + if (state.activeProject) await loadDomains(state.activeProject) + return state +} + +async function loadDomains(project) { + if (!project) { + state.domains = [] + state.activeDomain = '' + return + } + const r = await listDomains(project) + state.domains = r.domains || [] + // C2:优先用服务端持久化的 active_domain 作默认选中(跨刷新粘性); + // 否则若当前选择在新项目里仍有效则保持(跨视图粘性),再退到第一个。 + const serverActive = r.active_domain + if (serverActive && state.domains.includes(serverActive)) { + state.activeDomain = serverActive + } else if (!state.domains.includes(state.activeDomain)) { + state.activeDomain = state.domains[0] || '' + } +} + +// 工作台下拉选项目:仅本地切换 + 拉领域,不写服务端激活 +async function selectProject(name) { + state.activeProject = name + await loadDomains(name) +} + +// 显式激活:写服务端 active_project,并刷新列表 +async function setActiveProject(name) { + await activateProject(name) + await loadProjects() +} + +export function useActiveContext() { + return { state, loadProjects, loadDomains, selectProject, setActiveProject, setLocale } +} diff --git a/webui/frontend/src/i18n.js b/webui/frontend/src/i18n.js new file mode 100644 index 00000000..ff31366e --- /dev/null +++ b/webui/frontend/src/i18n.js @@ -0,0 +1,223 @@ +// 轻量 i18n:中文为 key(zh 直接回退到 key 本身),en 为覆盖映射。 +// 不引入 vue-i18n:复用 useActiveContext 的模块级 reactive 单例存放 locale, +// t() 在模板中读取 state.locale,切换时自动响应式重渲染。 +import { state, setLocale as _setLocale } from './composables/useActiveContext.js' + +// 英文覆盖映射(key 与模板/脚本中的中文文案一致) +const en = { + // 顶栏导航 + 'Resource2Skill WebUI': 'Resource2Skill WebUI', + '首页': 'Home', + '项目与 Domain 管理': 'Projects & Domains', + '任务中心': 'Task Center', + 'Agent 执行台': 'Agent Console', + '素材管理': 'Fixtures', + '蒸馏工作台': 'Distill Workbench', + '产物仓库': 'Artifact Repo', + 'LLM 配置': 'LLM Config', + + // Overview + '当前工作区': 'Current Workspace', + '激活项目': 'Active Project', + '(未选择)': '(not selected)', + '项目根路径': 'Project Root', + 'webui/projects/': 'webui/projects/', + '当前 Domain': 'Current Domain', + '运行中任务': 'Running Tasks', + '待运行任务': 'Queued Tasks', + 'Skill 总数': 'Total Skills', + '素材文件数': 'Fixture Files', + '+ 新建项目': '+ New Project', + '打开蒸馏工作台': 'Open Distill Workbench', + '新建 Agent 任务': 'New Agent Task', + '最近任务': 'Recent Tasks', + '暂无任务': 'No tasks yet', + '状态': 'Status', + '类型': 'Type', + 'Domain': 'Domain', + '创建时间': 'Created At', + '操作': 'Actions', + '查看详情': 'View Details', + '运行中': 'Running', + '排队中': 'Queued', + '成功': 'Success', + '失败': 'Failed', + '已终止': 'Stopped', + + // ProjectsDomains + '项目(多项目隔离)': 'Projects (multi-project isolation)', + '新建项目': 'New Project', + '名称': 'Name', + '描述': 'Description', + '领域数': 'Domains', + '删除': 'Delete', + '是': 'Yes', + '否': 'No', + '领域管理': 'Domain Management', + '· 当前项目:': '· Current project: ', + ' · 当前领域:': ' · Current domain: ', + '(请先在上方选择或激活一个项目)': '(please select or activate a project above)', + '请先在上方选择或激活一个项目': '(please select or activate a project above)', + '新建领域': 'New Domain', + '领域': 'Domain', + '校验结果': 'Validation', + '未校验': 'Not validated', + '错误详情': 'Error Details', + '—': '—', + '校验': 'Validate', + '仅字母/数字/-/_': 'Letters/digits/-/_ only', + '领域名': 'Domain Name', + '如 marketing / finance / 自定义(仅字母/数字/-/_)': 'e.g. marketing / finance / custom (letters/digits/-/_)', + '播种来源': 'Seed From', + '留空=生成空白脚手架': 'Leave empty = blank scaffold', + '选择仓库已有领域可直接复制其 domain.yaml 与引用文件(校验直接 PASS)。': 'Select an existing repo domain to copy its domain.yaml and referenced files (validation passes directly).', + '取消': 'Cancel', + '创建': 'Create', + '创建并校验': 'Create & Validate', + '请填写项目名称': 'Please enter the project name', + '项目已创建': 'Project created', + '已激活:': 'Activated: ', + '已删除(移入 .trash):': 'Deleted (moved to .trash): ', + '请填写领域名': 'Please enter the domain name', + '已从仓库播种': 'Seeded from repo', + '已生成空白脚手架': 'Blank scaffold generated', + ' 校验 PASS': ' validation PASS', + ' 校验 FAIL': ' validation FAIL', + '已激活领域:': 'Domain activated: ', + + // TaskCenter + '任务中心(蒸馏 / Agent 共用)': 'Task Center (shared by Distill / Agent)', + '刷新': 'Refresh', + 'ID': 'ID', + '项目/领域': 'Project/Domain', + '日志': 'Logs', + '终止': 'Stop', + '任务日志 ': 'Task Logs ', + '加载任务失败:': 'Failed to load tasks: ', + '已发送终止信号': 'Stop signal sent', + '终止失败:': 'Failed to stop: ', + + // AgentConsole + '项目': 'Project', + '任务': 'Task', + '模型': 'Model', + '推理档位': 'Reasoning Tier', + '迭代上限': 'Max Iterations', + '参考技能数': 'Ref Skills', + 'TopK': 'TopK', + '选择项目': 'Select project', + '选择领域': 'Select domain', + '用自然语言描述要 Agent 完成的任务,例如:为一款产品生成立项文档并产出阶段交付物': 'Describe the task for the Agent in natural language, e.g.: create a project proposal for a product and produce stage deliverables', + '留空则用 LLM 模板模型': 'Leave empty to use the LLM template model', + '留空则用 LLM 模板值': 'Leave empty to use the LLM template value', + 'Dry-run(占位工具,不落盘交付物)': 'Dry-run (placeholder tools, no deliverables written)', + '运行 Agent': 'Run Agent', + '单 worker 串行执行,运行期间请勿再提交其它任务': 'Single-worker serial execution; do not submit other tasks while running', + '工具调用记录': 'Tool Call Log', + '工具': 'Tool', + '结果': 'Result', + '参数': 'Args', + '错误': 'Error', + '选择项目/领域并填写任务后点击「运行 Agent」': 'Select project/domain and fill in the task, then click "Run Agent"', + '已提交 Agent 任务': 'Agent task submitted', + '提交失败:': 'Submit failed: ', + '(当前)': ' (current)', + + // Fixtures + '上传素材': 'Upload Fixture', + '支持 PDF / DOCX / PPTX / XLSX / MD / TXT;仅文档类': 'Supports PDF / DOCX / PPTX / XLSX / MD / TXT; documents only', + '文件名': 'File Name', + '大小': 'Size', + '启用': 'Enabled', + '上传时间': 'Uploaded At', + '预览': 'Preview', + '暂无素材,先选择领域并上传': 'No fixtures yet; select a domain and upload first', + '预览:': 'Preview: ', + '无可用文本': 'No text available', + '仅显示前 ': 'Showing first ', + ' 字(共 ': ' chars (total ', + ' 字)': ' chars)', + '加载素材失败:': 'Failed to load fixtures: ', + '加载领域失败:': 'Failed to load domains: ', + '不支持的类型:': 'Unsupported type: ', + '上传成功': 'Upload successful', + '上传失败(类型不支持或服务端拒绝)': 'Upload failed (unsupported type or rejected by server)', + '更新启用状态失败:': 'Failed to update enabled status: ', + '确认删除素材「': 'Confirm delete fixture "', + '」?将移入回收站(可恢复)。': '"? It will be moved to the recycle bin (recoverable).', + '删除确认': 'Delete Confirmation', + '已删除(软删除)': 'Deleted (soft delete)', + '初始化失败:': 'Initialization failed: ', + '删除失败:': 'Failed to delete: ', + '预览失败:': 'Preview failed: ', + + // ArtifactRepo + '技能 ': 'Skills ', + ' 个 · 技能文件 ': ' · Skill files ', + ' 个 · 产物 ': ' · Artifacts ', + ' 个': '', + '技能库': 'Skill Library', + '产物 output': 'Artifacts output', + '相对路径': 'Relative Path', + '下载': 'Download', + '选择项目/领域后浏览产物': 'Select project/domain to browse artifacts', + '关闭': 'Close', + '(加载中…)': '(loading…)', + '(文本加载失败)': '(text load failed)', + '加载产物失败:': 'Failed to load artifacts: ', + + // LlmConfig + '新建配置模板': 'New Config Template', + '厂商': 'Provider', + 'Endpoint': 'Endpoint', + 'Key': 'Key', + '激活': 'Active', + '测试/编辑:': 'Test/Edit: ', + '测试/编辑': 'Test/Edit', + 'DeepSeek': 'DeepSeek', + 'OpenAI': 'OpenAI', + 'Azure OpenAI': 'Azure OpenAI', + 'Ollama(本地)': 'Ollama (local)', + '自定义API': 'Custom API', + 'API Key': 'API Key', + '不明文展示,仅本机加密存储': 'Not shown in plaintext; stored encrypted locally', + 'Temperature': 'Temperature', + 'Max Tokens': 'Max Tokens', + 'Reasoning': 'Reasoning', + '代理(可选)': 'Proxy (optional)', + '测试连接': 'Test Connection', + '保存': 'Save', + '已保存': 'Saved', + '已激活': 'Activated', + '已删除': 'Deleted', + '请输入真实 API Key 后再测试': 'Please enter a real API Key before testing', + '连通成功 (': 'Connection OK (', + '连通失败:': 'Connection failed: ', + + // DistillWorkbench + '仅验证管线(不调用 LLM)': 'Validate pipeline only (no LLM call)', + '开始蒸馏': 'Start Distill', + '本域已蒸馏技能:': 'Distilled skills in this domain: ', + '选择领域后点击「开始蒸馏」': 'Select a domain then click "Start Distill"', + '已提交蒸馏任务': 'Distill task submitted', + + // TaskProgress + '任务 ': 'Task ', + '重跑': 'Rerun', + '(等待日志…)': '(waiting for logs…)', + '新增 ': 'Added ', + ' 条,跳过 ': ' , skipped ', + ' 个,技能库现有 ': ' ; library now has ', + ' 条': ' skills', + '已用相同参数重跑,新任务 ': 'Reran with same params, new task ', + '重跑失败:': 'Rerun failed: ', +} + +export function t(key) { + if (state.locale === 'en') { + return Object.prototype.hasOwnProperty.call(en, key) ? en[key] : key + } + return key +} + +export const setLocale = _setLocale diff --git a/webui/frontend/src/main.js b/webui/frontend/src/main.js new file mode 100644 index 00000000..351fa45c --- /dev/null +++ b/webui/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' + +createApp(App).use(ElementPlus).mount('#app') diff --git a/webui/frontend/src/views/AgentConsole.vue b/webui/frontend/src/views/AgentConsole.vue new file mode 100644 index 00000000..58625d89 --- /dev/null +++ b/webui/frontend/src/views/AgentConsole.vue @@ -0,0 +1,122 @@ + + + diff --git a/webui/frontend/src/views/ArtifactRepo.vue b/webui/frontend/src/views/ArtifactRepo.vue new file mode 100644 index 00000000..5ab9bdbb --- /dev/null +++ b/webui/frontend/src/views/ArtifactRepo.vue @@ -0,0 +1,166 @@ +