diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..06c9c83
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+.git
+.venv
+__pycache__
+*.pyc
+.pytest_cache
+htmlcov
+.coverage
+data
+logs
+*.log
+.env
+.env.local
+docker-compose.override.yml
+frontend/node_modules
+frontend/dist
+app/static/admin
diff --git a/.gitignore b/.gitignore
index 056168c..5cf2009 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@ dist/
build/
.venv/
+# Frontend
+node_modules/
+
# Env
.env
.env.local
diff --git a/Dockerfile b/Dockerfile
index 371ba2f..354dd7d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,17 @@
+# ---------- Stage 1: 前端构建 ----------
+FROM node:20-slim AS frontend-builder
+
+WORKDIR /frontend
+
+# 依赖层(利用 Docker 层缓存)
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm ci
+
+# 源码 + 构建
+COPY frontend/ ./
+RUN npm run build
+
+# ---------- Stage 2: Python 后端 ----------
FROM python:3.12-slim AS base
WORKDIR /app
@@ -13,6 +27,9 @@ RUN uv sync --frozen --no-dev
COPY app/ app/
COPY scripts/ scripts/
+# 前端构建产物 → 后端静态目录(SPA 由 /admin 路由服务)
+COPY --from=frontend-builder /frontend/dist /app/app/static/admin
+
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
diff --git a/app/api/v1/settings.py b/app/api/v1/settings.py
new file mode 100644
index 0000000..cf9728e
--- /dev/null
+++ b/app/api/v1/settings.py
@@ -0,0 +1,114 @@
+"""运行时配置 API:GET/PUT /api/v1/settings、GET /api/v1/settings/schema、POST /api/v1/settings/reset
+
+GET 任何登录用户可读;PUT/reset 需 admin。PUT 后会清空 LLM 客户端 / 解析插件 /
+去重策略三处进程级缓存,使新配置立即对后续请求生效。
+"""
+
+from typing import Any
+
+import structlog
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+
+from app.api.response import ApiError, ok
+from app.core.auth import AuthUser, get_current_user, require_admin
+from app.core.dedup import invalidate_dedup_strategy_cache
+from app.core.file_parser import (
+ list_docx_plugins,
+ list_ocr_plugins,
+ list_pdf_plugins,
+ invalidate_parser_plugin_cache,
+)
+from app.core.runtime_settings import (
+ RuntimeSettings,
+ get_runtime_settings,
+ reset_runtime_settings,
+ update_runtime_settings,
+)
+from app.services.llm import invalidate_llm_client_cache
+
+logger = structlog.get_logger()
+
+router = APIRouter(prefix="/api/v1", tags=["settings"])
+
+
+class SettingsUpdateRequest(BaseModel):
+ """Settings PATCH body:任意子树可缺省,缺省字段保留原值
+
+ 例:{"models": {"summarize": {"model": "qwen2.5:3b"}}}、
+ {"parsers": {"ocr": {"plugin": "tesseract"}}}、
+ {"dedup": {"strategy": "simhash", "simhash_threshold": 5}}
+ """
+
+ models: dict[str, Any] | None = None
+ parsers: dict[str, Any] | None = None
+ dedup: dict[str, Any] | None = None
+
+
+@router.get("/settings")
+async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
+ """返回当前 RuntimeSettings(任何登录用户可读)"""
+ cfg = get_runtime_settings()
+ return ok(cfg.model_dump(mode="json"))
+
+
+@router.put("/settings")
+async def update_settings(
+ body: SettingsUpdateRequest,
+ user: AuthUser = Depends(require_admin),
+) -> dict[str, Any]:
+ """部分更新 RuntimeSettings(仅 admin)
+
+ 更新成功后清空 LLM 客户端 / 解析插件 / 去重策略三处进程级缓存,
+ 使新配置立即对后续请求生效。
+ """
+ patch = body.model_dump(exclude_none=True)
+ if not patch:
+ raise ApiError(1001, "请求体为空,未提供任何待更新字段")
+
+ try:
+ cfg = update_runtime_settings(patch)
+ except Exception as exc:
+ logger.error("RuntimeSettings 更新失败", error=str(exc), exc_info=True)
+ raise ApiError(2000, f"配置更新失败: {exc}") from exc
+
+ # 清缓存:让后续读取拿到新配置
+ invalidate_llm_client_cache()
+ invalidate_parser_plugin_cache()
+ invalidate_dedup_strategy_cache()
+ logger.info("RuntimeSettings 已更新并清理缓存", operator=user.username)
+ return ok(cfg.model_dump(mode="json"))
+
+
+@router.get("/settings/schema")
+async def get_settings_schema(
+ user: AuthUser = Depends(get_current_user),
+) -> dict[str, Any]:
+ """返回可选插件与策略列表(前端 Settings 页渲染选项用)"""
+ return ok(
+ {
+ "llm_providers": ["ollama", "openai_compatible"],
+ "pdf_plugins": list_pdf_plugins(),
+ "docx_plugins": list_docx_plugins(),
+ "ocr_plugins": list_ocr_plugins(),
+ "dedup_strategies": ["none", "sha256", "simhash"],
+ }
+ )
+
+
+@router.post("/settings/reset")
+async def reset_settings(
+ user: AuthUser = Depends(require_admin),
+) -> dict[str, Any]:
+ """重置 RuntimeSettings 为默认值(仅 admin),同时清缓存"""
+ try:
+ cfg = reset_runtime_settings()
+ except Exception as exc:
+ logger.error("RuntimeSettings 重置失败", error=str(exc), exc_info=True)
+ raise ApiError(2000, f"配置重置失败: {exc}") from exc
+
+ invalidate_llm_client_cache()
+ invalidate_parser_plugin_cache()
+ invalidate_dedup_strategy_cache()
+ logger.info("RuntimeSettings 已重置为默认值", operator=user.username)
+ return ok(cfg.model_dump(mode="json"))
diff --git a/app/config.py b/app/config.py
index b8ad1a3..9b1a7fd 100644
--- a/app/config.py
+++ b/app/config.py
@@ -9,11 +9,12 @@ class Settings(BaseSettings):
log_level: str = "info"
# 嵌入模型
- embedding_provider: str = "openai" # openai | local
+ embedding_provider: str = "local" # openai | local
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
embedding_model: str = "text-embedding-3-small"
- embedding_dimension: int = 1536
+ # bge-m3(本地 Ollama 嵌入)维度为 1024;切换 openai provider 时需同步改为 1536
+ embedding_dimension: int = 1024
# Ollama 本地模型(用于文档三级总结)
ollama_base_url: str = "http://localhost:11434"
diff --git a/app/core/classifier.py b/app/core/classifier.py
index a15972e..9f1a82f 100644
--- a/app/core/classifier.py
+++ b/app/core/classifier.py
@@ -1,6 +1,7 @@
"""文档分类器
-入库链路第二步:基于 L1 总结,用 Ollama 小模型将文档判定为 taxonomy 中的
+入库链路第二步:基于 L1 总结,用 LLM(Ollama 或 OpenAI 兼容服务,由
+runtime_settings.models.classify 决定)将文档判定为 taxonomy 中的
主类目 + 附加标签:
- LLM 输出解析失败 / 类目名不在 taxonomy → 归 uncategorized(confidence=0.0)
- 置信度低于阈值 → 主类目归 uncategorized,候选类目名保留进 tags(软召回用)
@@ -13,7 +14,7 @@ import structlog
from app.config import settings
from app.models.knowledge import UNCATEGORIZED, CategoryResult, TaxonomyCategory, load_taxonomy
-from app.services.ollama import OllamaClient
+from app.services.llm import LLMClient, create_llm_client
logger = structlog.get_logger()
@@ -47,8 +48,10 @@ def _extract_json(raw: str) -> dict | None:
class Classifier:
"""文档分类器:将 L1 总结判定为 taxonomy 主类目 + 附加标签"""
- def __init__(self, ollama: OllamaClient | None = None, taxonomy: list[TaxonomyCategory] | None = None) -> None:
- self.ollama = ollama or OllamaClient()
+ def __init__(self, ollama: LLMClient | None = None, taxonomy: list[TaxonomyCategory] | None = None) -> None:
+ # 默认按 runtime_settings.models.classify 选择 LLM 实现;
+ # 测试可通过 ollama 参数注入替身。
+ self.ollama = ollama or create_llm_client("classify")
self.taxonomy = taxonomy if taxonomy is not None else load_taxonomy()
# 合法类目名集合(含 uncategorized)
self._valid_names = {c.name for c in self.taxonomy}
diff --git a/app/core/dedup.py b/app/core/dedup.py
new file mode 100644
index 0000000..33beba0
--- /dev/null
+++ b/app/core/dedup.py
@@ -0,0 +1,216 @@
+"""文本去重策略工厂
+
+支持三种策略:
+- none:关闭去重,每次都跑完整流水线
+- sha256:精确匹配(hash 完全相同才算重复)
+- simhash:近似匹配(海明距离 <= 阈值视为重复)
+
+每种策略实现 DedupStrategy 协议:lookup(text) -> dict|None、record(text, result) -> None。
+工厂 get_dedup_strategy() 根据 runtime_settings.dedup 选择实现。
+所有策略共享同一 Redis key 前缀,但 key 后缀按策略区分避免冲突。
+"""
+
+from __future__ import annotations
+
+import hashlib
+from typing import Any, Protocol, runtime_checkable
+
+import structlog
+
+from app.core.runtime_settings import get_runtime_settings
+from app.services.redis import RedisCache
+
+logger = structlog.get_logger()
+
+# Redis 去重 key 前缀,按策略分桶
+_DEDUP_KEY_PREFIX = "dedup:"
+
+
+@runtime_checkable
+class DedupStrategy(Protocol):
+ """去重策略协议"""
+
+ async def lookup(self, text: str) -> dict[str, Any] | None:
+ """查询文本是否已入库;命中返回旧 IngestionResult(dict),未命中返回 None"""
+ ...
+
+ async def record(self, text: str, result_dict: dict[str, Any]) -> None:
+ """记录文本已入库,供后续命中复用"""
+ ...
+
+
+class NoopDedupStrategy:
+ """关闭去重:永远不命中,也不记录"""
+
+ async def lookup(self, text: str) -> dict[str, Any] | None:
+ return None
+
+ async def record(self, text: str, result_dict: dict[str, Any]) -> None:
+ return None
+
+
+class Sha256DedupStrategy:
+ """SHA256 精确去重:完全相同文本才算重复"""
+
+ def __init__(self, redis: RedisCache, ttl_seconds: int) -> None:
+ self._redis = redis
+ self._ttl = ttl_seconds
+
+ def _key(self, text: str) -> str:
+ h = hashlib.sha256(text.encode("utf-8")).hexdigest()
+ return f"{_DEDUP_KEY_PREFIX}sha256:{h}"
+
+ async def lookup(self, text: str) -> dict[str, Any] | None:
+ try:
+ return await self._redis.get_json(self._key(text))
+ except Exception:
+ logger.warning("sha256 去重查询失败,降级未命中", exc_info=True)
+ return None
+
+ async def record(self, text: str, result_dict: dict[str, Any]) -> None:
+ try:
+ await self._redis.set_json(self._key(text), result_dict, ttl=self._ttl)
+ except Exception:
+ logger.warning("sha256 去重记录写入失败", exc_info=True)
+
+
+# SimHash 实现(64 位)
+_MASK_64 = (1 << 64) - 1
+
+
+def _simhash(text: str, token_size: int = 4) -> int:
+ """计算文本 64 位 simhash
+
+ 简化实现:按 token_size 字符滑窗分词,每段 md5 → 128 位 → 取低 64 位作为 hash,
+ 逐位加权(hash 该位为 1 则 +1,为 0 则 -1),最终符号位定 1/0。
+ """
+ if not text:
+ return 0
+ tokens = [text[i : i + token_size] for i in range(0, len(text), token_size)]
+ weights = [1] * 64
+ vec = [0] * 64
+ for token in tokens:
+ h = int(hashlib.md5(token.encode("utf-8")).hexdigest(), 16) & _MASK_64
+ for i in range(64):
+ bit = (h >> i) & 1
+ vec[i] += weights[i] if bit else -weights[i]
+ fingerprint = 0
+ for i in range(64):
+ if vec[i] > 0:
+ fingerprint |= (1 << i)
+ return fingerprint
+
+
+def _hamming_distance(a: int, b: int) -> int:
+ return bin((a ^ b) & _MASK_64).count("1")
+
+
+class SimhashDedupStrategy:
+ """SimHash 近似去重:海明距离 <= 阈值视为重复
+
+ Redis 中存所有已入库文本的 simhash(value 为 IngestionResult + simhash)。
+ lookup 时遍历所有候选 simhash 算海明距离,找最近的一个 <= 阈值则命中。
+ 注:本实现为简化版,全表扫描,适合中小规模知识库;超大规模需换 LSH 索引。
+ """
+
+ INDEX_KEY = f"{_DEDUP_KEY_PREFIX}simhash:index" # list 形式存所有 simhash+key
+
+ def __init__(self, redis: RedisCache, ttl_seconds: int, threshold: int = 3) -> None:
+ self._redis = redis
+ self._ttl = ttl_seconds
+ self._threshold = max(0, min(64, threshold))
+
+ async def lookup(self, text: str) -> dict[str, Any] | None:
+ try:
+ fingerprint = _simhash(text)
+ # 简化:扫描所有 simhash 记录找最近的
+ # 用 set:dedup:simhash:fps 存所有 fingerprint,每个 fp 对应一个 record key
+ # 这里用 list key 简化(适合小规模)
+ index = await self._redis.get_json(self.INDEX_KEY) or {"entries": []}
+ entries = index.get("entries", [])
+ best_dist = self._threshold + 1
+ best_key: str | None = None
+ for entry in entries:
+ fp = entry.get("fingerprint")
+ key = entry.get("key")
+ if fp is None or key is None:
+ continue
+ dist = _hamming_distance(fingerprint, int(fp))
+ if dist <= self._threshold and dist < best_dist:
+ best_dist = dist
+ best_key = key
+ if best_key is None:
+ return None
+ return await self._redis.get_json(best_key)
+ except Exception:
+ logger.warning("simhash 去重查询失败,降级未命中", exc_info=True)
+ return None
+
+ async def record(self, text: str, result_dict: dict[str, Any]) -> None:
+ try:
+ fingerprint = _simhash(text)
+ key = f"{_DEDUP_KEY_PREFIX}simhash:{fingerprint:016x}"
+ # 1. 写记录
+ await self._redis.set_json(key, result_dict, ttl=self._ttl)
+ # 2. 更新索引
+ index = await self._redis.get_json(self.INDEX_KEY) or {"entries": []}
+ entries = index.get("entries", [])
+ entries.append({"fingerprint": fingerprint, "key": key})
+ await self._redis.set_json(self.INDEX_KEY, {"entries": entries}, ttl=self._ttl)
+ except Exception:
+ logger.warning("simhash 去重记录写入失败", exc_info=True)
+
+
+# ---------------------------------------------------------------------------- #
+# 工厂
+# ---------------------------------------------------------------------------- #
+
+_strategy_cache: DedupStrategy | None = None
+_strategy_signature: tuple[str, int, int] | None = None # (strategy, ttl, threshold)
+
+
+def get_dedup_strategy(redis: RedisCache | None) -> DedupStrategy:
+ """按 runtime_settings.dedup 构造去重策略
+
+ Redis 不可用时降级为 NoopDedupStrategy(关闭去重),不影响主流程。
+ 策略配置变更时自动重建单例。
+ """
+ global _strategy_cache, _strategy_signature
+
+ rt = get_runtime_settings()
+ sig = (rt.dedup.strategy, rt.dedup.ttl_seconds, rt.dedup.simhash_threshold)
+
+ if _strategy_cache is not None and _strategy_signature == sig:
+ return _strategy_cache
+
+ if redis is None or rt.dedup.strategy == "none":
+ strategy: DedupStrategy = NoopDedupStrategy()
+ elif rt.dedup.strategy == "sha256":
+ strategy = Sha256DedupStrategy(redis=redis, ttl_seconds=rt.dedup.ttl_seconds)
+ elif rt.dedup.strategy == "simhash":
+ strategy = SimhashDedupStrategy(
+ redis=redis, ttl_seconds=rt.dedup.ttl_seconds, threshold=rt.dedup.simhash_threshold
+ )
+ else:
+ logger.warning("未知去重策略,降级为 noop", requested=rt.dedup.strategy)
+ strategy = NoopDedupStrategy()
+
+ _strategy_cache = strategy
+ _strategy_signature = sig
+ return strategy
+
+
+def invalidate_dedup_strategy_cache() -> None:
+ """清除策略缓存(runtime_settings 更新后调用)"""
+ global _strategy_cache, _strategy_signature
+ _strategy_cache = None
+ _strategy_signature = None
+
+
+def compute_text_hash(text: str) -> str:
+ """计算文本哈希(兼容旧接口,sha256 策略下与 dedup key 一致)"""
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+
+# 兼容旧测试与外部引用:保留 DEDUP_KEY_PREFIX 导出
+DEDUP_KEY_PREFIX = _DEDUP_KEY_PREFIX
diff --git a/app/core/file_parser.py b/app/core/file_parser.py
index 1d71ef0..fc18018 100644
--- a/app/core/file_parser.py
+++ b/app/core/file_parser.py
@@ -1,14 +1,19 @@
-"""多格式文件文本提取:按扩展名分发到对应解析器
+"""多格式文件文本提取:按扩展名分发到对应解析器(插件化)
支持的扩展名:
- .txt / .md:UTF-8 解码(errors="replace" 兜底)
- .html / .htm:标准库 html.parser 剥离标签提取可见文本
-- .pdf:pypdf 逐页 extract_text 拼接;文本层为空(扫描件/图片型)时
- 自动降级为 OCR(pypdfium2 渲染 + rapidocr-onnxruntime 识别)
-- .docx:python-docx 段落文本拼接(不含表格/页眉页脚)
+- .pdf:PDF 文本层提取插件(默认 pypdf);文本层为空(扫描件)时降级到 OCR 插件
+- .docx:DOCX 解析插件(默认 python-docx)
+
+插件化设计:
+- PdfTextExtractor / DocxParser / OcrEngine 三个 Protocol
+- 每种插件类型有注册表 + 默认实现 + 工厂
+- 工厂根据 runtime_settings.parsers 选择具体插件
+- 未注册或依赖缺失时降级到默认插件并告警
未识别扩展名抛 ValueError("不支持的文件类型: {ext}");
-解析异常统一包装为 ValueError("文件解析失败: {detail}"),原异常链式保留。
+解析异常统一包装为 ValueError("文件解析失败: {detail}")。
"""
from __future__ import annotations
@@ -17,17 +22,19 @@ import io
from collections.abc import Callable
from html.parser import HTMLParser
from pathlib import Path
-from typing import Any
+from typing import Any, Protocol, runtime_checkable
import structlog
from app.config import settings
+from app.core.runtime_settings import get_runtime_settings
logger = structlog.get_logger()
-# 模块级懒加载 OCR 引擎单例(首次调用时初始化,避免无扫描件场景白白下载模型)
-_ocr_engine: Any | None = None
-_ocr_unavailable: bool = False # 标记 OCR 依赖不可用,后续直接跳过避免重复尝试
+
+# ---------------------------------------------------------------------------- #
+# HTML / Text 解析(无插件化需求,保留原实现)
+# ---------------------------------------------------------------------------- #
class _VisibleTextExtractor(HTMLParser):
@@ -51,13 +58,11 @@ class _VisibleTextExtractor(HTMLParser):
self._parts.append(data)
def get_text(self) -> str:
- # 块级标签间用空格连接,再折叠多余空白
text = " ".join(self._parts)
return " ".join(text.split())
def _decode_html(content: bytes) -> str:
- """HTML 内容解码:优先 utf-8(带 BOM),失败回退 latin-1"""
try:
return content.decode("utf-8-sig")
except UnicodeDecodeError:
@@ -65,108 +70,362 @@ def _decode_html(content: bytes) -> str:
def _parse_text(content: bytes) -> str:
- """UTF-8 解码(errors=replace 兜底),保留原字符"""
return content.decode("utf-8", errors="replace")
def _parse_html(content: bytes) -> str:
- """HTML 剥离标签,保留可见文本"""
parser = _VisibleTextExtractor()
parser.feed(_decode_html(content))
parser.close()
return parser.get_text()
-def _parse_pdf(content: bytes) -> str:
- """PDF 解析:优先 pypdf extract_text;文本层为空(扫描件)时降级 OCR
+# ---------------------------------------------------------------------------- #
+# 插件协议
+# ---------------------------------------------------------------------------- #
- OCR 流程:pypdfium2 渲染每页为 PIL Image → rapidocr-onnxruntime 识别 →
- 拼接每页识别出的文本。受 settings.pdf_ocr_* 控制:开关、最大页数、DPI。
- OCR 依赖未安装或运行异常时降级返回空字符串(由上游 upload 端点拒绝入库)。
+
+@runtime_checkable
+class PdfTextExtractor(Protocol):
+ """PDF 文本层提取插件协议"""
+
+ def extract_text(self, content: bytes) -> str:
+ """从 PDF 二进制内容提取文本层;无文本层返回空字符串"""
+ ...
+
+
+@runtime_checkable
+class DocxParser(Protocol):
+ """DOCX 解析插件协议"""
+
+ def extract_text(self, content: bytes) -> str:
+ """从 DOCX 二进制内容提取段落文本"""
+ ...
+
+
+@runtime_checkable
+class OcrEngine(Protocol):
+ """OCR 引擎插件协议"""
+
+ def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
+ """对 PDF 跑 OCR,返回识别文本;失败返回空字符串"""
+ ...
+
+
+# ---------------------------------------------------------------------------- #
+# 默认 PDF 插件:pypdf
+# ---------------------------------------------------------------------------- #
+
+
+class PypdfTextExtractor:
+ """pypdf 文本层提取(默认)"""
+
+ def extract_text(self, content: bytes) -> str:
+ from pypdf import PdfReader
+
+ reader = PdfReader(io.BytesIO(content))
+ parts: list[str] = []
+ for page in reader.pages:
+ text = page.extract_text() or ""
+ if text:
+ parts.append(text)
+ return "\n".join(parts).strip()
+
+
+# ---------------------------------------------------------------------------- #
+# 备选 PDF 插件:pdfplumber(lazy import,依赖缺失时不可用)
+# ---------------------------------------------------------------------------- #
+
+
+class PdfplumberTextExtractor:
+ """pdfplumber 文本层提取(备选,对复杂排版更友好)"""
+
+ def extract_text(self, content: bytes) -> str:
+ import pdfplumber # type: ignore[import-untyped]
+
+ parts: list[str] = []
+ with pdfplumber.open(io.BytesIO(content)) as pdf:
+ for page in pdf.pages:
+ text = page.extract_text() or ""
+ if text:
+ parts.append(text)
+ return "\n".join(parts).strip()
+
+
+# ---------------------------------------------------------------------------- #
+# 默认 DOCX 插件:python-docx
+# ---------------------------------------------------------------------------- #
+
+
+class PythonDocxParser:
+ """python-docx 段落提取(默认)"""
+
+ def extract_text(self, content: bytes) -> str:
+ from docx import Document # type: ignore[import-untyped]
+
+ document = Document(io.BytesIO(content))
+ parts = [p.text for p in document.paragraphs if p.text and p.text.strip()]
+ return "\n".join(parts).strip()
+
+
+# ---------------------------------------------------------------------------- #
+# 默认 OCR 插件:rapidocr-onnxruntime + pypdfium2
+# ---------------------------------------------------------------------------- #
+
+
+class RapidocrOcrEngine:
+ """rapidocr-onnxruntime OCR 引擎(默认)
+
+ 流程:pypdfium2 渲染每页为 PIL Image → rapidocr 识别 → 拼接。
+ 类级懒加载单例(_engine/_unavailable 为类属性,跨实例共享),
+ 依赖缺失时降级返回空文本。
"""
- from pypdf import PdfReader
- reader = PdfReader(io.BytesIO(content))
- parts: list[str] = []
- for page in reader.pages:
- text = page.extract_text() or ""
- if text:
- parts.append(text)
- text_layer = "\n".join(parts).strip()
+ _engine: Any | None = None
+ _unavailable: bool = False
+
+ def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
+ if self._unavailable:
+ return ""
+
+ # 1. 懒加载 OCR 引擎(类级单例,跨实例共享)
+ if self._engine is None:
+ try:
+ from rapidocr_onnxruntime import RapidOCR
+
+ RapidocrOcrEngine._engine = RapidOCR()
+ logger.info("PDF OCR 引擎已初始化", dpi=dpi)
+ except Exception:
+ RapidocrOcrEngine._unavailable = True
+ logger.warning(
+ "OCR 依赖不可用,扫描件 PDF 将无法提取文本", exc_info=True
+ )
+ return ""
+
+ # 2. 渲染并识别
+ try:
+ import pypdfium2 as pdfium
+
+ scale = max(1.0, dpi / 72.0)
+ pdf = pdfium.PdfDocument(io.BytesIO(content))
+ total = min(len(pdf), max(1, max_pages))
+ page_texts: list[str] = []
+ for i in range(total):
+ page = pdf[i]
+ pil_image = page.render(scale=scale).to_pil()
+ result, _ = self._engine(pil_image)
+ if result:
+ lines = [
+ item[1]
+ for item in result
+ if item and len(item) >= 2 and item[1]
+ ]
+ if lines:
+ page_texts.append("\n".join(lines))
+ pdf.close()
+ return "\n".join(page_texts).strip()
+ except Exception as exc:
+ logger.warning(
+ "PDF OCR 失败,降级返回空文本", error=str(exc), exc_info=True
+ )
+ return ""
+
+
+# ---------------------------------------------------------------------------- #
+# 备选 OCR 插件:tesseract(lazy import,依赖缺失时不可用)
+# ---------------------------------------------------------------------------- #
+
+
+class TesseractOcrEngine:
+ """pytesseract + pdfium OCR(备选)
+
+ 需要系统安装 tesseract 二进制与语言包。
+ 类级 _unavailable 单例,跨实例共享。
+ """
+
+ _unavailable: bool = False
+
+ def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
+ if self._unavailable:
+ return ""
+ try:
+ import pypdfium2 as pdfium
+ import pytesseract # type: ignore[import-untyped]
+ from PIL import Image # type: ignore[import-untyped]
+ except Exception:
+ TesseractOcrEngine._unavailable = True
+ logger.warning("tesseract 依赖不可用,降级返回空文本", exc_info=True)
+ return ""
+
+ try:
+ scale = max(1.0, dpi / 72.0)
+ pdf = pdfium.PdfDocument(io.BytesIO(content))
+ total = min(len(pdf), max(1, max_pages))
+ page_texts: list[str] = []
+ for i in range(total):
+ page = pdf[i]
+ pil_image = page.render(scale=scale).to_pil()
+ text = pytesseract.image_to_string(pil_image, lang="chi_sim+eng")
+ if text:
+ page_texts.append(text)
+ pdf.close()
+ return "\n".join(page_texts).strip()
+ except Exception as exc:
+ logger.warning(
+ "tesseract OCR 失败,降级返回空文本", error=str(exc), exc_info=True
+ )
+ return ""
+
+
+class NoopOcrEngine:
+ """关闭 OCR 的占位插件(strategy=none 时使用)"""
+
+ def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
+ return ""
+
+
+# ---------------------------------------------------------------------------- #
+# 插件注册表
+# ---------------------------------------------------------------------------- #
+
+_PDF_PLUGINS: dict[str, type[PdfTextExtractor]] = {
+ "pypdf": PypdfTextExtractor,
+ "pdfplumber": PdfplumberTextExtractor,
+}
+
+_DOCX_PLUGINS: dict[str, type[DocxParser]] = {
+ "python_docx": PythonDocxParser,
+}
+
+_OCR_PLUGINS: dict[str, type[OcrEngine]] = {
+ "rapidocr": RapidocrOcrEngine,
+ "tesseract": TesseractOcrEngine,
+ "none": NoopOcrEngine,
+}
+
+
+def list_pdf_plugins() -> list[str]:
+ return list(_PDF_PLUGINS.keys())
+
+
+def list_docx_plugins() -> list[str]:
+ return list(_DOCX_PLUGINS.keys())
+
+
+def list_ocr_plugins() -> list[str]:
+ return list(_OCR_PLUGINS.keys())
+
+
+# ---------------------------------------------------------------------------- #
+# 工厂
+# ---------------------------------------------------------------------------- #
+
+# 进程级插件单例缓存
+_pdf_plugin_cache: dict[str, PdfTextExtractor] = {}
+_docx_plugin_cache: dict[str, DocxParser] = {}
+_ocr_plugin_cache: dict[str, OcrEngine] = {}
+
+
+def _get_pdf_plugin() -> PdfTextExtractor:
+ rt = get_runtime_settings()
+ name = rt.parsers.pdf.plugin or "pypdf"
+ if name in _pdf_plugin_cache:
+ return _pdf_plugin_cache[name]
+ cls = _PDF_PLUGINS.get(name)
+ if cls is None:
+ logger.warning("未知 PDF 插件,降级到 pypdf", requested=name)
+ cls = PypdfTextExtractor
+ name = "pypdf"
+ instance = cls()
+ _pdf_plugin_cache[name] = instance
+ return instance
+
+
+def _get_docx_plugin() -> DocxParser:
+ rt = get_runtime_settings()
+ name = rt.parsers.docx.plugin or "python_docx"
+ if name in _docx_plugin_cache:
+ return _docx_plugin_cache[name]
+ cls = _DOCX_PLUGINS.get(name)
+ if cls is None:
+ logger.warning("未知 DOCX 插件,降级到 python_docx", requested=name)
+ cls = PythonDocxParser
+ name = "python_docx"
+ instance = cls()
+ _docx_plugin_cache[name] = instance
+ return instance
+
+
+def _get_ocr_plugin() -> OcrEngine:
+ rt = get_runtime_settings()
+ name = rt.parsers.ocr.plugin or "rapidocr"
+ if name in _ocr_plugin_cache:
+ return _ocr_plugin_cache[name]
+ cls = _OCR_PLUGINS.get(name)
+ if cls is None:
+ logger.warning("未知 OCR 插件,降级到 rapidocr", requested=name)
+ cls = RapidocrOcrEngine
+ name = "rapidocr"
+ instance = cls()
+ _ocr_plugin_cache[name] = instance
+ return instance
+
+
+def invalidate_parser_plugin_cache(kind: str | None = None) -> None:
+ """清除插件缓存(runtime_settings 更新后调用)"""
+ if kind is None or kind == "pdf":
+ _pdf_plugin_cache.clear()
+ if kind is None or kind == "docx":
+ _docx_plugin_cache.clear()
+ if kind is None or kind == "ocr":
+ _ocr_plugin_cache.clear()
+ # 重置 RapidocrOcrEngine 类级标记,允许重新初始化
+ RapidocrOcrEngine._engine = None
+ RapidocrOcrEngine._unavailable = False
+ TesseractOcrEngine._unavailable = False
+
+
+# ---------------------------------------------------------------------------- #
+# 顶层解析函数
+# ---------------------------------------------------------------------------- #
+
+
+def _parse_pdf(content: bytes) -> str:
+ """PDF 解析:优先文本层插件;文本层为空时降级 OCR 插件"""
+ rt = get_runtime_settings()
+ pdf_plugin = _get_pdf_plugin()
+ try:
+ text_layer = pdf_plugin.extract_text(content)
+ except Exception as exc:
+ logger.warning(
+ "PDF 文本层提取失败,尝试 OCR 降级",
+ plugin=rt.parsers.pdf.plugin,
+ error=str(exc),
+ exc_info=True,
+ )
+ text_layer = ""
- # 文本层非空:直接返回
if text_layer:
return text_layer
- # 文本层为空 → 尝试 OCR 降级
+ # 文本层为空 → OCR 降级
if not settings.pdf_ocr_enabled:
return ""
- ocr_text = _ocr_pdf(
- content, max_pages=settings.pdf_ocr_max_pages, dpi=settings.pdf_ocr_dpi
+ ocr_plugin = _get_ocr_plugin()
+ return ocr_plugin.ocr_pdf(
+ content,
+ max_pages=rt.parsers.ocr.params.get("max_pages", settings.pdf_ocr_max_pages),
+ dpi=rt.parsers.ocr.params.get("dpi", settings.pdf_ocr_dpi),
)
- return ocr_text
-
-
-def _ocr_pdf(content: bytes, max_pages: int, dpi: int) -> str:
- """对扫描件 PDF 跑 OCR:渲染每页 → 识别 → 拼接
-
- 返回空字符串的场景:依赖未安装 / 渲染或识别异常 / 无识别结果。
- 任何异常仅告警不抛出,由上游按"无法提取文本"处理。
- """
- global _ocr_engine, _ocr_unavailable
-
- if _ocr_unavailable:
- return ""
-
- # 1. 懒加载 OCR 引擎
- if _ocr_engine is None:
- try:
- from rapidocr_onnxruntime import RapidOCR
-
- _ocr_engine = RapidOCR()
- logger.info("PDF OCR 引擎已初始化", dpi=dpi)
- except Exception:
- _ocr_unavailable = True
- logger.warning("OCR 依赖不可用,扫描件 PDF 将无法提取文本", exc_info=True)
- return ""
-
- # 2. 渲染并识别
- try:
- import pypdfium2 as pdfium
-
- scale = max(1.0, dpi / 72.0)
- pdf = pdfium.PdfDocument(io.BytesIO(content))
- total = min(len(pdf), max(1, max_pages))
- page_texts: list[str] = []
- for i in range(total):
- page = pdf[i]
- pil_image = page.render(scale=scale).to_pil()
- result, _ = _ocr_engine(pil_image)
- if result:
- # result: [[box, text, score], ...],按行拼接
- lines = [
- item[1] for item in result if item and len(item) >= 2 and item[1]
- ]
- if lines:
- page_texts.append("\n".join(lines))
- pdf.close()
- return "\n".join(page_texts).strip()
- except Exception as exc:
- logger.warning("PDF OCR 失败,降级返回空文本", error=str(exc), exc_info=True)
- return ""
def _parse_docx(content: bytes) -> str:
- """DOCX 段落文本拼接(不含表格/页眉页脚)"""
- from docx import Document # type: ignore[import-untyped]
-
- document = Document(io.BytesIO(content))
- parts = [p.text for p in document.paragraphs if p.text and p.text.strip()]
- return "\n".join(parts).strip()
+ """DOCX 解析"""
+ return _get_docx_plugin().extract_text(content)
-# 扩展名 → 解析函数映射(启动时构建,避免每次请求重复构造)
+# 扩展名 → 解析函数映射
_PARSERS: dict[str, Callable[[bytes], str]] = {
".txt": _parse_text,
".md": _parse_text,
diff --git a/app/core/ingest_tasks.py b/app/core/ingest_tasks.py
index a0c9c57..15bd1e6 100644
--- a/app/core/ingest_tasks.py
+++ b/app/core/ingest_tasks.py
@@ -6,10 +6,12 @@
内存注册表为主(记录 status/created_at/updated_at/result/error),
Redis 为持久镜像(key: ingest_task:{task_id}),每次状态迁移同步写入;
Redis 不可用或写入失败仅记录 warning,不影响任务执行。
+
+文本去重委托给 app.core.dedup.get_dedup_strategy(),按 runtime_settings.dedup
+选择策略(none/sha256/simhash)。
"""
import asyncio
-import hashlib
import uuid
from datetime import UTC, datetime
from enum import StrEnum
@@ -18,6 +20,7 @@ from typing import Any
import structlog
from app.config import Settings
+from app.core.dedup import DEDUP_KEY_PREFIX, get_dedup_strategy
from app.core.ingestion import Ingester, IngestionError
from app.models.document import DocumentInput
from app.services.redis import RedisCache
@@ -26,8 +29,6 @@ logger = structlog.get_logger()
# Redis 任务状态 key 前缀
REDIS_KEY_PREFIX = "ingest_task:"
-# Redis 文本去重 key 前缀(value 为已入库文档的 IngestionResult JSON)
-DEDUP_KEY_PREFIX = "dedup:sha256:"
class IngestTaskStatus(StrEnum):
@@ -71,17 +72,17 @@ class IngestTaskManager:
async def submit(self, doc: DocumentInput) -> str:
"""登记入库任务并后台执行,立即返回 task_id
- 文本去重:基于 doc.text 的 sha256 在 Redis 中查重;命中则直接复用旧
- IngestionResult(仅置 deduplicated=True),不重跑流水线;未命中走原
- 异步入库流程,完成后写入去重记录供后续命中复用。Redis 不可用时跳过
- 去重,按原流程执行,不影响主流程。
+ 文本去重:按 runtime_settings.dedup 选择策略(none/sha256/simhash);
+ 命中则直接复用旧 IngestionResult(仅置 deduplicated=True),不重跑流水线;
+ 未命中走原异步入库流程,完成后写入去重记录供后续命中复用。
+ Redis 不可用或策略=none 时跳过,按原流程执行。
"""
task_id = uuid.uuid4().hex
now = _utc_now_iso()
- text_hash = hashlib.sha256(doc.text.encode("utf-8")).hexdigest()
+ dedup = get_dedup_strategy(self._redis)
# 1. 去重命中:直接置 done,复用旧结果,不调 _run
- dedup_record = await self._lookup_dedup(text_hash)
+ dedup_record = await dedup.lookup(doc.text)
if dedup_record is not None:
result_dict = dict(dedup_record)
result_dict["deduplicated"] = True
@@ -111,7 +112,7 @@ class IngestTaskManager:
"error": None,
}
self._schedule_mirror(task_id)
- background = asyncio.create_task(self._run(task_id, doc, text_hash))
+ background = asyncio.create_task(self._run(task_id, doc, dedup))
self._background_tasks.add(background)
background.add_done_callback(self._background_tasks.discard)
logger.info("入库任务已登记", task_id=task_id, title=doc.title)
@@ -151,7 +152,7 @@ class IngestTaskManager:
raise TimeoutError(f"入库任务 {task_id} 在 {timeout}s 内未进入终态")
await asyncio.sleep(0.01)
- async def _run(self, task_id: str, doc: DocumentInput, text_hash: str) -> None:
+ async def _run(self, task_id: str, doc: DocumentInput, dedup: Any) -> None:
"""后台执行入库:并发限流 + 阶段状态推进 + 结果/错误落账 + 去重记录写入"""
async with self._semaphore:
try:
@@ -185,34 +186,9 @@ class IngestTaskManager:
result=result_dict,
)
self._schedule_mirror(task_id)
- await self._record_dedup(text_hash, result_dict)
+ await dedup.record(doc.text, result_dict)
logger.info("入库任务完成", task_id=task_id)
- async def _lookup_dedup(self, text_hash: str) -> dict[str, Any] | None:
- """查询文本去重记录;Redis 不可用或异常时降级为未命中"""
- if self._redis is None:
- return None
- try:
- return await self._redis.get_json(f"{DEDUP_KEY_PREFIX}{text_hash}")
- except Exception:
- logger.warning(
- "去重记录查询失败,降级为未命中", text_hash=text_hash, exc_info=True
- )
- return None
-
- async def _record_dedup(self, text_hash: str, result_dict: dict[str, Any]) -> None:
- """写入文本去重记录(含完整 IngestionResult),供后续命中复用;失败仅告警"""
- if self._redis is None:
- return
- try:
- await self._redis.set_json(
- f"{DEDUP_KEY_PREFIX}{text_hash}",
- result_dict,
- ttl=self._settings.ingest_task_ttl_done,
- )
- except Exception:
- logger.warning("去重记录写入失败", text_hash=text_hash, exc_info=True)
-
def _finish_failed(self, task_id: str, error: dict[str, Any]) -> None:
"""将任务置为 failed 并记录错误信息"""
self._tasks[task_id].update(
diff --git a/app/core/query_parser.py b/app/core/query_parser.py
index 29c0ed3..82fc92e 100644
--- a/app/core/query_parser.py
+++ b/app/core/query_parser.py
@@ -1,6 +1,7 @@
"""query 解析与分类路由模块
-分层 RAG 在线侧第一步:用 Ollama 小模型将用户 query 解析为结构化 JSON
+分层 RAG 在线侧第一步:用 LLM(Ollama 或 OpenAI 兼容服务,由
+runtime_settings.models.query 决定)将用户 query 解析为结构化 JSON
(命中类目+置信度、rewrite 后 query、关键词),再由纯函数做路由决策:
- 高置信且命中类目数 <= 上限 → 按类目过滤检索
- 低置信 / 解析失败 / 命中类目过多 → 全库兜底(不丢召回)
@@ -16,8 +17,8 @@ import structlog
from pydantic import BaseModel, Field, ValidationError
from app.config import settings
-from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory
-from app.services.ollama import OllamaClient
+from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy
+from app.services.llm import LLMClient, create_llm_client
from app.services.redis import RedisCache, get_cache
logger = structlog.get_logger()
@@ -106,17 +107,22 @@ def decide_route(parsed: ParsedQuery, threshold: float, max_categories: int) ->
class QueryParser:
- """query 解析器:调用 Ollama 小模型将 query 解析为结构化 JSON"""
+ """query 解析器:调用 LLM 将 query 解析为结构化 JSON"""
def __init__(
- self, ollama: OllamaClient, taxonomy: list[TaxonomyCategory], cache: RedisCache | None = None
+ self,
+ ollama: LLMClient | None = None,
+ taxonomy: list[TaxonomyCategory] | None = None,
+ cache: RedisCache | None = None,
) -> None:
- self.ollama = ollama
- self.taxonomy = taxonomy
+ # 默认按 runtime_settings.models.query 选择 LLM 实现;
+ # 测试可通过 ollama 参数注入替身。
+ self.ollama = ollama or create_llm_client("query")
+ self.taxonomy = taxonomy if taxonomy is not None else load_taxonomy()
# 解析结果缓存,缺省用全局单例;RedisCache 全操作容错,缓存不可用时退化为无缓存行为
self.cache = cache if cache is not None else get_cache()
# 可作为路由命中类目的名字集合(uncategorized 不可作为路由命中类目)
- self._routable_names = {c.name for c in taxonomy if c.name != UNCATEGORIZED}
+ self._routable_names = {c.name for c in self.taxonomy if c.name != UNCATEGORIZED}
async def parse(self, query: str) -> ParsedQuery:
"""调用 LLM 将 query 解析为结构化结果
diff --git a/app/core/result_summarizer.py b/app/core/result_summarizer.py
index eedef9b..6bdc20b 100644
--- a/app/core/result_summarizer.py
+++ b/app/core/result_summarizer.py
@@ -1,6 +1,7 @@
"""检索结果 AI 总结
-对检索返回的 chunk 命中结果,调用 Ollama 本地模型生成一段针对用户 query 的总结回答。
+对检索返回的 chunk 命中结果,调用 LLM(Ollama 或 OpenAI 兼容服务,由
+runtime_settings.models.query 决定)生成一段针对用户 query 的总结回答。
仅基于检索结果内容,不编造未提及的信息。
"""
@@ -8,7 +9,7 @@ import structlog
from app.config import settings
from app.models.search import SearchHit
-from app.services.ollama import OllamaClient
+from app.services.llm import LLMClient, create_llm_client
logger = structlog.get_logger()
@@ -16,8 +17,10 @@ logger = structlog.get_logger()
class ResultSummarizer:
"""检索结果总结器"""
- def __init__(self, ollama: OllamaClient | None = None) -> None:
- self.ollama = ollama or OllamaClient()
+ def __init__(self, ollama: LLMClient | None = None) -> None:
+ # 默认按 runtime_settings.models.query 选择 LLM 实现;
+ # 测试可通过 ollama 参数注入替身。
+ self.ollama = ollama or create_llm_client("query")
async def summarize(self, query: str, hits: list[SearchHit]) -> str:
"""对检索结果生成针对 query 的总结
diff --git a/app/core/retriever.py b/app/core/retriever.py
index 9240f68..c9fa4a7 100644
--- a/app/core/retriever.py
+++ b/app/core/retriever.py
@@ -21,7 +21,7 @@ from app.core.result_summarizer import ResultSummarizer
from app.core.sparse import SparseEncoder
from app.models.knowledge import load_taxonomy
from app.models.search import ExtractedInfo, SearchHit, SearchRequest, SearchResponse
-from app.services.ollama import OllamaClient
+from app.services.llm import create_llm_client
from app.services.qdrant import (
COLLECTION_CHUNKS,
COLLECTION_L1,
@@ -52,7 +52,7 @@ class Retriever:
) -> None:
self.qdrant = qdrant or QdrantService()
self.query_parser = query_parser or QueryParser(
- ollama=OllamaClient(),
+ ollama=create_llm_client("query"),
taxonomy=load_taxonomy(settings.taxonomy_path),
)
self.embedding = embedding or create_embedding_service()
diff --git a/app/core/runtime_settings.py b/app/core/runtime_settings.py
new file mode 100644
index 0000000..de7cdea
--- /dev/null
+++ b/app/core/runtime_settings.py
@@ -0,0 +1,224 @@
+"""运行时可调配置(runtime settings)
+
+与 `app.config.settings`(启动时从环境变量加载、不可变)互补:本模块管理运行时
+可通过管理后台动态调整的子集,持久化到 JSON 文件,启动时加载覆盖到内存单例。
+
+设计要点:
+- RuntimeSettings 只包含「可运行时调整」的字段(模型/解析插件/去重等),
+ 不包含敏感或启动期固定的字段(端口、数据库连接等)。
+- 持久化路径默认 `data/runtime_settings.json`,可由 env `RUNTIME_SETTINGS_PATH` 覆盖。
+- 加载失败/文件缺失时回退到默认值,不阻塞启动。
+- 写入采用「先临时文件后 rename」原子替换,避免半写损坏。
+- 全模块只通过 `get_runtime_settings()` 访问单例,避免直接读 JSON。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+from pathlib import Path
+from threading import RLock
+from typing import Any, Literal
+
+import structlog
+from pydantic import BaseModel, Field
+
+from app.config import settings
+
+logger = structlog.get_logger()
+
+# 默认持久化路径(相对工作目录);可由 env 覆盖
+DEFAULT_RUNTIME_SETTINGS_PATH = "./data/runtime_settings.json"
+
+_lock = RLock()
+
+
+# ---------------------------------------------------------------------------- #
+# 模型配置(文档总结 / 查询 / 分类 各一份独立配置)
+# ---------------------------------------------------------------------------- #
+
+class LlmProviderConfig(BaseModel):
+ """单个用途的 LLM 提供方配置"""
+
+ provider: Literal["ollama", "openai_compatible"] = Field(
+ default="ollama", description="提供方:ollama 走 Ollama HTTP;openai_compatible 走 OpenAI 兼容 chat/completions"
+ )
+ base_url: str = Field(default="", description="服务地址;空则 ollama 用 settings.ollama_base_url,openai_compatible 用 settings.openai_base_url")
+ api_key: str = Field(default="", description="API Key(仅 openai_compatible 需要;ollama 忽略)")
+ model: str = Field(default="", description="模型名;空则 ollama 用 settings.ollama_model,openai_compatible 用 settings.embedding_model 同级(如 gpt-4o-mini)")
+ timeout: float = Field(default=120.0, description="请求超时秒数")
+ temperature: float = Field(default=0.3, description="采样温度(0~2)")
+
+
+class ModelSettings(BaseModel):
+ """模型相关运行时配置:三种用途独立配置"""
+
+ summarize: LlmProviderConfig = Field(default_factory=LlmProviderConfig, description="文档三级总结")
+ query: LlmProviderConfig = Field(default_factory=LlmProviderConfig, description="检索链路 query 解析与结果总结")
+ classify: LlmProviderConfig = Field(default_factory=LlmProviderConfig, description="文档分类判定")
+
+
+# ---------------------------------------------------------------------------- #
+# 解析插件配置
+# ---------------------------------------------------------------------------- #
+
+class PluginConfig(BaseModel):
+ """单个解析插件的选择与参数"""
+
+ plugin: str = Field(default="", description="插件名;空则用默认")
+ params: dict[str, Any] = Field(default_factory=dict, description="插件参数(透传给插件实现)")
+
+
+class ParserSettings(BaseModel):
+ """解析插件运行时配置"""
+
+ ocr: PluginConfig = Field(default_factory=lambda: PluginConfig(plugin="rapidocr"), description="OCR 插件")
+ pdf: PluginConfig = Field(default_factory=lambda: PluginConfig(plugin="pypdf"), description="PDF 文本层提取插件")
+ docx: PluginConfig = Field(default_factory=lambda: PluginConfig(plugin="python_docx"), description="DOCX 解析插件")
+
+
+# ---------------------------------------------------------------------------- #
+# 去重策略配置
+# ---------------------------------------------------------------------------- #
+
+class DedupSettings(BaseModel):
+ """文本去重策略运行时配置"""
+
+ strategy: Literal["none", "sha256", "simhash"] = Field(
+ default="sha256", description="去重策略:none 关闭;sha256 精确匹配;simhash 近似匹配"
+ )
+ simhash_threshold: int = Field(default=3, description="simhash 海明距离阈值(仅 strategy=simhash 生效,0~64)")
+ ttl_seconds: int = Field(default=86400, description="去重记录 Redis 保留秒数")
+
+
+# ---------------------------------------------------------------------------- #
+# 顶层 RuntimeSettings
+# ---------------------------------------------------------------------------- #
+
+class RuntimeSettings(BaseModel):
+ """运行时可调配置顶层模型"""
+
+ models: ModelSettings = Field(default_factory=ModelSettings)
+ parsers: ParserSettings = Field(default_factory=ParserSettings)
+ dedup: DedupSettings = Field(default_factory=DedupSettings)
+
+
+# ---------------------------------------------------------------------------- #
+# 单例 + 持久化
+# ---------------------------------------------------------------------------- #
+
+_runtime_settings: RuntimeSettings | None = None
+
+
+def _resolve_path() -> Path:
+ """解析持久化文件路径:env RUNTIME_SETTINGS_PATH > settings 自定义 > 默认"""
+ path_str = os.environ.get("RUNTIME_SETTINGS_PATH", "") or getattr(settings, "runtime_settings_path", "") or DEFAULT_RUNTIME_SETTINGS_PATH
+ return Path(path_str).expanduser().resolve()
+
+
+def _default_with_env_fallback() -> RuntimeSettings:
+ """构造默认 RuntimeSettings,并把启动 env 中已有的模型相关字段填充进去
+
+ 这样首次启动(无持久化文件)时,UI 显示的不是空字符串而是 env 当前值。
+ """
+ cfg = RuntimeSettings()
+ # 模型默认值沿用 env
+ cfg.models.summarize.base_url = settings.ollama_base_url
+ cfg.models.summarize.model = settings.ollama_model
+ cfg.models.query.base_url = settings.ollama_base_url
+ cfg.models.query.model = settings.ollama_model
+ cfg.models.classify.base_url = settings.ollama_base_url
+ cfg.models.classify.model = settings.ollama_model
+ # 若 env 提供了 OpenAI key/url,预填到 openai_compatible 字段方便切换
+ if settings.openai_api_key:
+ for usage in ("summarize", "query", "classify"):
+ getattr(cfg.models, usage).api_key = settings.openai_api_key
+ getattr(cfg.models, usage).base_url = settings.openai_base_url if settings.openai_base_url else getattr(cfg.models, usage).base_url
+ return cfg
+
+
+def load_runtime_settings() -> RuntimeSettings:
+ """从磁盘加载 RuntimeSettings;文件缺失或损坏时回退到默认值(带 env 兜底)"""
+ path = _resolve_path()
+ try:
+ if path.exists():
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return RuntimeSettings.model_validate(data)
+ except Exception:
+ logger.warning("RuntimeSettings 加载失败,回退默认值", path=str(path), exc_info=True)
+ return _default_with_env_fallback()
+
+
+def save_runtime_settings(cfg: RuntimeSettings) -> None:
+ """原子写入 RuntimeSettings 到磁盘(先临时文件后 rename)"""
+ path = _resolve_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ data = cfg.model_dump(mode="json")
+ # 写到同目录临时文件再 rename,避免半写损坏
+ fd, tmp_path = tempfile.mkstemp(prefix=".runtime_settings.", suffix=".tmp", dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ os.replace(tmp_path, path)
+ except Exception:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+
+
+def get_runtime_settings() -> RuntimeSettings:
+ """获取 RuntimeSettings 单例(首次调用时加载)"""
+ global _runtime_settings
+ with _lock:
+ if _runtime_settings is None:
+ _runtime_settings = load_runtime_settings()
+ return _runtime_settings
+
+
+def update_runtime_settings(patch: dict[str, Any]) -> RuntimeSettings:
+ """以 patch 字典更新 RuntimeSettings 并持久化
+
+ 支持部分更新(顶层键可缺失,缺省保留原值)。例如:
+ update_runtime_settings({"models": {"summarize": {"model": "qwen2.5:3b"}}})
+ """
+ with _lock:
+ current = get_runtime_settings()
+ merged = current.model_dump(mode="json")
+ _deep_merge(merged, patch)
+ new_cfg = RuntimeSettings.model_validate(merged)
+ save_runtime_settings(new_cfg)
+ # 替换单例后立即返回,新值对所有后续读取生效
+ global _runtime_settings
+ _runtime_settings = new_cfg
+ logger.info("RuntimeSettings 已更新并持久化", path=str(_resolve_path()))
+ return new_cfg
+
+
+def _deep_merge(target: dict[str, Any], patch: dict[str, Any]) -> None:
+ """递归把 patch 合并到 target(同 key 字典则递归,否则覆盖)"""
+ for k, v in patch.items():
+ if k in target and isinstance(target[k], dict) and isinstance(v, dict):
+ _deep_merge(target[k], v)
+ else:
+ target[k] = v
+
+
+def reload_runtime_settings() -> RuntimeSettings:
+ """强制从磁盘重新加载(管理后台触发)"""
+ with _lock:
+ global _runtime_settings
+ _runtime_settings = load_runtime_settings()
+ return _runtime_settings
+
+
+def reset_runtime_settings() -> RuntimeSettings:
+ """重置为默认值并持久化(管理后台触发)"""
+ with _lock:
+ new_cfg = _default_with_env_fallback()
+ save_runtime_settings(new_cfg)
+ global _runtime_settings
+ _runtime_settings = new_cfg
+ return new_cfg
diff --git a/app/core/summarizer.py b/app/core/summarizer.py
index 909f65a..5752300 100644
--- a/app/core/summarizer.py
+++ b/app/core/summarizer.py
@@ -1,6 +1,7 @@
"""文档三级总结模块
-通过 Ollama 本地小模型对文档进行分级总结:
+通过 LLM(Ollama 或 OpenAI 兼容服务,由 runtime_settings.models.summarize 决定)
+对文档进行分级总结:
- L1: 总结(一句话高度概括)
- L2: 大纲(主要章节和关键主题)
- L3: 内容大纲(每个章节的详细内容摘要)
@@ -12,7 +13,7 @@ import structlog
from app.core.headings import parse_headings, render_outline
from app.models.document import DocumentSummary, SummaryLevel
-from app.services.ollama import OllamaClient
+from app.services.llm import LLMClient, create_llm_client
logger = structlog.get_logger()
@@ -23,8 +24,10 @@ MIN_TEXT_LENGTH_FOR_L3 = 500
class Summarizer:
"""文档三级总结器"""
- def __init__(self, ollama: OllamaClient | None = None) -> None:
- self.ollama = ollama or OllamaClient()
+ def __init__(self, ollama: LLMClient | None = None) -> None:
+ # 默认按 runtime_settings 选择 LLM 实现(Ollama 或 OpenAI 兼容);
+ # 测试可通过 ollama 参数注入替身。
+ self.ollama = ollama or create_llm_client("summarize")
async def summarize(self, text: str, *, title: str = "") -> DocumentSummary:
"""对文档文本进行三级总结
diff --git a/app/main.py b/app/main.py
index 998a835..5361416 100644
--- a/app/main.py
+++ b/app/main.py
@@ -5,13 +5,14 @@ from pathlib import Path
import structlog
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from app.api.response import ApiError, error
from app.api.v1.auth import router as auth_router
from app.api.v1.document import router as document_router
from app.api.v1.knowledge import router as knowledge_router
from app.api.v1.search import router as search_router
+from app.api.v1.settings import router as settings_router
from app.config import settings
from app.core.auth import ensure_default_admin
from app.services.qdrant import QdrantService
@@ -53,6 +54,7 @@ app.include_router(auth_router)
app.include_router(search_router)
app.include_router(document_router)
app.include_router(knowledge_router)
+app.include_router(settings_router)
@app.exception_handler(ApiError)
@@ -84,10 +86,54 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
-_ADMIN_HTML = Path(__file__).resolve().parent / "static" / "admin.html"
+_STATIC_DIR = Path(__file__).resolve().parent / "static"
+_ADMIN_HTML = _STATIC_DIR / "admin.html"
+_ADMIN_SPA_DIR = _STATIC_DIR / "admin"
+
+
+def _spa_index() -> Path | None:
+ """SPA 入口文件路径;不存在返回 None(回退旧 admin.html)"""
+ index = _ADMIN_SPA_DIR / "index.html"
+ return index if index.is_file() else None
@app.get("/admin", include_in_schema=False)
-async def admin_page() -> FileResponse:
- """管理后台单页(单文件静态 HTML,零外部依赖)"""
+async def admin_page():
+ """管理后台:优先服务 Vue SPA,回退到旧单文件 admin.html"""
+ spa = _spa_index()
+ if spa is not None:
+ return FileResponse(spa, media_type="text/html")
return FileResponse(_ADMIN_HTML, media_type="text/html")
+
+
+@app.get("/admin/", include_in_schema=False)
+async def admin_page_trailing_slash():
+ """带斜杠的 /admin/ 重定向到 /admin"""
+ return RedirectResponse(url="/admin", status_code=307)
+
+
+@app.get("/admin/{rest:path}", include_in_schema=False)
+async def admin_spa(rest: str):
+ """SPA 静态资源与客户端路由兜底
+
+ - 真实文件(assets/*.js, *.css 等)→ 直接返回
+ - 其余路径(/overview, /documents 等客户端路由)→ 返回 index.html
+ - SPA 目录不存在时 → 404(旧 admin.html 无子路由需求)
+ """
+ spa_dir = _ADMIN_SPA_DIR
+ if not spa_dir.is_dir():
+ return JSONResponse(
+ status_code=404, content={"code": 1002, "message": "Not found"}
+ )
+
+ # 安全校验:防止路径越界
+ file_path = (spa_dir / rest).resolve()
+ try:
+ file_path.relative_to(spa_dir.resolve())
+ except ValueError:
+ return FileResponse(spa_dir / "index.html", media_type="text/html")
+
+ if file_path.is_file():
+ return FileResponse(file_path)
+ # SPA 客户端路由兜底
+ return FileResponse(spa_dir / "index.html", media_type="text/html")
diff --git a/app/services/llm.py b/app/services/llm.py
new file mode 100644
index 0000000..1d661f1
--- /dev/null
+++ b/app/services/llm.py
@@ -0,0 +1,179 @@
+"""LLM 客户端抽象层
+
+提供统一的 `generate(prompt, json_mode)` 接口,底层实现可切换:
+- OllamaLLMClient:走 Ollama HTTP /api/generate(本地或远程 Ollama 服务)
+- OpenAICompatibleLLMClient:走 OpenAI 兼容 /v1/chat/completions(OpenAI / DeepSeek / 智谱 / Qwen API 等)
+
+工厂 `create_llm_client(purpose)` 根据 runtime_settings 选择实现与参数。
+所有客户端共享同一接口,业务侧无需感知底层协议差异。
+"""
+
+from __future__ import annotations
+
+from typing import Literal, Protocol, runtime_checkable
+
+import httpx
+import structlog
+
+from app.config import settings
+from app.core.runtime_settings import LlmProviderConfig, get_runtime_settings
+from app.services.ollama import OllamaClient
+
+logger = structlog.get_logger()
+
+LlmPurpose = Literal["summarize", "query", "classify"]
+
+
+@runtime_checkable
+class LLMClient(Protocol):
+ """LLM 客户端统一接口"""
+
+ async def generate(self, prompt: str, json_mode: bool = False) -> str:
+ """生成文本
+
+ Args:
+ prompt: 输入提示词
+ json_mode: True 时约束输出为 JSON(不支持时降级为普通生成)
+
+ Returns:
+ 生成的文本内容
+ """
+ ...
+
+ async def is_available(self) -> bool:
+ """检查服务是否可用"""
+ ...
+
+
+class OllamaLLMClient:
+ """Ollama HTTP API 客户端(包装现有 OllamaClient,便于统一接口)"""
+
+ def __init__(self, base_url: str, model: str, timeout: float = 120.0) -> None:
+ self._inner = OllamaClient(base_url=base_url, model=model, timeout=timeout)
+
+ async def generate(self, prompt: str, json_mode: bool = False) -> str:
+ return await self._inner.generate(prompt, json_mode=json_mode)
+
+ async def is_available(self) -> bool:
+ return await self._inner.is_available()
+
+
+class OpenAICompatibleLLMClient:
+ """OpenAI 兼容 chat/completions 客户端
+
+ 适用于 OpenAI 官方 API、DeepSeek、智谱 ChatGLM、Qwen DashScope、Moonshot 等
+ 所有兼容 OpenAI /v1/chat/completions 协议的服务。
+ """
+
+ def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 120.0, temperature: float = 0.3) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.api_key = api_key
+ self.model = model
+ self.timeout = timeout
+ self.temperature = temperature
+
+ async def generate(self, prompt: str, json_mode: bool = False) -> str:
+ url = f"{self.base_url}/chat/completions"
+ payload: dict = {
+ "model": self.model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": self.temperature,
+ "stream": False,
+ }
+ if json_mode:
+ payload["response_format"] = {"type": "json_object"}
+
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ resp = await client.post(url, json=payload, headers=headers)
+ resp.raise_for_status()
+ data = resp.json()
+
+ # OpenAI 标准响应结构:choices[0].message.content
+ choices = data.get("choices") or []
+ if not choices:
+ logger.warning("OpenAI 兼容响应无 choices", model=self.model, raw_keys=list(data.keys()))
+ return ""
+ message = choices[0].get("message") or {}
+ content = message.get("content") or ""
+ logger.debug("OpenAI 兼容生成完成", model=self.model, output_length=len(content))
+ return content
+
+ async def is_available(self) -> bool:
+ """简单探测:调 /models 列表接口(多数 OpenAI 兼容服务支持)"""
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ resp = await client.get(
+ f"{self.base_url}/models",
+ headers={"Authorization": f"Bearer {self.api_key}"},
+ )
+ return resp.status_code == 200
+ except httpx.HTTPError:
+ return False
+
+
+# ---------------------------------------------------------------------------- #
+# 工厂
+# ---------------------------------------------------------------------------- #
+
+# 进程级客户端缓存(避免每请求新建 httpx client)
+_client_cache: dict[str, LLMClient] = {}
+
+
+def _resolve_provider_config(purpose: LlmPurpose) -> LlmProviderConfig:
+ """从 runtime_settings 取指定用途的 LLM 配置"""
+ rt = get_runtime_settings()
+ return getattr(rt.models, purpose)
+
+
+def _build_client(purpose: LlmPurpose) -> LLMClient:
+ """按 runtime_settings 构造 LLM 客户端"""
+ cfg = _resolve_provider_config(purpose)
+
+ if cfg.provider == "ollama":
+ base_url = cfg.base_url or settings.ollama_base_url
+ model = cfg.model or settings.ollama_model
+ return OllamaLLMClient(base_url=base_url, model=model, timeout=cfg.timeout)
+
+ if cfg.provider == "openai_compatible":
+ base_url = cfg.base_url or settings.openai_base_url
+ api_key = cfg.api_key or settings.openai_api_key
+ # openai_compatible 默认模型:若 cfg.model 为空,退化到 OpenAI 通用 chat 模型
+ model = cfg.model or "gpt-4o-mini"
+ if not api_key:
+ logger.warning("OpenAI 兼容 provider 缺少 api_key,调用大概率会失败", purpose=purpose)
+ return OpenAICompatibleLLMClient(
+ base_url=base_url, api_key=api_key, model=model, timeout=cfg.timeout, temperature=cfg.temperature
+ )
+
+ raise ValueError(f"未知 LLM provider: {cfg.provider}")
+
+
+def create_llm_client(purpose: LlmPurpose, *, use_cache: bool = True) -> LLMClient:
+ """创建指定用途的 LLM 客户端
+
+ Args:
+ purpose: 用途(summarize / query / classify)
+ use_cache: True 时复用进程级客户端单例(默认);False 每次新建(测试用)
+
+ Returns:
+ LLMClient 实例
+ """
+ if not use_cache:
+ return _build_client(purpose)
+
+ cache_key = f"{purpose}"
+ if cache_key not in _client_cache:
+ _client_cache[cache_key] = _build_client(purpose)
+ return _client_cache[cache_key]
+
+
+def invalidate_llm_client_cache(purpose: LlmPurpose | None = None) -> None:
+ """清除客户端缓存(runtime_settings 更新后调用,确保后续读取新配置)"""
+ if purpose is None:
+ _client_cache.clear()
+ else:
+ _client_cache.pop(purpose, None)
diff --git a/docker-compose.yml b/docker-compose.yml
index d4b579f..2eeda38 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,8 @@ services:
- QDRANT_PORT=6333
- REDIS_URL=redis://redis:6379/0
- OLLAMA_BASE_URL=http://ollama:11434
+ # 针对 16 线程 / 61GB 内存的 NAS 调优:放宽入库并发
+ - INGEST_MAX_CONCURRENCY=${INGEST_MAX_CONCURRENCY:-4}
env_file:
- .env
depends_on:
@@ -21,6 +23,7 @@ services:
condition: service_started
volumes:
- ${NAS_DATA_DIR:-./data}/logs:/app/logs
+ - ${NAS_DATA_DIR:-./data}/uploads:/app/uploads
networks:
- qmdsearch
@@ -65,11 +68,25 @@ services:
- "${OLLAMA_PORT:-11434}:11434"
volumes:
- ${NAS_DATA_DIR:-./data}/ollama:/root/.ollama
- # 首次启动后需手动拉取模型:
- # docker exec qmdsearch-ollama ollama pull qwen2.5:1.5b
- # 或取消下方 entrypoint 注释以自动拉取(需等待下载完成)
- # entrypoint: /bin/bash
- # command: -c "ollama serve & sleep 5 && ollama pull qwen2.5:1.5b && wait"
+ environment:
+ # 针对 Ryzen 9 7940HS(16 线程)的 CPU 推理调优:
+ # 并行推理任务数、常驻模型数、单请求线程上限、KV 缓存量化以省内存
+ - OLLAMA_NUM_PARALLEL=4
+ - OLLAMA_MAX_LOADED_MODELS=2
+ - OLLAMA_NUM_THREADS=16
+ - OLLAMA_KV_CACHE_TYPE=q8_0
+ # 首次启动自动拉取所需模型(qwen2.5:1.5b 总结 + bge-m3 嵌入),
+ # 下载完成后转交常驻 ollama serve。已存在时仅做健康检查。
+ entrypoint: /bin/bash
+ command:
+ - -c
+ - |
+ ollama serve &
+ SERVE_PID=$$!
+ sleep 6
+ ollama pull qwen2.5:1.5b
+ ollama pull bge-m3
+ wait $$SERVE_PID
networks:
- qmdsearch
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a1e6ec0
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+dist-ssr
+*.local
+.DS_Store
+.vite
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..baa86c3
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ QMDSearch 知识库管理后台
+
+
+
+
+
+
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
new file mode 100644
index 0000000..18b4628
--- /dev/null
+++ b/frontend/jsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "preserve",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "checkJs": false
+ },
+ "include": ["src/**/*.js", "src/**/*.vue"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..01b0de2
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1871 @@
+{
+ "name": "qmdsearch-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "qmdsearch-frontend",
+ "version": "1.0.0",
+ "dependencies": {
+ "@ant-design/icons-vue": "^7.0.1",
+ "ant-design-vue": "^4.2.6",
+ "axios": "^1.7.9",
+ "dayjs": "^1.11.13",
+ "pinia": "^2.3.0",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "vite": "^5.4.11"
+ }
+ },
+ "node_modules/@ant-design/colors": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
+ "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@ctrl/tinycolor": "^3.4.0"
+ }
+ },
+ "node_modules/@ant-design/icons-svg": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
+ "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==",
+ "license": "MIT"
+ },
+ "node_modules/@ant-design/icons-vue": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
+ "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/colors": "^6.0.0",
+ "@ant-design/icons-svg": "^4.2.1"
+ },
+ "peerDependencies": {
+ "vue": ">=3.0.3"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@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": "3.6.1",
+ "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+ "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.9.2",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+ "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/@simonwep/pickr": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
+ "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-js": "^3.15.1",
+ "nanopop": "^2.1.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@vue/shared/-/shared-3.5.40.tgz",
+ "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
+ "license": "MIT"
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/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/ant-design-vue": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
+ "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/colors": "^6.0.0",
+ "@ant-design/icons-vue": "^7.0.0",
+ "@babel/runtime": "^7.10.5",
+ "@ctrl/tinycolor": "^3.5.0",
+ "@emotion/hash": "^0.9.0",
+ "@emotion/unitless": "^0.8.0",
+ "@simonwep/pickr": "~1.8.0",
+ "array-tree-filter": "^2.1.0",
+ "async-validator": "^4.0.0",
+ "csstype": "^3.1.1",
+ "dayjs": "^1.10.5",
+ "dom-align": "^1.12.1",
+ "dom-scroll-into-view": "^2.0.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.15",
+ "resize-observer-polyfill": "^1.5.1",
+ "scroll-into-view-if-needed": "^2.2.25",
+ "shallow-equal": "^1.0.0",
+ "stylis": "^4.1.3",
+ "throttle-debounce": "^5.0.0",
+ "vue-types": "^3.0.0",
+ "warning": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ant-design-vue"
+ },
+ "peerDependencies": {
+ "vue": ">=3.2.0"
+ }
+ },
+ "node_modules/array-tree-filter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+ "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
+ "license": "MIT"
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
+ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.6",
+ "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.npmjs.org/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.npmjs.org/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/compute-scroll-into-view": {
+ "version": "1.0.20",
+ "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+ "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+ "license": "MIT"
+ },
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dom-align": {
+ "version": "1.12.4",
+ "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+ "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
+ "license": "MIT"
+ },
+ "node_modules/dom-scroll-into-view": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+ "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
+ "license": "MIT"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/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/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/is-plain-object": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
+ "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/nanopop": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
+ "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/pinia": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
+ "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "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.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/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/scroll-into-view-if-needed": {
+ "version": "2.2.31",
+ "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+ "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+ "license": "MIT",
+ "dependencies": {
+ "compute-scroll-into-view": "^1.0.20"
+ }
+ },
+ "node_modules/shallow-equal": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+ "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/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/stylis": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
+ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
+ "license": "MIT"
+ },
+ "node_modules/throttle-debounce": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+ "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.22"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
+ "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-object": "3.0.1"
+ },
+ "engines": {
+ "node": ">=10.15.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..a8d2b06
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "qmdsearch-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@ant-design/icons-vue": "^7.0.1",
+ "ant-design-vue": "^4.2.6",
+ "axios": "^1.7.9",
+ "dayjs": "^1.11.13",
+ "pinia": "^2.3.0",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "vite": "^5.4.11"
+ }
+}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..117818c
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
new file mode 100644
index 0000000..c5c540a
--- /dev/null
+++ b/frontend/src/api/auth.js
@@ -0,0 +1,19 @@
+import http from './client'
+
+/**
+ * 用户名密码登录
+ * @param {string} username
+ * @param {string} password
+ * @returns {Promise<{access_token:string, expires_in:number, user:{username:string, role:string, created_at:string}}>}
+ */
+export function login(username, password) {
+ return http.post('/api/v1/auth/login', { username, password })
+}
+
+/**
+ * 获取当前登录用户信息
+ * @returns {Promise<{username:string, role:string, created_at:string}>}
+ */
+export function me() {
+ return http.get('/api/v1/auth/me')
+}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..c1d6462
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,108 @@
+import axios from 'axios'
+import { message } from 'ant-design-vue'
+
+const TOKEN_STORAGE_KEY = 'qmd_token'
+
+/** 认证相关错误码:触发清 token + 跳登录 */
+const AUTH_ERROR_CODES = new Set([1003, 1005])
+
+const httpClient = axios.create({
+ // 不设 baseURL,使用相对路径,由 vite proxy / nginx 转发
+ timeout: 60000,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+})
+
+// 请求拦截器:注入 Bearer token
+httpClient.interceptors.request.use((config) => {
+ const token = localStorage.getItem(TOKEN_STORAGE_KEY) || ''
+ if (token) {
+ config.headers = config.headers || {}
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+})
+
+let unauthorizedHandler = null
+
+/**
+ * 注册 401 / 认证错误处理回调(由 router/store 注入,避免循环依赖)
+ * @param {() => void} handler
+ */
+export function setUnauthorizedHandler(handler) {
+ unauthorizedHandler = handler
+}
+
+function triggerUnauthorized() {
+ localStorage.removeItem(TOKEN_STORAGE_KEY)
+ if (typeof unauthorizedHandler === 'function') {
+ unauthorizedHandler()
+ }
+}
+
+// 响应拦截器:统一处理 code !== 0 与 401
+httpClient.interceptors.response.use(
+ (response) => {
+ const body = response.data
+ if (body && typeof body === 'object' && 'code' in body) {
+ if (body.code === 0) {
+ return body.data
+ }
+ // 业务错误
+ if (AUTH_ERROR_CODES.has(body.code)) {
+ triggerUnauthorized()
+ }
+ const err = new Error(body.message || '请求失败')
+ err.code = body.code
+ err.message = body.message || '请求失败'
+ return Promise.reject(err)
+ }
+ // 非标准结构,原样返回
+ return body
+ },
+ (error) => {
+ const status = error?.response?.status
+ if (status === 401) {
+ triggerUnauthorized()
+ const err = new Error('未认证或登录已过期,请重新登录')
+ err.code = 1003
+ return Promise.reject(err)
+ }
+ // 后端返回了 body 但 HTTP 错误
+ const body = error?.response?.data
+ if (body && typeof body === 'object' && 'code' in body) {
+ if (AUTH_ERROR_CODES.has(body.code)) {
+ triggerUnauthorized()
+ }
+ const err = new Error(body.message || `请求失败 (HTTP ${status ?? '?'})`)
+ err.code = body.code
+ return Promise.reject(err)
+ }
+ const err = new Error(error?.message || '网络请求失败')
+ err.code = `HTTP_${status ?? 'NETWORK'}`
+ return Promise.reject(err)
+ }
+)
+
+/**
+ * 统一发起请求,捕获异常并弹出 antd message
+ * @param {() => Promise} fn
+ * @param {{ silent?: boolean, errorText?: string }} [options]
+ * @returns {Promise}
+ */
+export async function callApi(fn, options = {}) {
+ const { silent = false, errorText = '操作失败' } = options
+ try {
+ return await fn()
+ } catch (err) {
+ const text = err?.message || errorText
+ if (!silent) {
+ message.error(text)
+ }
+ throw err
+ }
+}
+
+export { TOKEN_STORAGE_KEY }
+export default httpClient
diff --git a/frontend/src/api/documents.js b/frontend/src/api/documents.js
new file mode 100644
index 0000000..799b914
--- /dev/null
+++ b/frontend/src/api/documents.js
@@ -0,0 +1,62 @@
+import http from './client'
+
+/**
+ * 分页列出文档
+ * @param {number} [limit=20]
+ * @param {string|null} [offset=null]
+ * @returns {Promise<{items: Array, next_offset: string|null}>}
+ */
+export function list(limit = 20, offset = null) {
+ const params = { limit }
+ if (offset !== null && offset !== undefined && offset !== '') {
+ params.offset = offset
+ }
+ return http.get('/api/v1/documents', { params })
+}
+
+/**
+ * 获取文档详情
+ * @param {string} docId
+ * @returns {Promise