From 4b94922cae1cdd2e00eb8482caef3b7c7a8e9a6a Mon Sep 17 00:00:00 2001 From: tc10 Date: Tue, 28 Jul 2026 16:27:18 +0800 Subject: [PATCH 1/7] webui: add Overview homepage + /api/config; commit C1/C2/I1 WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: add GET /api/config (active project/domain/llm context), merged from the B checkout. - Frontend: add Overview.vue homepage dashboard (B's) + getConfig api helper. - App.vue: 首页(home) is now first/default nav item; LLM config stays last; Overview navigates via @navigate. - Includes prior C1 (shared useActiveContext store + TaskProgress), C2 (domain activation stickiness), I1 (row-click loads domains) and core engine changes (collector/llm/registry). - Excludes node_modules, webui_config.json, .key, projects/ data (gitignored) and runtime logs. --- core/collector.py | 389 ++-- core/llm.py | 94 +- core/skill_wiki/registry.py | 12 +- webui/.gitignore | 7 + webui/SLICE1_OVERVIEW.md | 43 + webui/SLICE2_OVERVIEW.md | 40 + webui/SLICE3_OVERVIEW.md | 38 + webui/__init__.py | 0 webui/backend/__init__.py | 0 webui/backend/agent.py | 175 ++ webui/backend/config_store.py | 47 + webui/backend/distill.py | 128 ++ webui/backend/fixtures.py | 178 ++ webui/backend/main.py | 307 +++ webui/backend/models.py | 47 + webui/backend/projects.py | 273 +++ webui/backend/repo.py | 132 ++ webui/backend/requirements.txt | 5 + webui/backend/security.py | 38 + webui/backend/tasks.py | 131 ++ webui/frontend/index.html | 12 + webui/frontend/package-lock.json | 1731 +++++++++++++++++ webui/frontend/package.json | 21 + webui/frontend/src/App.vue | 61 + webui/frontend/src/api.js | 61 + .../frontend/src/components/TaskProgress.vue | 79 + .../src/composables/useActiveContext.js | 55 + webui/frontend/src/main.js | 6 + webui/frontend/src/views/AgentConsole.vue | 115 ++ webui/frontend/src/views/ArtifactRepo.vue | 165 ++ webui/frontend/src/views/DistillWorkbench.vue | 65 + webui/frontend/src/views/Fixtures.vue | 197 ++ webui/frontend/src/views/LlmConfig.vue | 170 ++ webui/frontend/src/views/Overview.vue | 145 ++ webui/frontend/src/views/ProjectsDomains.vue | 280 +++ webui/frontend/src/views/TaskCenter.vue | 84 + webui/frontend/vite.config.js | 12 + webui/poll_task.py | 25 + 38 files changed, 5204 insertions(+), 164 deletions(-) create mode 100644 webui/.gitignore create mode 100644 webui/SLICE1_OVERVIEW.md create mode 100644 webui/SLICE2_OVERVIEW.md create mode 100644 webui/SLICE3_OVERVIEW.md create mode 100644 webui/__init__.py create mode 100644 webui/backend/__init__.py create mode 100644 webui/backend/agent.py create mode 100644 webui/backend/config_store.py create mode 100644 webui/backend/distill.py create mode 100644 webui/backend/fixtures.py create mode 100644 webui/backend/main.py create mode 100644 webui/backend/models.py create mode 100644 webui/backend/projects.py create mode 100644 webui/backend/repo.py create mode 100644 webui/backend/requirements.txt create mode 100644 webui/backend/security.py create mode 100644 webui/backend/tasks.py create mode 100644 webui/frontend/index.html create mode 100644 webui/frontend/package-lock.json create mode 100644 webui/frontend/package.json create mode 100644 webui/frontend/src/App.vue create mode 100644 webui/frontend/src/api.js create mode 100644 webui/frontend/src/components/TaskProgress.vue create mode 100644 webui/frontend/src/composables/useActiveContext.js create mode 100644 webui/frontend/src/main.js create mode 100644 webui/frontend/src/views/AgentConsole.vue create mode 100644 webui/frontend/src/views/ArtifactRepo.vue create mode 100644 webui/frontend/src/views/DistillWorkbench.vue create mode 100644 webui/frontend/src/views/Fixtures.vue create mode 100644 webui/frontend/src/views/LlmConfig.vue create mode 100644 webui/frontend/src/views/Overview.vue create mode 100644 webui/frontend/src/views/ProjectsDomains.vue create mode 100644 webui/frontend/src/views/TaskCenter.vue create mode 100644 webui/frontend/vite.config.js create mode 100644 webui/poll_task.py diff --git a/core/collector.py b/core/collector.py index 96a79888..4ecc23b5 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,领域无关)。 + +与 distill_ipd.py 的关系: +- 复用其文本抽取(extract_text)、切片(chunk_text)、技能名解析(parse_skill_name)、 + 归类启发式(categorize)等纯逻辑; +- 但目录**参数化**:素材读项目 fixtures//,技能写项目 skills_library//, + **不碰** distill_ipd 的全局 LIB_DIR/RES_DIR,保持多项目隔离、零 core 全局副作用。 +- 蒸馏 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" - +# 复用 distill_ipd 的纯逻辑(不触发其全局 main / 全局目录) +from distill_ipd 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. + distill_ipd.extract_text 仅覆盖 PDF/PPTX/DOCX/XLSX;MD/TXT 在其全局 SKIP_EXT + 中被故意跳过。通用采集器需支持文档类,故此处对 MD/TXT 直接读文本(UTF-8→GBK + 兜底),其余委托 distill_ipd,保持不修改 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/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/SLICE1_OVERVIEW.md b/webui/SLICE1_OVERVIEW.md new file mode 100644 index 00000000..0d6779aa --- /dev/null +++ b/webui/SLICE1_OVERVIEW.md @@ -0,0 +1,43 @@ +# Resource2Skill WebUI — 切片1:项目与 Domain 管理 + +## 本切片交付 + +**后端** (`webui/backend/`) +- 新增 `projects.py`:项目与领域的 CRUD + 校验,多项目隔离(零 core 重构)。 +- `main.py` 新增端点: + - `GET/POST/DELETE /api/projects`、`POST /api/projects/{name}/active` + - `GET /api/repo-domains`(仓库已有领域,供“播种”下拉) + - `GET/POST /api/projects/{name}/domains`、`POST /api/projects/{name}/domains/{domain}/active`、`POST .../validate` +- `models.py` 增加 `ProjectCreate` / `DomainCreate`。 + +**前端** (`webui/frontend/`) +- `api.js` 增加项目/领域 API 封装。 +- `App.vue` 顶部 `el-menu` 在「LLM 配置 / 项目与 Domain 管理」间切换。 +- 新增 `views/ProjectsDomains.vue`:项目表(激活/删除)、领域表(内联校验/激活)、新建项目与新建领域对话框(领域支持“从仓库播种”)。 + +## 关键设计 +- **隔离模型**:项目 = `webui/projects//` 下独立 `domains/ skills_library/ fixtures/ output/ project.json`;校验经 `core.validate_domain(name, domains_dir=项目domains)` 覆盖参数实现,不改 core。 +- **领域创建两模式**: + - 播种:复制仓库 `domains/`(如 ipd)→ 真实可用、`validate` 直接 PASS;自动改写 `mcp.cwd` 为项目根。 + - 空白脚手架:legacy 形态(不声明 `execution_mode`,`mcp: null`)→ `validate` 直接 PASS,后续再升级为新形态以支持 Agent 循环。 +- **软删除**:沙箱拦截 `shutil.rmtree`,删除改为移动到 `projects/.trash/`(可恢复、不计入列表)。 + +## 验收结果(curl 实测) +``` +建项目 demo + → 新建领域 ipd(seed_from=ipd) {"seeded": true} + → POST .../ipd/validate {"valid": true, "errors": []} ✅ PASS + → 新建领域 mytopic(空白脚手架) {"seeded": false} + → POST .../mytopic/validate {"valid": true, "errors": []} ✅ PASS(legacy) + → 删除 demo / demo2 {"ok": true}(软删除) +``` +前端 `vite build` 编译 1634 模块全过(仅生产构建的 dist 清理被沙箱拦截,dev 不受影响)。 + +## 当前运行 +- 后端:uvicorn `webui.backend.main:app` @ `http://127.0.0.1:8000` +- 前端:vite dev @ `http://localhost:5173`(顶部切换“项目与 Domain 管理”) + +## 下一步(切片2→4) +- 切片2 素材管理(上传/清单/预览/删除) +- 切片3 通用收集器 `core/collector.py` + 蒸馏工作台(后台线程 + SSE) +- 切片4 Agent 执行 + 产物仓库 diff --git a/webui/SLICE2_OVERVIEW.md b/webui/SLICE2_OVERVIEW.md new file mode 100644 index 00000000..c88d4389 --- /dev/null +++ b/webui/SLICE2_OVERVIEW.md @@ -0,0 +1,40 @@ +# 切片2:素材管理 — 交付概览 + +日期:2026-07-28 + +## 本片交付 + +### 后端 +- 新增 `webui/backend/fixtures.py`:素材管理核心模块(零 core 重构)。 + - `list_fixtures`:扫描 `fixtures//`,与 `manifest.json` 对账(自动剔除已删条目),返回 `{name, ext, size, enabled, uploaded_at}`。 + - `save_fixture`:落盘到 `fixtures//`,写 manifest(默认 enabled=true),校验扩展名。 + - `set_enabled`:更新 manifest 启用/停用标记。 + - `delete_fixture`:软删除(移动到 `fixtures//.trash/`,可恢复,且不计入列表)。 + - `preview_text`:复用 `distill_ipd.extract_text` 抽取 PDF/PPTX/DOCX/XLSX 纯文本;MD/TXT 直接读;为空时返回友好提示。 + - `_safe_name`:文件名归一 + 路径穿越校验(`/` `..` 一律拒绝)。 +- `main.py` 新增 5 个端点(`GET/POST fixtures`、`POST .../enable`、`DELETE ...`、`GET .../preview`)。 + +### 前端 +- `views/Fixtures.vue`:项目+领域下拉、el-upload 上传、表格(文件名/类型/大小/启用开关/上传时间/预览·删除)、预览对话框(可滚动文本,超长截断提示)。 +- `api.js` 封装 5 个素材 API + `fixtureUploadUrl`。 +- `App.vue` 顶部导航新增「素材管理」。 + +## 验收实测(curl) +| 项目 | 结果 | +|---|---| +| 上传 sample.md / sample.txt | ok,enabled=true ✅ | +| 列表显示两个素材 | ✅ | +| 预览 MD/TXT | available:true + 文本 ✅ | +| 停用 sample.txt | enabled:false 反映到列表 ✅ | +| 上传 .zip | 400「不支持的类型」✅ | +| 删除 sample.txt | 软删除,列表剩 sample.md,.trash 留存 ✅ | +| 恶意文件名 `../../pwn.txt` 上传 | 400「非法文件名」,无文件逃逸 ✅ | +| 前端 vite build | 1635 模块全部 transformed,dist 生成 ✅ | + +## 关键决策 / 契约 +- **R2S core 本身不消费 `fixtures/`**:`distill_ipd.py` 无 manifest 概念。启用/停用 manifest 是 WebUI 侧新引入的契约,**切片3 通用 collect 将读取它来筛选素材**(只蒸馏 enabled 的)。 +- 删除统一走软删除(沙箱禁硬删,且可恢复)。 +- 预览复用 distill 抽取逻辑,不触发 LLM 蒸馏。 + +## 下一步 +切片3:通用采集器 `core/collector.py`(领域无关,读取本片 manifest 筛选素材)+ 蒸馏工作台(后台线程 + SSE 日志)+ 任务中心。 diff --git a/webui/SLICE3_OVERVIEW.md b/webui/SLICE3_OVERVIEW.md new file mode 100644 index 00000000..fb35248d --- /dev/null +++ b/webui/SLICE3_OVERVIEW.md @@ -0,0 +1,38 @@ +# 切片3:通用采集器 + 蒸馏工作台 + 任务中心 — 交付概览 + +日期:2026-07-28 + +## 本片交付 + +### 通用采集器(core/collector.py,领域无关) +- 读 `fixtures//manifest.json` 筛 **enabled** 素材 → 复用 `distill_ipd` 的抽取/切片/技能名解析 → 用 domain 的 `distiller_prompt.md`(无则通用提示词)蒸馏 → 写**项目** `skills_library//` 的 R2S 兼容 `skill.json` + `index.json`。 +- MD/TXT 由本地 `_extract_text` 直读(`distill_ipd` 全局 `SKIP_EXT` 故意跳过 `.md`,未改 core)。 +- LLM 传输由调用方注入的 `llm_fn(messages, *, model, max_tokens, reasoning) -> str` 提供,core 不依赖 webui、不碰全局 env。 + +### 后端:蒸馏任务 + 任务中心 +- `webui/backend/tasks.py`:单 worker **串行**任务注册表(蒸馏 / 后续 Agent 共用),内存态(重启即丢);Task 带 log 缓冲(封顶 3000 行)+ `stop_requested` 终止标志。 +- `webui/backend/distill.py`:把用户 active LLM 模板经**环境变量上下文管理器**注入 `core.llm.call_azure_openai`;**蒸馏前 ping 预检**(reasoning=none, 5 token),坏 key 立即 error 拒绝,避免 5×10s 重试空耗;`dry_run` 换桩跳过 LLM。 +- 端点:`POST /api/projects/{p}/domains/{d}/distill`、`GET /api/tasks`、`GET /api/tasks/{id}`、`POST /api/tasks/{id}/stop`。 + +### 前端 +- `views/DistillWorkbench.vue`:项目/域选择 + 开始蒸馏 + `dry_run` 勾选 + 实时日志轮询 + 终止。 +- `views/TaskCenter.vue`:任务列表(类型/项目域/状态/时间)+ 日志查看 + 终止。 +- `api.js` 封装 4 个 API;`App.vue` 导航新增「蒸馏工作台」「任务中心」。 + +## 验收实测 +| 项目 | 结果 | +|---|---| +| dry_run 蒸馏(不调 LLM) | 写 `skills_library/ipd` skill.json + index.json total=1 ✅ | +| **真实蒸馏**(DS 模板 key 有效) | sample.md → 真实技能「基于 4W+2H 的 IPD Charter 快速生成模板」,任务 done ✅ | +| 预检失败快速 error(坏 key) | 不进入蒸馏循环、不空耗 ✅ | +| env 注入/还原(无 API 调用) | IN 正确、AFTER 完整还原 ✅ | +| 任务中心列表/日志/终止 | ✅ | +| 前端 vite build | 1637 模块全过 ✅ | + +## 关键决策 / 契约 +- **LLM 接入**:`call_azure_openai` 仅从环境变量读配置(不收参数)。故用 **env 注入 + 单 worker 串行** 把用户模板喂给 core,避免并发改 env 互相踩;provider 映射:deepseek / azure / openai_compatible(ollama 等)。 +- **零 core 重构**:采集器写**项目**技能库,不碰全局 `skills_library/`;distill_ipd 全局行为未改。 +- **预检闸**:真实蒸馏前先 ping,连通才继续,保护无效 key 不空耗、不花冤枉 token。 + +## 下一步 +切片4:Agent 执行台(`core.agent_executor` 以项目根 cwd 运行,流式 token+工具调用+gate)+ 产物仓库(浏览 skills/output、预览 md/html/img、下载)。 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..57eeb8aa --- /dev/null +++ b/webui/backend/agent.py @@ -0,0 +1,175 @@ +"""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 协同)。 +- 对 IPD 领域注入 IPD_SKILLS_DIR / IPD_WORKSPACE 环境变量,实现产物隔离 + (依赖 domains/ipd/mcp_server/server.py 的 env 覆盖补丁,未设置则回退仓库根)。 +- 把 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() + ipd_env = ("IPD_SKILLS_DIR", "IPD_WORKSPACE") + saved_env = {k: os.environ.get(k) for k in ipd_env} + try: + os.chdir(str(pdir)) + if domain == "ipd": + os.environ["IPD_SKILLS_DIR"] = str(skills_dir) + os.environ["IPD_WORKSPACE"] = str(pdir / "output" / "ipd_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..219770c9 --- /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/),沙箱禁止硬删,且可恢复。 +- 预览复用 distill_ipd.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: + # 复用 distill_ipd 的抽取逻辑(懒导入,避免启动即加载重型依赖) + try: + from distill_ipd 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..1a57fe3b --- /dev/null +++ b/webui/backend/main.py @@ -0,0 +1,307 @@ +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) ----------------------------- + +@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 = tasks.submit( + type="distill", + project=name, + domain=domain, + label=f"蒸馏 {name}/{domain}" + ("(dry-run)" if body.dry_run else ""), + runner=lambda t: distill.run_distill_task(t, name, domain, body.dry_run), + ) + 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/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) ----------------------------- + +@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 = tasks.submit( + type="agent", + project=name, + domain=body.domain, + label=f"Agent {name}/{body.domain}" + ("(dry-run)" if body.dry_run else ""), + 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, + ), + ) + 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..870ee40c --- /dev/null +++ b/webui/backend/projects.py @@ -0,0 +1,273 @@ +"""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。 +- 已有同名仓库领域(如 ipd)可“播种”进项目,保证 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 注入项目隔离路径(IPD 领域)。 + # 这样 MCP 子进程(cwd=项目根、args 相对解析到本项目 mcp_server)会把技能库与 + # 产物落到本项目数据目录,且产物位于 output/ 下,便于产物仓库浏览与隔离。 + _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 注入项目隔离路径。 + + 仅 ipd 领域需要 IPD_SKILLS_DIR / IPD_WORKSPACE。主通道是命令行参数 + (MCP 子进程透传可靠,server.py 解析 --workspace/--skills-dir);同时保留 + mcp.env 作为兜底。这样 MCP 子进程把技能库与产物落到本项目数据目录,且产物 + 位于 output/ 下,便于产物仓库浏览与隔离。 + """ + 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): + mcp["cwd"] = str(project_dir) + if domain == "ipd": + # 关键:IPD 的 mcp_server 由“仓库根单实例”承载(单一事实来源), + # 避免 projects/

/domains/ipd 下的播种副本滞后导致 _PROJECT_ROOT 回退错误。 + # 项目隔离完全通过 --workspace/--skills-dir 透传实现。 + server_py = (REPO_DOMAINS / domain / "mcp_server" / "server.py").resolve() + skills_dir = (project_dir / "skills_library" / domain).resolve() + workspace = (project_dir / "output" / "ipd_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 的绝对路径;绝不能用播种副本。 + if clean: + clean[0] = str(server_py) + else: + clean = [str(server_py)] + clean += ["--workspace", str(workspace), "--skills-dir", str(skills_dir)] + mcp["args"] = clean + # 兜底环境变量 + env = dict(mcp.get("env") or {}) + env["IPD_SKILLS_DIR"] = str(skills_dir) + env["IPD_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..31d887f8 --- /dev/null +++ b/webui/backend/repo.py @@ -0,0 +1,132 @@ +"""产物仓库(切片4)。 + +浏览项目数据目录: +- 技能库:projects//skills_library//(R2S 蒸馏产物,含 skill.json) +- 产物: projects//output/(agent 交付物落点,IPD 下为 output/ipd_workspace/) + +提供:列举(技能 + 文件树)+ 安全下载/预览(防目录穿越)。 +""" +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..20d69cca --- /dev/null +++ b/webui/backend/tasks.py @@ -0,0 +1,131 @@ +"""后台任务注册表(切片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) + _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 = "") -> Task: + tid = uuid.uuid4().hex[:8] + t = Task(id=tid, type=type, project=project, domain=domain, + label=label, created_at=_iso()) + 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, + } + 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, + } 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..9c6e77fd --- /dev/null +++ b/webui/frontend/src/App.vue @@ -0,0 +1,61 @@ + + + diff --git a/webui/frontend/src/api.js b/webui/frontend/src/api.js new file mode 100644 index 00000000..2a19f191 --- /dev/null +++ b/webui/frontend/src/api.js @@ -0,0 +1,61 @@ +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) + +// ---- 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..414eff94 --- /dev/null +++ b/webui/frontend/src/components/TaskProgress.vue @@ -0,0 +1,79 @@ + + + diff --git a/webui/frontend/src/composables/useActiveContext.js b/webui/frontend/src/composables/useActiveContext.js new file mode 100644 index 00000000..e1dc6475 --- /dev/null +++ b/webui/frontend/src/composables/useActiveContext.js @@ -0,0 +1,55 @@ +// 共享的"当前项目/领域"上下文(模块级 reactive 单例)。 +// 不引入 Pinia:应用小、单用户,模块级单例就是最简形态的 store。 +import { reactive } from 'vue' +import { listProjects, listDomains, activateProject } from '../api.js' + +const state = reactive({ + projects: [], + activeProject: '', + domains: [], + activeDomain: '', + loading: false, +}) + +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 } +} 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..174949dd --- /dev/null +++ b/webui/frontend/src/views/AgentConsole.vue @@ -0,0 +1,115 @@ + + + diff --git a/webui/frontend/src/views/ArtifactRepo.vue b/webui/frontend/src/views/ArtifactRepo.vue new file mode 100644 index 00000000..ece64f10 --- /dev/null +++ b/webui/frontend/src/views/ArtifactRepo.vue @@ -0,0 +1,165 @@ +