diff --git a/.claude/launch.json b/.claude/launch.json index 65dcc86..9b1f5d7 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -14,6 +14,13 @@ "runtimeArgs": ["run", "dev", "--", "--port", "5174"], "cwd": "sundynix-admin", "port": 5174 + }, + { + "name": "web-face", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5175"], + "cwd": "sundynix-web", + "port": 5175 } ] } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3531c27 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +**/node_modules +**/.venv +**/dist +**/build +sundynix-desktop/build +backups +*.log +/tmp +**/*_test.go +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd01177 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# sundynix-agentix 部署配置模板 —— 复制为 .env 后填写(docker compose -f docker-compose.prod.yml 读取) +# +# cp .env.example .env +# # 按下方注释生成/填写各项,然后: +# docker compose -f docker-compose.prod.yml up -d --build +# +# 说明:这里只放「引导密钥/基建凭据」。LLM 模型 API key 不在这里 —— 部署起来后登录控制台配置、 +# 由平台 AES 加密存库(加密用的就是下面的 SUNDYNIX_SECRET_KEY)。 + +# ── 必填:引导密钥(生成命令见各行) ───────────────────────────── +# 主加密密钥:用于加密控制台里配置的模型 API key。三服务共用同一个,丢了=已存的密钥都解不开。 +# 生成:openssl rand -base64 32 +SUNDYNIX_SECRET_KEY= + +# 登录令牌签名密钥(JWT)。 +# 生成:openssl rand -base64 32 +JWT_SECRET= + +# 数据库密码(PostgreSQL,平台主库)。 +# 生成:openssl rand -base64 24 (勿用特殊字符 @ : / 以免污染 DSN) +POSTGRES_PASSWORD= + +# 图数据库密码(Neo4j,知识图谱)。 +NEO4J_PASSWORD= + +# ── 必填:管理员 ───────────────────────────────────────────── +# 管理员用户 ID 白名单(逗号分隔)。生产模式下只有这些用户能进 /admin 控制台。 +# 首次部署:先留空起服务 → 注册第一个账号 → 从返回的 user id 填进来 → 重启 gateway。 +ADMIN_USER_IDS= + +# ── 选填:网络/端口 ───────────────────────────────────────── +# 控制台对外端口(默认 80)。 +HTTP_PORT=80 +# 网关对外端口(供桌面端直连;纯 Web 用可不暴露)。 +GATEWAY_PORT=8080 +# 允许的前端来源(生产建议设具体域名,如 https://app.example.com;缺省 * 仅供内网/演示)。 +CORS_ALLOW_ORIGIN=* +# Jaeger 链路追踪 UI 端口。 +JAEGER_UI_PORT=16686 + +# ── 选填:调优 ─────────────────────────────────────────────── +# 单调度副本并发任务数。 +DISPATCHER_CONCURRENCY=8 +# 单任务 token 预算上限(防失控成本)。 +TASK_TOKEN_BUDGET=200000 +# 镜像标签。 +TAG=latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f354b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +# 只认 main:push 到 main、以及任何以 main 为目标的 PR。 +# 开发分支(feat/*)自己 push 不跑 CI,靠合入 main 前的 PR 把关。 +on: + push: + branches: [main] + pull_request: + branches: [main] + +# 同一分支新 push 取消上一次未完成的运行,省 CI 时间。 +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + name: Go · build + vet + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: "**/go.sum" + - name: build + vet + test(4 模块;bus 用内嵌 NATS,无需外部服务) + run: | + set -e + for m in sundynix-shared sundynix-gateway sundynix-dispatcher sundynix-mcp-go; do + echo "::group::$m" + (cd "$m" && go build ./... && go vet ./... && go test ./...) + echo "::endgroup::" + done + + web: + name: Frontend · tsc + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dir: [sundynix-desktop/frontend, sundynix-admin, sundynix-web] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ${{ matrix.dir }}/package-lock.json + - name: install + typecheck + working-directory: ${{ matrix.dir }} + run: | + npm ci + npx tsc --noEmit + + py: + name: mcp-py · sandbox guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: install + test(无 Docker 时测降级路径) + working-directory: sundynix-mcp-py + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + PYTHONPATH=src:tests python -m pytest tests/ -q || \ + PYTHONPATH=src:tests python -c "import test_sandbox as t; \ + fns=[getattr(t,n) for n in dir(t) if n.startswith('test_')]; \ + [ (f(),print('PASS',f.__name__)) for f in fns ]; print(f'{len(fns)} passed')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4a8d9ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +# 打 vX.Y.Z 标签即触发:各平台用 wails3 构建桌面端 → 发布到 GitHub Release。 +# 用户旧版 App 启动时查 /releases/latest,发现新版即提示下载(见 frontend/src/lib/version.ts)。 +on: + push: + tags: ["v*"] + +permissions: + contents: write # 创建 release + 上传产物需要 + +env: + # 必须与 sundynix-desktop/go.mod 里的 wails/v3 版本一致:alpha 阶段 CLI 生成的 + # bindings 与运行时是配套的,版本错开会出难查的怪问题。升级时两处一起改。 + WAILS3_VERSION: v3.0.0-alpha2.117 + +jobs: + build: + name: 构建 ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + label: macOS (universal) + asset: sundynix_desktop_macos_universal.zip + - os: windows-latest + label: Windows (amd64) + asset: sundynix_desktop_windows_amd64.exe + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: 安装 wails3 CLI + run: go install github.com/wailsapp/wails/v3/cmd/wails3@${{ env.WAILS3_VERSION }} + + # v3 的 build 只出裸二进制(macOS 上双击不起窗),必须 package 才有 .app; + # universal 走 darwin:package:universal:分别编 amd64/arm64 再 lipo 合并。 + # 任务链自带 npm install + generate:bindings,故不需要单独的前端构建步骤。 + - name: 构建 macOS .app(universal) + if: runner.os == 'macOS' + working-directory: sundynix-desktop + env: + GOWORK: "off" # 必须加引号:YAML 裸 off 会被解析成布尔 false,使 workflow 校验不合法而不触发 + run: wails3 task darwin:package:universal + + # Windows 沿用 v2 时代的行为:发裸 .exe。v3 的 package 默认走 NSIS 出 installer, + # 需 runner 上有 makensis,属于额外未知数,要升级安装包再单独议。 + - name: 构建 Windows .exe + if: runner.os == 'Windows' + working-directory: sundynix-desktop + env: + GOWORK: "off" + run: wails3 build + + # macOS:.app 是目录,打包成 zip 才能作为单文件资产分发。 + - name: 打包 macOS 产物 + if: runner.os == 'macOS' + working-directory: sundynix-desktop/bin + run: zip -r -y ${{ matrix.asset }} sundynix_desktop.app + + - name: 重命名 Windows 产物 + if: runner.os == 'Windows' + working-directory: sundynix-desktop/bin + run: Rename-Item sundynix_desktop.exe ${{ matrix.asset }} + + # fail_on_unmatched_files 必须为 true:产物路径写错时要当场炸,而不是安静地 + # 发一个没有附件的 Release。v2→v3 迁移正是把产物从 build/bin 挪到了 bin。 + - name: 发布到 GitHub Release(按 tag 创建/追加资产) + uses: softprops/action-gh-release@v2 + with: + fail_on_unmatched_files: true + files: sundynix-desktop/bin/${{ matrix.asset }} diff --git a/.gitignore b/.gitignore index 87b8c74..efc5da4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,15 +11,18 @@ __pycache__/ *.pyc .venv/ -# Wails(构建产物 + 生成的绑定/缓存,每次 wails build 自动重建) +# Wails v3(只忽略产物/缓存;build/ 的 Taskfile+配置是项目构建系统,需提交) build/bin/ frontend/dist/ -sundynix-desktop/build/ -sundynix-desktop/frontend/wailsjs/ +# v3 的构建产物落在 bin/,已被第 3 行的 bin/ 通配覆盖,无需单列。 +sundynix-desktop/build/bin/ +sundynix-desktop/.task/ +.task/ sundynix-desktop/frontend/package.json.md5 # Data data/ +.data/ # 演示产物 .bin/ @@ -28,3 +31,9 @@ data/ # Claude Code 本地机器配置(launch.json 可共享,settings.local 不提交) .claude/settings.local.json + +# 部署密钥(绝不入库) +.env +.env.* +!.env.example +backups/ diff --git a/ARCHITECTURE_DESIGN.md b/ARCHITECTURE_DESIGN.md new file mode 100644 index 0000000..73d32d7 --- /dev/null +++ b/ARCHITECTURE_DESIGN.md @@ -0,0 +1,239 @@ +# sundynix-agentix · 架构设计文档(当前完整版) + +> 版本:2026-07-02(反映 T0–T2 全量 + T4.B/E 完成 + T4.F 部分后的现状) +> 定位:**事件驱动的 AI Agent 平台**。以 NATS 为统一总线,服务经契约解耦,Eino 为编排核心,控制面热切换。 +> 配套文档:`ARCHITECTURE_REVIEW.md`(架构评审:优缺点/技术债)、`DEPTH_ROADMAP.md`(路线图)、`EINO_ADOPTION.md`(Eino 采纳)。 + +--- + +## 1. 系统概述 + +sundynix-agentix 把「用户在画布上编排 Agent 图 → 平台调度执行 → 实时回流结果」做成一套可运维、可观测、可扩展的平台。核心特征: + +- **图编排执行**:前端画布导出 DSL(节点+连线),后端编译为可执行图,支持分支、并行、多智能体、人工审批。 +- **工具即服务**:能力(RAG/记忆/历史/报告/沙箱)以独立 MCP 服务经 NATS 暴露,加工具零改调度代码。 +- **分层韧性**:模型 failover + 熔断 + 预算 + 输入护栏 + 输出脱敏 + 自动评测纠偏(「恒温器」治理层)。 +- **控制面热切换**:模型配置、提示词经 NATS 广播热下发,不重启即生效。 +- **流式原生**:token 流与执行轨迹经 NATS→Redis Stream→SSE 实时回流,支持断点重放。 + +--- + +## 2. 分层架构总览 + +``` +┌── 客户端层 ───────────────────────────────────────────────┐ +│ 桌面端 Wails(:5173,用户工作产品) 管理端 React(:5174,运维控制台) │ +└──────────────┬───────────────────────────┬────────────────┘ + │ HTTP / SSE │ HTTP +┌── 接入层 ─────┴───────────────────────────┴────────────────┐ +│ gateway (gin, :8080) │ +│ JWT鉴权 · 路由 · 输入护栏 · 限流(按用户) · 审计 · SSE回流 · 导出 │ +└──────────────────────────┬─────────────────────────────────┘ + │ +┌── 总线层 ─────────────────┴─────────────────────────────────┐ +│ NATS + JetStream ——「万物总线」 │ +│ 任务 · 工具RPC · token流 · 执行轨迹 · 控制面广播 · 心跳 · 持久队列 │ +└──┬──────────────────────┬──────────────────────┬────────────┘ + │ │ │ +┌──┴────────┐ ┌────────┴────────┐ ┌────────┴────────┐ +│ dispatcher │ │ mcp-go │ │ mcp-py │ +│ 编排核心 │ │ Go I/O 工具服务 │ │ Python 算法工具 │ +│ Eino+harness│ │ RAG/记忆/历史/报告│ │ 沙箱/解析 │ +└──┬────────┘ └────────┬────────┘ └─────────────────┘ + │ LLM providers │ + │ (OpenAI 兼容) │ +┌──┴────────────────────────┴─────────────────────────────────┐ +│ 数据层:PostgreSQL(关系) · Redis(缓存/流/限流) · Milvus(向量) │ +│ Neo4j(图谱) · MinIO(对象/正文) · Jaeger(OTel 追踪) │ +└──────────────────────────────────────────────────────────────┘ +``` + +**分层原则**:服务间**互不 import**,只依赖 `shared/contract`(数据契约)+ `shared/bus`(NATS 封装)。任何跨服务通信都过 NATS。 + +--- + +## 3. 组件详细设计 + +### 3.1 gateway(接入层,gin) +唯一对外 HTTP 入口,不含业务编排。职责: +- **鉴权**:JWT 无状态(`owner` = 雪花 user.id);`RequireAuth`(登录)/ `RequireAdmin`(管理面,白名单 `ADMIN_USER_IDS`)。 +- **中间件链**(顺序):Recovery → OTel → RequestID → Observe(指标+日志) → CORS → **Auth** → **RateLimit(按 uid,未认证按 IP)** → **Guardrail(输入护栏+命中落库)**。 +- **DSL 拓扑校验**:`ParseAndAssemble` 前置拦截重复/空节点 id、悬挂边(防坏图进编排后被 compose 静默跳过)。 +- **SSE 回流**:`/tasks/:id/stream`(token 流)、`/tasks/:id/exec`(执行轨迹)、`/kb/ingest/:id/stream`(入库进度);优先读 Redis Stream(可断点续传),降级 live NATS。 +- **报告导出**:`/reports/:id/export?format=docx|md` → 经 NATS 调 mcp-go 现渲染 → `c.File` 流回。 +- **控制面 API**(`/admin/*`,RequireAdmin):模型 CRUD、计价、系统状态探活、系统聚合 overview、审计流、护栏事件流。 +- **审计**:`middleware.Audit(db)` 对变更类请求(POST/PUT/DELETE)best-effort 落库。 + +### 3.2 dispatcher(编排核心) +消费 NATS 任务,用 Eino 执行编排图 + harness 治理。详见 §5、§6。 + +### 3.3 mcp-go(Go 工具服务) +MCP 协议端点,订阅 `sundynix.tools.go.>`(queue group `mcp-go-workers`)。工具注册表是**单一事实源**(每工具声明 `cn/desc/agent/params/inject/handler`): +- **RAG**:`kb_search`(三路混合) / `wiki_search` / `kb_graph` / `kb_ingest` / `kb_delete`(级联删)。三路 = Milvus 向量 + Bleve 全文(落盘) + Neo4j 图谱,RRF 融合 + rerank。 +- **记忆**:`memory_get/upsert/delete/list`(用户长期偏好,软删)。 +- **历史**:`history_get/append`(会话历史,Redis)。 +- **报告**:`report_store`(存源) / `report_render`(渲染 docx 落盘) / `report_export`(按需导出)。 +- **元工具**:`list_tools`(自省,供 dispatcher 动态发现) / `health`(Milvus/Neo4j 就绪)。 + +### 3.4 mcp-py(Python 算法工具) +订阅 `sundynix.tools.py.>`。承载 CPU 密集/生态依赖的算法:沙箱代码执行、文档解析等。**注:算法层目前为桩,调用链路已做真。** + +### 3.5 shared(跨服务共享库) +- **bus**:NATS/JetStream 封装(Publish/Subscribe/Request-Reply、控制面广播、心跳、持久队列)。 +- **contract**:Task/ToolCall/ToolResult/ModelConfig/报告路径 等跨服务契约。 +- **prompts**:提示词注册表(内置默认 + 运行期覆盖)。 +- **secrets**:API Key AES-256-GCM 端到端加密(`SUNDYNIX_SECRET_KEY` 三服务须一致)。 +- **otel**:OTel 初始化(NATS 跨总线传播 traceparent)。 + +--- + +## 4. NATS 总线设计(架构中枢) + +NATS 不只是消息队列,而是承载 **5 类通信**: + +| 类型 | 模式 | Subject / 机制 | 用途 | +|---|---|---|---| +| 任务提交 | Publish | `sundynix.tasks.*` | gateway → dispatcher | +| 工具调用 | Request-Reply | `sundynix.tools.go.` / `.py.` | dispatcher/gateway → mcp-*(queue group 负载均衡) | +| token 流 | Publish/Sub | `sundynix.streams.` | dispatcher → gateway → SSE | +| 执行轨迹 | Publish/Sub | exec 事件 | 运行·观测面板 | +| 控制面广播 | Publish | 模型配置 / 提示词激活集 | 热下发 dispatcher/mcp-go(不重启) | +| 心跳探活 | Request-Reply | `sundynix.health.dispatcher` 等 | 管理端服务状态 | +| 持久队列 | JetStream | 入库工作队列(durable consumer) | 崩溃重投 / 幂等 / 背压 / AckWait 续租 | + +**⚠️ 运维要点**:单点 NATS(集群为 T3);本地开发禁止同时开 `devnats` 与 docker NATS(会脑裂)。 + +--- + +## 5. 编排引擎设计(Eino compose) + +**Eino 只在 dispatcher 里用,是编排引擎地基。** + +### 5.1 DSL → compose 图 +前端画布导出 `{version, nodes[{id,kind,label,config}], edges[{source,target,sourceHandle}]}`,dispatcher 编译为 Eino `compose.Graph`(`compose_compiler.go`),支持拓扑 + 连线 + 分支剪枝 + DAG 并行调度。**自研 graph.go 解释器已退役,单 compose 引擎。** + +### 5.2 节点类型(执行器) +- `input`:用户输入 +- `retriever`:RAG 检索(Eino Retriever 包 mcp-go `kb_search`) +- `agent`:模型流式推理(拼消息 → token 流) +- `tool`:调 MCP 工具(编排直连 NATS) +- `branch`:条件路由(true/false 边标签 + **default/else 兜底** + 未匹配收口 END) +- `map`:并行 fan-out(有界并发撰写;**子项失败传播 + 全失败置 fatalErr**) +- `coordinator`:多智能体(专家=agent-as-tool,lead 分解→定制简报→并行派发→综合;**专家超时跳过**) +- `approval`:HITL 人工审批中断(暂停→waiting→批准回 running / 拒绝 rejected,checkpoint 落盘) + +### 5.3 ReAct 自主调用(`agent:true` 工具) +`agentTools()` 经 `list_tools` 动态发现 mcp 工具 → 包成 Eino `InvokableTool`(`mcpTool`)→ 模型 function-calling → ToolsNode 分发 → `InvokableRun` 转 NATS RPC。运行时注入 `user_id/session_id/kb`(不暴露给模型)。**加工具只改 mcp-go 注册表,dispatcher 零改动。** + +### 5.4 harness 治理层(「恒温器」) +- **评测**:综合分(规则+LLM质量+RAG忠实度)+ 分级(ok/warn/poor),低分**自动纠偏**重生成。 +- **输入护栏**:注入检测(网关 Tier1)+ LLM 分类器(dispatcher Tier2 灰区裁决)。 +- **输出脱敏**:有状态流式脱敏(跨分片缓冲,杜绝密钥被切断漏检)。 +- **预算**:单任务 token 计量 + 封顶(默认 `TASK_TOKEN_BUDGET`,触顶中止全图)。 +- **熔断**:后端连续失败→断开→冷却半开探测→恢复(编排层门控 + **model 层每模型熔断**)。 + +--- + +## 6. LLM 接入设计 + +- **接入策略**:开发期接第三方在线 API(OpenAI 兼容),不拉本地 Ollama(但支持 Ollama/vLLM 占位 key)。 +- **LLM Pool**(`llm/pool.go`):经 Eino ChatModel 组件;配置热更新、降级桩(未配置时)。 +- **Failover 链**(`llm/failover.go`):active=主 + 其余=按序备用,串成 `ToolCallingChatModel`,compose/ReAct/Chat 全路径透明。**每模型带熔断器**:主持续失败→熔断→跳过主直连备用;冷却半开探测自动恢复;`WithTools` 重包共享 breakers。 +- **输出缓存**(`llm/cache.go`):包在 failover 外层,缓存 Generate(非流式),键=模型+工具+消息哈希。 +- **控制面热切换**:admin 改模型 → gateway 写 DB → NATS 广播 → dispatcher `SetConfig` 热重建 pool。 + +--- + +## 7. 数据存储设计 + +| 存储 | 角色 | 关键内容 | +|---|---|---| +| **PostgreSQL** | 关系事实源 | 用户/任务/评测/模型/计价/提示词/知识库元/文档/双链/编排/审计/护栏事件(表名 `sundynix_` 前缀,AutoMigrate) | +| **Redis** | 缓存/流/限流 | token 用量计数、限流、会话历史、Redis Stream(token/exec 回放) | +| **Milvus** | 向量库 | 文档向量(语义检索),键 file_id | +| **Neo4j** | 图数据库 | 知识图谱三元组(实体 kb+name 共享,关系打 file_id) | +| **MinIO** | 对象存储 | 文档正文(大文件正文一律落 MinIO) | +| **Jaeger** | 追踪 | OTel span(节点/工具/LLM,NATS 跨总线传播) | + +**大文件入库生产化**:正文 MinIO + 切片 + 向量并发分批 + 图谱窗口化 + **JetStream 持久工作队列**(崩溃重投/幂等/背压),全 live 验证。 + +--- + +## 8. 关键数据流 + +**① 任务生命周期**:桌面端 POST DSL → gateway 解析+拓扑校验 → NATS `tasks.*` → dispatcher 编译 compose 图执行 → token 流 + 轨迹经 NATS→Redis Stream→SSE 回流 → FSM 状态机落库(submitted/running/done/failed/timeout/waiting/rejected)。 + +**② 工具调用(两条路)**: +- 编排直连:`o.tools.CallTool()` 直接 NATS→mcp-go(报告写文件走这条,**绕开 Eino**)。 +- Eino ReAct:模型 function-call → ToolsNode → `mcpTool.InvokableRun` → NATS→mcp-go。 +- 真正执行永远在 mcp-*;**Eino 把工具当黑盒(吃 JSON→吐字符串),不碰文件系统**。 + +**③ 控制面热切换**:admin 改配置 → gateway 写 DB → NATS 广播激活集 → dispatcher/mcp-go `ApplyOverrides` 热更新。 + +**④ 报告导出/写文件**:mcp-go `report_render` `os.WriteFile` 到 `SUNDYNIX_REPORTS_DIR`(默认 `$TMPDIR/sundynix-reports/{id}.docx`) → gateway `c.File(路径)` 流回 → 桌面端 Wails `SaveReportAs` 原生另存为落盘。**mcp-go 写、gateway 读须共享该目录。** PDF 目前靠前端打印(无后端 PDF 渲染)。 + +--- + +## 9. 安全与治理 + +- **鉴权**:JWT 无状态;owner 隔离(`owner_id`,单租户假设)。 +- **API Key 加密**:AES-256-GCM 端到端密文(PG + NATS 均密文)。 +- **输入护栏**:Tier1 正则/黑名单(网关,命中即拦 + 落库 `guardrail_event`)+ Tier2 LLM 分类(dispatcher 灰区)。 +- **审计**:敏感操作(改模型/密钥/激活 prompt/审批)经 `audit_log` 留痕,`/admin/audit` 可查。 +- **限流**:按登录用户(未认证按 IP),Redis 会话级。 +- **CORS**:开发 `*`,生产未显式配置则不放行任意源。 + +--- + +## 10. 可观测性 + +- **OTel 全链路**:节点/工具/LLM span,NATS 跨总线传播 traceparent,Jaeger(:16686)看瀑布。 +- **执行轨迹**:ExecEvent → 前端"运行·观测"面板(工具/专家调用、分支决策、审批等)。 +- **管理端**:`/status`(基建+服务探活+MCP 工具注册)、`/dashboard`(系统级 overview)、`/audit`(审计+安全事件)。 +- **缺口(待补)**:failover/熔断的**运行时态**只在日志、admin UI 不可见(T4.F 🔴)。 + +--- + +## 11. 前端设计 + +**双前端,严格独立、绝不合并**: +- **桌面端**(Wails 原生窗口 + React):用户工作产品——画布编排/任务运行/知识库/报告/记忆。含 Wails Go 绑定(`app.go`)提供原生能力:另存为/系统应用打开/文件读写/通知。 +- **管理端**(React,Vite):运维控制台——模型/计价/数据源/提示词/服务状态/评测/租户/护栏/审计。已接 vitest。 + +--- + +## 12. 部署形态与配置 + +- **基建**:docker compose(PG/Redis/NATS/Milvus+etcd+MinIO/Neo4j/Jaeger)。 +- **应用**:Go 服务本地编译二进制 / `go run`;启动序 = gateway → dispatcher/mcp-go(mcp-go 必须在 Milvus 后)→ mcp-py。 +- **关键配置对齐**(多进程/多机须一致):`SUNDYNIX_SECRET_KEY`、`NATS_URL`、`POSTGRES_DSN`、`SUNDYNIX_REPORTS_DIR`;`ADMIN_USER_IDS`、`CORS_ALLOW_ORIGIN`(生产)、`TASK_TOKEN_BUDGET`、`RATE_LIMIT_PER_MIN` 等。 + +--- + +## 13. 当前能力矩阵(干到哪一步) + +**✅ 已建(live 验证)** +编排引擎(compose 单引擎,分支/并行/HITL)· 多智能体协调 · RAG 三路混合 + 评测台 · 大文件入库生产化(MinIO+JetStream)· 模型 failover + 每模型熔断 · 输出缓存 · 评测纠偏闭环 · 输入护栏 + 输出脱敏 + 预算 · prompt 版本化 + DB 控制面热切换 · API Key 加密 · OTel 全链路 · **审计可溯源(audit_log + guardrail_event + admin 审计页)** · **系统级 admin overview** · **DSL 拓扑校验** · admin 控制台(含 vitest) + +**🔶 进行中(T4.F 健壮性收口)** +拆残骸/CORS/限流已做;**剩** KB 级联删事务化 · 关键 DB 写失败上浮 5xx · 审批 checkpoint 落盘重试 · 🔴模型健康/熔断态上 admin · 分页 · 配置化 + +**⬜ 规划(未动)** +T4.A 多租户/RBAC(无 tenant_id,L)· T4.C 真实计费(L)· T4.D AI 核心(记忆 Consolidate / prompt 灰度% / mcp-py 算法去桩 / 报告原生 PDF)· T3 生产硬化 ⏸(NATS 集群 / DB HA / K8s / TLS / 备份 DR,等真实流量) + +--- + +## 14. 技术栈 + +| 层 | 技术 | +|---|---| +| 编排/后端 | Go · gin · **Eino(cloudwego)** · NATS/JetStream · gorm | +| LLM | OpenAI 兼容 API(DeepSeek 等)· eino-ext openai 组件 | +| 工具服务 | Go(mcp-go) · Python(mcp-py) | +| 前端 | React 19 · Vite · Tailwind · Wails(桌面端) · vitest | +| 数据 | PostgreSQL · Redis · Milvus · Neo4j · MinIO | +| 可观测 | OpenTelemetry · Jaeger · Prometheus 指标 | + +--- + +*本文档描述当前架构的「设计」;对该设计的优缺点评价、技术债与生产化建议见 `ARCHITECTURE_REVIEW.md`。* diff --git a/ARCHITECTURE_REVIEW.md b/ARCHITECTURE_REVIEW.md new file mode 100644 index 0000000..4baee13 --- /dev/null +++ b/ARCHITECTURE_REVIEW.md @@ -0,0 +1,172 @@ +# sundynix-agentix · 架构评审报告 + +> 评审日期:2026-07-02 | 评审范围:全栈架构(gateway / dispatcher / mcp-go / mcp-py / shared / 双前端 / 基建) +> 评审方式:通读代码 + 真机运行验证(本次会话中实跑并踩到若干真实问题,见文末「证据索引」) +> 定性:这不是「一个应用」,而是一套 **事件驱动的 Agent 平台**。请按「平台」而非「应用」的标准衡量它。 + +--- + +## 0. 摘要(一句话结论) + +**架构 factored 得相当好、也相当有野心,但当前是「分布式系统的复杂度,由接近单人的规模在扛」。** +解耦干净、控制面热切换、韧性分层——这些是真资产;代价是运维面大、一致性靠自觉、单租户假设焊死。 +复杂度**是否值得,取决于目标**:多租户平台产品 → 值;单用户工具/demo → 偏重。 + +| | Top 3 | +|---|---| +| 最强 | ① 契约+NATS 解耦(加工具/换模型/改 prompt 热生效,零重编译)② 分层韧性(failover+熔断+预算+护栏+评测)③ 流式原生 | +| 最痛 | ① NATS 万物单点 + 运维尖角(双 NATS 脑裂真踩到)② 一致性靠 best-effort(非事务级联删等)③ 单租户假设焊死 | + +--- + +## 1. 系统全景 + +``` + ┌─────────────┐ ┌─────────────┐ + │ 桌面端 Wails │ │ 管理端 React │ ← 两个独立产品面(绝不合并) + └──────┬──────┘ └──────┬──────┘ + │ HTTP/SSE │ HTTP + ┌──────┴──────────────────┴──────┐ + │ gateway (gin) │ 接入层:JWT鉴权/路由/护栏/限流/审计/SSE回流/导出 + └───────────────┬────────────────┘ + │ NATS(RPC + 流 + 控制面广播 + 心跳)——万物总线 + ┌──────────────────┼───────────────────────────────┐ + │ │ │ +┌────┴─────┐ ┌──────┴──────┐ ┌──────┴──────┐ +│dispatcher│ │ mcp-go │ │ mcp-py │ +│ 编排核心 │ │ Go 工具服务 │ │ Python 算法 │ +│ (Eino) │ │ RAG/记忆/ │ │ 沙箱/解析 │ +│ +harness │ │ 历史/报告 │ │ │ +└────┬─────┘ └──────┬──────┘ └─────────────┘ + │ │ + └── LLM providers └── PG / Redis / Milvus / Neo4j / MinIO + (OpenAI 兼容) 关系 / 缓存 / 向量 / 图谱 / 对象 +``` + +**模块职责边界(清晰、无循环 import)** +- **gateway**:HTTP 接入、JWT、路由、输入护栏、限流、审计、SSE 回流、报告导出服务。不含业务编排。 +- **dispatcher**:Eino 编排引擎(DSL→compose 图)+ harness 治理层(评测/护栏/预算/熔断/纠偏/脱敏)+ LLM 池(failover+缓存)+ 多智能体协调。 +- **mcp-go / mcp-py**:MCP 工具服务,经 NATS 被调。真正的 I/O 与算法执行在这里。 +- **shared**:bus(NATS/JetStream)、contract(跨服务契约)、prompts 注册表、secrets(AES-GCM)、otel。**服务间只认 contract + bus,互不 import。** + +--- + +## 2. 核心架构决策与取舍 + +| 决策 | 好处 | 代价 | +|---|---|---| +| **NATS 为统一总线**(RPC/流/控制面/心跳全在上) | 极致解耦;广播式控制面热更新 | 万物单点;一旦异常以诡异方式全面降级;单点 NATS(集群是 T3) | +| **MCP 工具作为独立服务** | 加工具只改注册表、dispatcher 自动发现;多语言(Go+Py) | 每次 tool call 是网络 RPC(多 `no responders`/超时故障类);要整链在线 | +| **Eino 做编排核心** | DSL→compose、ReAct、checkpoint(HITL) 白嫖框架 | 框架耦合/塑形;限制传导(如流式中途失败不切) | +| **控制面热切换**(模型/prompt 经 NATS 广播) | 不重启改配置/换 provider/改提示词 | 权威态在 DB、下发靠广播,广播失败需兜底 | +| **契约解耦**(shared/contract) | 服务独立演进、独立部署 | 契约变更需跨服务协调 | +| **多存储专用化**(PG/Redis/Milvus/Neo4j/MinIO) | 各司其职、检索质量高 | 5 个存储要起/连/备份/保一致 | +| **双前端**(desktop/admin 独立) | 用户产品与运维面互不干扰 | 两套前端 + 两套构建要维护 | + +--- + +## 3. 关键数据流(架构最能说明问题的四条) + +**A. 任务生命周期**:桌面端 POST DSL → gateway 解析+拓扑校验 → NATS 发布 → dispatcher 编译为 Eino compose 图执行 → token 流 + 执行轨迹经 NATS→Redis Stream→SSE 回流 → 状态机 FSM 落库。 + +**B. 工具调用(两条路,易混淆)** +- **编排直连**:dispatcher 编排代码 `o.tools.CallTool()` 直接 NATS→mcp-go。**报告写文件(report_render)走这条,绕开 Eino。** +- **Eino ReAct 自主调用**:仅 `agent:true` 工具。模型 function-call → Eino ToolsNode → `mcpTool.InvokableRun`(react_agent.go)→ NATS→mcp-go。 +- **共同点**:真正执行永远在 mcp-go;**Eino 把工具当黑盒(吃 JSON→吐字符串),从不碰文件系统**。 + +**C. 报告导出/写文件**:dispatcher 生成 → mcp-go `report_store` 写源 JSON → 用户导出 → gateway `report_export` → mcp-go 渲染 docx 并 `os.WriteFile` 到 `SUNDYNIX_REPORTS_DIR`(默认 `$TMPDIR/sundynix-reports/{id}.docx`)→ gateway `c.File(路径)` 流回 → 桌面端 Wails `SaveReportAs` 原生另存为落盘。**⚠️ mcp-go 写、gateway 按路径读,二者必须共享该目录。** + +**D. 控制面热切换**:admin 改配置 → gateway 写 DB → NATS 广播激活集 → dispatcher/mcp-go `ApplyOverrides` 热更新(不重启)。本次会话 live 验证 prompt/模型 均即时生效。 + +--- + +## 4. 优点(有据,非纸面) + +1. **解耦干净、扩展成本低 —— 最大亮点。** 服务互不 import,只认契约。加工具只改 mcp-go 注册表、dispatcher 经 `list_tools` 自动发现;换 provider/改 prompt 控制面热下发、零重编译。本次会话亲见 live 生效。 +2. **韧性分层叠进架构,非事后补。** model 层 failover + 编排层熔断 + 预算 + 护栏 + 评测纠偏(「恒温器」)。单 provider 挂平台不宕——本次真机演示过完整 failover+熔断+半开恢复。 +3. **流式原生。** token 流 + 执行轨迹 NATS→SSE + Redis Stream 断点重放,实时体验是架构级支持。 +4. **Eino 省造轮子。** 编排图/ReAct/checkpoint(HITL) 白嫖框架,自研 graph.go 已退役。 +5. **合理的多语言/多存储。** Go 走热路径,Python 做算法/沙箱;存储各司其职。 +6. **可观测与水平扩展底子在。** OTel 全链路 + 结构化日志 + 轨迹;NATS queue group + JetStream 持久消费,worker 无状态可扩;入库走 JetStream 持久队列(崩溃重投/幂等/背压,已 live 验证)。 + +--- + +## 5. 缺点 / 风险 / 技术债(诚实的一半) + +### 5.1 运维面大、故障以诡异方式出现 +- **NATS 万物单点。** 它是 RPC/流/控制面/心跳,一旦异常全面降级。本次真踩到**双 NATS 脑裂**(`make devnats` 与 docker NATS 都占 4222,客户端被劈成两半、静默半失败)。当前是**单点 NATS**(集群为 T3,未上)。 +- **分布式跑/调都难。** 4 服务 + NATS + 5 存储要全起对连对。`local-run-gotchas` 那条 memory 长不是没原因(mcp-go 必须在 Milvus 后、ReportsDir 共享、token 过期静默 401、JetStream 重投…)。 + +### 5.2 一致性/正确性靠自觉,非机制强制 +- 大量 **best-effort**:审计失败吞掉、部分关键 DB 写失败仍返 200、**KB 级联删跨三库非事务**(删一半失败仍删 PG → 不一致)。 +- 预算/护栏是「计量+兜底」式,非硬约束(不过已有默认顶,见「审计误报」)。 + +### 5.3 与「干净解耦」自相矛盾的隐性耦合 +- **共享本地文件系统**(ReportsDir):mcp-go 写、gateway 按路径读——单机能对上,一旦容器化拆开即断,除非挂共享卷。这是整套 NATS 解耦里一处**潜在耦合**。 + +### 5.4 扩展性/多租户 +- **单租户假设焊死。** 无 RBAC/tenant_id,`owner_id` 当隔离键。变多租户是**动表结构 + 鉴权边界的手术**(T4.A,L 级)。 +- **RequireAdmin 开发期放行任意登录用户**(生产靠 `ADMIN_USER_IDS` 白名单,无角色矩阵)。 + +### 5.5 框架耦合 / 取舍不一致 +- 编排核心 Eino-native,框架抽象塑形代码,限制会传导(流式中途失败不切,部分是 Eino stream 语义)。 +- 又有「该用库却手搓」的地方:`office/unioffice.go` 其实是零依赖手搓 docx(非 unioffice 库);`mcp-py` 算法层目前是**桩**(解析未接 OCR)。 + +### 5.6 可观测的盲区 +- failover/熔断的**运行时态只在 dispatcher 日志,admin UI 看不到**(本次 demo 暴露,已记 T4.F 待办)。分层韧性在运维上偏不透明。 + +--- + +## 6. 「税单」:复杂度的代价何时到期(映射路线图) + +这套架构的痛点,本质是「分布式平台迟早要付的税」。项目已把它们编入路线图,且**克制地把纯部署硬化推迟**(T3 标 ⏸「等有真实流量再上」——这是对的判断,别在没用户前先付税): + +| 债 | 何时爆发 | 对应项 | +|---|---|---| +| 单点 NATS / DB 无 HA | 第一次真实并发/宕机 | T3(NATS 集群 + DB HA)⏸ | +| 单租户 → 多租户 | 第一个多租户客户 | T4.A(RBAC/tenant_id,L)| +| 共享文件系统耦合 | 第一次容器化/多机 | 见 §7 必修清单 | +| 非事务级联删 / best-effort 写 | 数据量上来后的静默不一致 | T4.F(级联删事务化/关键写上浮 5xx)| +| 无审计/安全溯源 | 合规/事故复盘 | ✅ T4.B(已做:审计+护栏事件+审计页)| +| 运行时韧性态不可见 | 生产排障 | T4.F(🔴 模型健康/熔断态 surface 到 admin,本次新增)| + +**已还的债(本次会话)**:T4.B 审计可溯源整组 ✅、T4.E 编排边角整组 ✅(Map 错误传播/专家超时/Branch else/DSL 校验/熔断接回 failover)、T4.F 部分(拆 search 残骸/CORS+限流收紧)。 + +--- + +## 7. 「上生产前必修清单」(当你要容器化 / 多机部署时,这些会先断) + +1. **报告文件落盘 → 去本地盘耦合。** mcp-go 写/gateway 读同一本地目录在多机即断。改:报告直传 MinIO(正文已走 MinIO,导出产物同理),gateway 从对象存储取,而非 `c.File(本地路径)`。 +2. **单点 NATS → 集群。** 3 节点 + JetStream Raft,否则总线宕=平台宕。(T3) +3. **多进程共享配置对齐。** `SUNDYNIX_SECRET_KEY`(三服务须一致)、`SUNDYNIX_REPORTS_DIR`、`NATS_URL` 等,容器化时用统一 env/secret,别靠 `$TMPDIR` 默认对齐(会不一致)。 +4. **DB HA + 备份/DR 演练。**(T3) +5. **CORS/TLS 严格化。** CORS 生产已收紧(本次做);TLS 待上(T3)。 +6. **RBAC/多租户**若面向多客户则前置(T4.A)。 +7. **一致性收口**:KB 级联删事务化、关键 DB 写失败上浮 5xx(T4.F)——多机下静默不一致更难查。 +8. **可观测补齐**:模型/熔断运行时态、各服务指标上报 admin(T4.F)。 + +--- + +## 8. 总体判断与建议 + +**判断**:一套 factored 良好、有平台野心的架构。解耦与控制面是真正的差异化资产;当前阶段(单租户、无真实流量)复杂度相对偏重,但**方向自洽**——痛点都已被识别并编入路线图,且硬化被克制地推迟。 + +**最该警惕的一条**:解耦很干净,但**一致性与运维的「暗债」在攒**(非事务级联删、best-effort 写、双 NATS 这类环境坑、共享文件系统)。这些不会在 demo 里咬你,会在「第一个真实多用户 / 容器化部署」时集中爆发。 + +**建议(按优先级)**: +1. **继续还 T4.F 的正确性债**(级联删事务化、关键写上浮)——低成本、防未来静默 bug。 +2. **补运行时可观测**(模型/熔断态上 admin)——让分层韧性在生产可见。 +3. **在容器化之前,先解「共享文件系统 + 配置对齐」两处**(§7 #1、#3)——这是"看起来能跑、一拆就断"的典型。 +4. **T3(NATS 集群/HA/TLS)维持 ⏸,直到有真实流量**——不提前付税是对的。 +5. **多租户(T4.A)按商业化节奏推**——不确定要多租户就先别动这个大手术。 + +--- + +## 附:证据索引(本次评审的一手依据) + +- **控制面热切换 live 有效**:prompt 建版→激活→图谱抽取即时变;模型配置热下发(dispatcher 日志 `model config set`)。 +- **failover+熔断 live**:配坏主模型→任务经备用出答案;连败 3 次熔断→跳过坏主;冷却半开探测→重试→重熔断(完整三态)。 +- **双 NATS 脑裂真踩到**:`devnats`(127.0.0.1:4222) 与 docker NATS(*:4222) 并存,客户端劈成两半、`/admin/status` 误报服务离线;`curl :8222/connz` 定位、`pkill devnats` 收敛。 +- **审计清单需逐项核实**(原始审计有噪声):ListModels「O(N²)」实为 O(N) 单次;budget「无限烧」实为 `taskBudget` 已默认 20 万顶;hybrid「删文件」实为拆残骸接线;failover「卡在备用」实为每次都重试主。**教训:改前必读码核实。** +- **关键文件**:`gateway/internal/{router,handler,middleware,store}`;`dispatcher/internal/{eino,llm,harness}`;`mcp-go/internal/{mcp,rag,office}`;`shared/{bus,contract,prompts,secrets}`。 +- **配套文档**:`DEPTH_ROADMAP.md`(T0–T4 路线)、`EINO_ADOPTION.md`(Eino 采纳 A/B/C✅ D 基本)、`production_readiness.md`、`architecture.md`。 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..0d2229f --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,69 @@ +# 部署指南(独立部署 / 单机演示) + +一条命令拉起整套 sundynix-agentix(应用 5 服务 + 基建)。适用于**演示**与**客户独立部署**。 +集群高可用(多副本 / NATS 集群 / 数据库主从)是用户量上来后的事,不在本指南范围。 + +## 前置 +- Docker 24+ 与 Docker Compose v2 +- 约 8GB 内存、10GB 磁盘(Milvus/Neo4j 较吃资源) + +## 三步上手 + +```bash +# 1) 生成配置 +cp .env.example .env + +# 2) 填密钥(必填四项 + 管理员) +# 主加密密钥 / JWT 密钥: +openssl rand -base64 32 # 填入 SUNDYNIX_SECRET_KEY +openssl rand -base64 32 # 填入 JWT_SECRET +# 数据库密码: +openssl rand -base64 24 # 填入 POSTGRES_PASSWORD(勿含 @ : /) +openssl rand -base64 24 # 填入 NEO4J_PASSWORD +# ADMIN_USER_IDS 先留空(见下「首次管理员」) + +# 3) 起服务(首次会构建镜像,约几分钟) +docker compose -f docker-compose.prod.yml up -d --build +``` + +起来后: +- 控制台: http://<服务器IP>:80 +- 网关 API: http://<服务器IP>:8080 (桌面端连这里) +- 链路追踪 UI(可选): http://<服务器IP>:16686 + +## 首次管理员 +生产模式下 `/admin` 仅 `ADMIN_USER_IDS` 白名单可进。首次: +1. 打开控制台注册第一个账号; +2. 取该账号的用户 id(注册响应里有,或 `GET /api/v1/auth/me`); +3. 填进 `.env` 的 `ADMIN_USER_IDS`,`docker compose -f docker-compose.prod.yml up -d gateway` 重启网关。 + +## 配置模型(部署后在控制台做) +LLM 模型 API key **不放 .env**,登录控制台 →「模型管理」配置: +- 对话模型(chat):在线 API(OpenAI 兼容,如 DeepSeek)或本地 vLLM/Ollama +- 向量模型(embedding):RAG 用 +平台会用 `SUNDYNIX_SECRET_KEY` 把这些 key AES 加密后存库,不落明文。 + +## 两类密钥(重要) +| 类别 | 放哪 | 例子 | +|---|---|---| +| 引导密钥 / 基建凭据 | `.env`(部署时填一次) | `SUNDYNIX_SECRET_KEY`、`JWT_SECRET`、库密码 | +| 业务模型 key | 控制台(运行时配,加密存库) | DeepSeek / Qwen / embedding 的 api_key | +`SUNDYNIX_SECRET_KEY` 是前者加密后者的主密钥,**务必备份且不可泄露**;丢失=已存模型 key 全部失效。 + +## 备份 / 恢复 +见 `scripts/backup.sh` 与 `scripts/restore.sh`(备份 PG / Neo4j / Milvus / MinIO 数据卷)。 +建议 cron 每日跑 `backup.sh`,产物存异机。 + +## 安全建议(对外开放时) +- 生产把 `CORS_ALLOW_ORIGIN` 设为具体域名,别留 `*`。 +- 基建端口(PG/Redis/NATS/Milvus/Neo4j/MinIO)默认**不对宿主暴露**,仅 compose 内网;勿手动开放。 +- 对外建议在 admin 容器前再加一层 TLS 反代(Caddy/Nginx),或用云 LB 终止 HTTPS。 +- `SUNDYNIX_SECRET_KEY`/`JWT_SECRET` 用 `openssl rand` 生成的强随机值,切勿用示例值。 + +## 运维常用 +```bash +docker compose -f docker-compose.prod.yml ps # 状态 +docker compose -f docker-compose.prod.yml logs -f gateway +docker compose -f docker-compose.prod.yml down # 停(保留数据卷) +docker compose -f docker-compose.prod.yml up -d --build gateway # 改配置后单独重启 +``` diff --git a/DEPTH_ROADMAP.md b/DEPTH_ROADMAP.md new file mode 100644 index 0000000..6e4f35b --- /dev/null +++ b/DEPTH_ROADMAP.md @@ -0,0 +1,260 @@ +# sundynix-agentix · 深度路线图 + +> 目标:**在现有广度下做深度** —— 不加新功能面,把已有的盘活、做实、能交付。 +> 用法:完成一项勾一项 `[ ]`→`[x]`,子项同理。每完成一个 Tier 回顾一次。 +> 图例:`[ ]` 未做 · `[x]` 完成 · `[~]` 进行中 · 🔴 高杠杆 · ⏸ 暂缓 + +## 诊断:深度不足分四类(别混排) + +- **A 建了没激活/没校准**:评测裁判恒给 ~1.00 → 纠偏闭环空转;多智能体后端通了但 Studio 画不出。 +- **B 重复/欠债的税**:两套编排引擎(graph.go + compose);前端测试薄(自评 🔴)。 +- **C AI 核心还浅**:单模型无 fallback;RAG 三路部分降级/桩、无检索质量度量;无 prompt 版本管理/输出缓存。 +- **D 生产硬化 🔴**:审计日志 / NATS 集群 / DB HA / K8s / TLS —— 是"部署深度"非"产品深度",**无真实流量前暂缓**。 + +--- + +## 🥇 Tier 0 — 激活已有投入(小改动,最高回报) + +### [x] T0.1 🔴 校准评测裁判 — 让恒温器真触发 ✅ +现状:LLM judge 恒给 0.94/1.00,从不判 poor,低分自动纠偏闭环几乎从没启动。 +- [x] `eval.go` judge 改对抗性/rubric 打分(默认怀疑、先挑毛病再打分、给死评分基准、用满 1–5) +- [x] grounded judge 同样收紧(忠实度按编造数递减,一处编造≤2;未支持说法进 Flags) +- [x] **根因更深的数学修复**:归一 `score/5`(下限 0.2) → `(v-1)/4`(1→0),否则 0.4*rule 底使 poor 永不可达 +- [x] 校准测试:normJudge 区间 + 「流畅但跑题(judge=1)可达 poor」+ 好坏区分度 +- [x] 复核阈值(poor<0.5 / warn<0.75)与新分布匹配 +- **验收 ✅ live(deepseek)**:跑题→0.40 poor→**触发纠偏(0.40→0.55)**;截断→0.55 warn;好答案→1.00 ok。 + 校准前这三个全是 1.00→ok,恒温器从没触发。 + +### [x] T0.2 多智能体进 Studio(多智能体 v2)✅ +现状:coordinator 后端已通(见 MULTI_AGENT.md),但画布拖不出。 +- [x] nodeCatalog 加 `coordinator` 节点(「多智能体协调」,indigo)+ 新 `agentList` 字段类型 + Specialist 接口 +- [x] Inspector `AgentListField`:可增删的专家卡片(名/用途/系统提示词/工具逗号分隔) +- [x] dsl 校验:agentList 需 ≥1 个有名字的专家 + 名字不重复 +- [x] 运行轨迹「工具调用」面板纳入专家派发(kind=agent)→ 改名「工具/专家」,专家项 indigo 标识 +- [x] 后端验证:UI 同款 DSL 结构提交可跑(agents 数组经 exportDsl 透传 → parseSpecialists) +- 验收 ✅:Studio 左栏出现「多智能体协调」节点,检查器配专家卡片,导出 DSL 对齐后端; + 轨迹/工具面板能看到专家派发。(真机由用户在 wails 窗口确认) + +--- + +## 🥈 Tier 1 — 收口 / 去税 + +### [x] T1.1 退役 graph.go — 消灭双引擎 ✅ +现状:compose 已默认,HITL/多智能体只在 compose 上;graph.go 是逃生舱。 +- [x] compose soak(默认跑数天,HITL/多智能体/评测全在其上真实运行) +- [x] 删 `runGraph`(自研拓扑解释器)+ `composeEnabled`/`EINO_COMPOSE` 开关 + `runConversation` 死代码 +- [x] compose 编译失败兜底改单轮对话(不再回退 graph.go);清掉仅 runGraph 用的 import(otel) +- [x] 保留 board/各节点执行器/工具函数(compose 各 lambda 复用);8 处测试 runGraph→runComposeGraph, + 等价测试转为 compose 正确性测试,runConversation 测试转为「无 ChatModel 降级 runAgent」 +- 验收 ✅:单引擎,go test ./... 全绿 + vet 干净;冒烟 简单 agent/分支图 跑通;HITL/多智能体/branch/map + 全在 compose 上。 + +### [ ] T1.2 前端深度补齐 +- [ ] 评测质量面板(运行页展示 overall/质量/忠实/纠偏/flags,驱动 T0.1 的可见性) +- [ ] HITL 边角(长等待后 token 重连、拒绝分支体验) +- [ ] 补前端测试(自评 🔴 缺失):关键流程(run/审批/多智能体)的组件/逻辑测试 +- 验收:后端已有能力在 UI 都可见可操作;前端测试覆盖核心流程。 + +### [~] T1.3 管理端做实(admin 控制台从 mock → 真控制面) +管理端定位 = **系统控制塔**(统筹全系统:全局吞吐/配置态/健康/安全/成本),区别于桌面端个人运行页。 +RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级**而非个人作用域。 +- [x] 接 vitest(此前零测试)+ 41 单测(api/diff/路由派生) +- [x] 新增「提示词」控制面页(接 `/api/v1/prompts`:建版本/激活热下发/对比 diff/撤销,live 验证) +- [x] 概览页重做为「系统控制塔」:全局任务吞吐 + 模型路由/Fallback 态 + 提示词控制面态 + 系统健康拓扑 + (去掉个人作用域的 我的KB/我的token/我的运行feed,与桌面端运行页不再重复) +- [ ] 🔴 **下一步:admin 专属聚合接口 `GET /api/v1/admin/overview`(RequireAdmin)** + —— 概览现借桌面端个人接口 `stats/overview`(其任务/评测碰巧全局因 Task/Eval 无 owner),不干净。 + 新接口一处聚合**系统治理口径**:全平台用户总数 / 租户数 / 全局任务(今日/累计/趋势/终态) / + 全局评测均分 / 模型配置态 / 提示词覆盖数 / 服务健康。前端概览改吃此接口,补上「用户/租户总数」缺口。 +- [ ] 其余 mock 页按需做实:Datasources / Evals / Tenants / Guardrails(多为本地 mock,无后端数据源) +- 验收:概览/关键页吃真实**系统级**数据;admin 有独立聚合接口不再蹭个人接口。 + +--- + +## 🥉 Tier 2 — 把 AI 核心做深(差异化卖点) + +### [x] T2.1 模型路由 + Fallback ✅ +现状:单模型,一家 provider 抖动全平台挂(自评 ⚠️缺失)。 +- [x] 多 provider 配置:复用 admin 已有 models 表 —— 注册多个 chat 模型即自动成主备(active=主, + 其它=按序备用),无需新 admin UI;ModelConfig 加 Fallbacks 骑主配置下发(不改 bus 签名) +- [x] 主调用失败/超时自动切备:`failoverModel`(model.ToolCallingChatModel)按序切,compose/ReAct/Chat + 全路径透明;调用方取消不切;备用 api_key 一并解密 +- [ ] 按任务类型/成本选模(可选,v1 跳过);熔断器联动(failover 在 model 层、熔断在编排层,互补未整合) +- 验收 ✅ live:active=死 ollama 主 + deepseek 备 → 任务连主拒连→自动切 deepseek→4s 完成。 +- v1 局限:Stream 仅建流同步报错时切(已回流 token 的中途失败不切)。 + +### [x] T2.2 RAG 深度(三路做实✅;离线质量评测✅) +实测纠正:RAG 三路(向量/全文/图谱)+RRF+rerank 早已实现,之前"降级/桩"全是基建没起的假象。 +基建拉起后发现真问题:**对中文只有向量路在干活**(`hybrid: 向量=1 全文=0 图谱=0`)。 +- [x] 修全文路:Bleve 默认分词器不切中文 → text 字段改 cjk 分词器(bigram),kb/doc 用 keyword +- [x] 修图谱路:`$q CONTAINS a.name` 漏后缀错配(查"星云一号"漏实体"星云一号卫星")→ 加查询 + 字符 n-gram(2..8) 双向子串匹配 +- [x] live 验证:同查询 `向量=1 全文=0 图谱=0` → `向量=1 全文=1 图谱=9`,三路全活;中文混合 1/3→3/3 +- [x] 检索质量度量(离线评测集 recall@k/MRR + 混合 vs 单路对比)—— `scripts/rageval.py` 可复用评测台; + 引擎加 `SearchByMode`(vector/fulltext/graph/hybrid 纯检索),kb_search 加 mode 参数。 + **live 跑分(24篇语料/16查询,含纯语义改写)**: + | 模式 | recall@5 | MRR | 纯语义改写 | + |---|---|---|---| + | vector | 1.00 | ~0.9 | 4/4 | + | fulltext | 0.88 | 0.77 | **2/4** | + | graph | 0.75 | 0.59 | **1/4** | + | **hybrid** | **1.00** | ~0.9 | **4/4** | + 结论:关键词/实体查询各路都强;**纯语义改写让全文/图谱漏召回,混合兜回 4/4**—— + 混合 = 各路上界的稳健组合,recall 始终 ≥ 最佳单路。图谱路最弱(依赖 LLM 抽取+实体匹配)。 +- 验收 ✅:三路都真出结果 + 质量可度量、混合价值量化(稳健兜底)。 + +### [x] T2.3 Prompt 版本管理 + 输出缓存(缓存✅ + prompt版本化✅:文件覆盖+DB控制面热切换;灰度%可选) +- [x] 模型输出缓存:`cachingModel`(llm/cache.go)包在 failover 外层,缓存 Generate(非流式), + 键=模型+工具+消息哈希,默认 TTL 60s(env 可调/关)。单测全过。 + ⚠️ **诚实**:本平台以流式创作为主、Generate 输入每次变,**真实命中率天然偏低**(主要吃短窗内 + 逐字相同的重试/双发);机制对、零风险,但非大成本杠杆。更大的省钱项(语义缓存/确定性工具结果 + 缓存)是后续。 +- [x] prompt 版本化 v1(地基)—— `shared/prompts` 注册表:内置默认(随代码) + 运行期覆盖(`PROMPTS_FILE`)。 + 9 处散落硬编码 system prompt(graph抽取/eval质量/eval修订/越狱护栏/协调者/记忆对账等)收口为 `prompts.Get(key)`, + **不重编译即可改/回滚/对比**。单测(默认/覆盖/文件) + **live A/B:覆盖 graph.extract→图谱抽取 2 条→0 条,向量仍正常**。 +- [x] prompt 版本化 v2(管理层)✅ —— `sundynix_prompt` 表(key/version/content/active) + 控制面热切换: + API(`GET/POST /prompts`、version/activate/deactivate) → 激活某版经 NATS 广播 → dispatcher/mcp-go + `ApplyOverrides` 热更新(镜像 model-config 控制面)。**live:建版本→激活→不重启 mcp-go 图谱抽取 2→0→回滚 2; + 版本留存可回溯;deactivate 回退代码默认**。灰度%/审计是可选后续。 +- 验收:缓存 ✅;prompt 版本化地基 ✅(文件覆盖) + DB 控制面热切换 ✅(live);灰度%可选。 + +### [~] T2.4 大文件 RAG 生产化(切片/向量/队列✅;检索持久 + 治理待补) +准生产目标:几十万字文件从上传到可检索,全链路抗并发、抗崩溃、抗大体量。三段已做实并全 live 验证。 +- [x] **存储**:正文一律落 MinIO(去 <8000字内联PG 阈值,仅 MinIO 挂时回退兜底);`sundynix_doc` 删死字段 `MD5` +- [x] **切片**:不改(`chunk.go` 语义切块 500字/重叠80/句界,对大文件本就 OK) +- [x] **向量化**:串行→`embedAll` 并发分批(并发4,保序);几十万字上千块快数倍 +- [x] **图谱**:整篇喂LLM(爆上下文只抽开头)→`extractGraphWindowed` 窗口化(4000字/窗,封顶60,并发3),实体按 kb+name 去重。live:16k→6窗,末段实体进图谱=全覆盖 +- [x] **入库工作队列**:裸 `go runIngest` → JetStream 持久队列(claim-check 暂存 + durable consumer + MaxAckPending 背压 + AckWait 30min + MaxDeliver 4 + 优雅 drain) +- [x] **live 验证**:202/80ms 异步入队;kill -9 中途崩→作业不丢→重启重投续跑完成;4次重投实体数 81 不翻倍(幂等:先删后写+MERGE) +- 验收 ✅:切片/向量/队列三段准生产级;下列待补项按 ROI 排序见下。 + +#### 待补 backlog(按 ROI 排序) +- [x] **🔴 P0 Bleve 落盘**(瓶颈②)✅ —— 全文索引 `NewMemOnly` → 落盘 scorch(env `BLEVE_PATH`,默认 `.data/bleve`, + 落盘失败退内存兜底;`Engine.Close` 刷盘释放锁)。修复"重启即丢、三路退两路"。 + 单测 `TestBleve_PersistsAcrossReopen`(写→关→重开仍可检索);**live:入库后全文=1 → 重启 mcp-go 不重入库 → 全文仍=1**。 +- [x] **P1 图谱抽取单配便宜模型**(瓶颈⑤)✅ —— 加 `graphChat`(env `GRAPH_CHAT_BASE/KEY/MODEL`), + `graphChatClient()` 优先专用模型、未配回退控制面主 chat。每篇≤60次图谱抽取走便宜模型,主对话仍用好模型。 + 单测 `TestGraphChatClient_FallsBackToMain`(回退/启用)。 +- [x] **P1 图谱封顶/窗口可配**(瓶颈①)✅ —— `graphMaxWindows/WindowRunes/Concurrency` 改 env 可配 + (`GRAPH_MAX_WINDOWS` 等)。单测 + **live:16k(默认6窗) 设 GRAPH_MAX_WINDOWS=2 → 实抽 2 窗 + 截断告警**。 +- [x] **P2 join key → file_id**(瓶颈④)✅ —— 下游键从 doc名 改**稳定 file_id**(gateway 传 docID; + Milvus/Bleve 按它键;Neo4j 关系打 `file_id` 属性、实体仍 kb+name 共享)。新增 `kb_delete` 工具 + + `DELETE /api/v1/kb/doc` **级联删**(三库 + MinIO + PG/双链,owner 隔离)。 + **live:删一篇 → PG404/向量0/全文0/图谱0/孤儿实体0/MinIO空 五处皆净;重名重入库 id 稳定不孤儿**。 + +--- + +## 🗄️ Tier 3 — 生产硬化 ⏸(等有真实流量再上) + +- [ ] 审计日志(敏感操作留痕) +- [ ] NATS 集群(3 节点 + JetStream Raft)+ DB HA +- [ ] K8s 部署 + 多副本 +- [ ] 网络安全(TLS / CORS 严格化) +- [ ] 备份 / DR 演练 + +--- + +## 🧱 Tier 4 — 后端做实(2026-06-30 三路 Explore 审计产出) + +> 来源:三个 Explore agent 全量扫 gateway / dispatcher+shared / mcp 后端,找出"未实现 + 可优化"。 +> 工作量:S=1–2 天 · M=3–5 天 · L=1–2 周。一项项做,完成勾选 + 注明 live 结果。 +> 推荐起步:**T4.B(审计可溯源 + admin/overview)** —— 内聚、全 M、直接喂管理端、不依赖大重构。 + +### [~] T4.A 多租户 / RBAC / 用户治理(结构性地基,体量大)—— **已启动,方向见 SAAS_DESIGN.md** +> 2026-07-06 用户定方向:真·多租户商业化 + 桌面端仍主产品(非Web-first)。按 SAAS_DESIGN.md P1 分阶段。 +- [x] **增量1:租户身份地基**✅(693a8f0)—— Tenant/TenantMember 表 + 注册建默认租户 + 存量回填 + + TenantContext 中间件注入 tenant_id + GET /tenants/current。live 验证。**未动查询隔离。** +- [x] **增量2:查询隔离机制(gorm 租户插件)**✅ —— KB/Agent 加 tenant_id + `isTenantScoped()` 标记; + `store/tenant_scope.go` gorm 回调按请求 ctx **自动**给受租户表查询加 `WHERE tenant_id`、创建自动填 + (统一强制、别靠人肉);ctx 无租户(系统/回填)则不过滤;中间件把 tenant 注入 request ctx;启动回填存量行。 + live 验证:创建自动写 tenant_id ✓;**同 owner 不同 tenant 的行被查询过滤掉** ✓(隔离机制生效)。 + Doc/DocLink(异步入库)、Task/Eval(无 owner,待提交链改造) 下一增量铺开。 +- [x] **增量2b:核心表 tenant_id 全链路闭合**✅(ee1e9cf + 766eaab)—— Task 加 owner+tenant(插件填)、 + Eval/Doc/DocLink 加 tenant(异步 background ctx 无请求租户 → 按关联源显式补:Eval 从 task 复制、 + Doc/DocLink 按 owner 查默认租户);`store.WithoutTenant` 系统旁路 + 修 admin overview 被自己租户 + 误过滤的回归;RecentTasks/RecentRuns 按 owner+tenant 过滤"我的运行"(raw Table 查询手动补); + BackfillRowTenants 扩到 doc/doc_link/task/eval。live 验证:创建自动填/异步补/跨用户读被过滤/admin + 全局口径全过。**PG 核心表(KB/Agent/Task/Eval/Doc/DocLink)隔离闭合。** +- [~] Redis/MinIO/Milvus/Neo4j 的 tenant 前缀 —— **实为已隔离,暂缓**:`scopedKB=userID+"/"+name` + 贯穿全存储(Milvus expr / Neo4j `{kb}` / MinIO key `owner/kb/doc` / Redis 按 user 键),userID 服务端注入 + 不可伪造 → 已按 owner 隔离;owner→tenant 1:1 故亦按租户隔离。仅当**多成员租户共享同一 KB**(工作区) + 时才需把命名空间从 `owner/name` 改 `tenant/name`——按需求拉动,别过早抽象。 +- **[~] P2 用量计量(SAAS_P2_DESIGN.md)** —— 积分为单位(token 直计=汇率1),扣量走扣积分。 + - [x] **增量1:链路 + 明细 + 折算**✅(d032c19)—— Meta/契约加 tenant→dispatcher 带;`usage_event` + 追加式明细(task_id 唯一幂等);credits(token基准×credit_weight) + cost(Pricing)折算落库。live 验证折算数学+租户传播。 + - [x] **增量2:账本 + 余额 + rollup**✅ —— `credit_ledger`(append-only,grant/usage/adjust) + + `tenant.credit_balance_micro` 物化余额(软扣可负) + `usage_rollup`(租户/天 upsert 累加); + 消费 usage_event 时**一个事务内**落明细→扣积分(账本+余额)→累加 rollup,幂等锚在 usage_event.task_id + 唯一(RowsAffected==0→重投跳过,绝不重复计费);`GrantCredits` 充值 API(增量3 admin 接)。 + live 验证:ledger 负分录 / 余额递减 / rollup 两任务累加 / **balance==seed+SUM(ledger) 不变量成立**。 + - [x] **增量3:`GET /admin/usage`**✅ —— 系统级(WithoutTenant):无 tenant→全平台按天 SUM + 各租户 + 用量排行(含余额);有 tenant→该租户按天趋势 + 当前余额。from/to(YYYYMMDD)缺省近 30 天。 + live 验证两口径数值自洽(balance=seed−credits、credits=tok×汇率)。**P2 后端(计量事实源)齐。** + - [ ] (P4 硬拦截 `CREDIT_ENFORCE` / 充值发放 / 前端用量页 —— 需求拉动) +- [ ] 真正的 RBAC:User 加 role 字段 + 角色/权限表,`RequireAdmin`(middleware/auth.go:46) 从白名单升级为角色校验 | M +- [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go)| M +- [ ] 多租户:Task/Eval/KB/Agent 加 tenant_id,owner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L + +### [x] 🔴 T4.B 审计 / 可溯源 + 管理端聚合 ✅(大头全 live;剩 HITL 明细可选) +- [x] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合 ✅ —— 全平台用户/KB/任务/评测/模型态/prompt 覆盖/健康;概览页已切过去(5调用→2),live 验证 users=7/kb 22/50 全平台口径。 +- [x] `audit_log` 表 + 中间件 ✅ —— `store.AuditLog` + `middleware.Audit(db)` 挂管理组 + prompt 激活/撤销 + HITL 审批;只审计变更类(POST/PUT/DELETE/PATCH),best-effort 落库不拖垮主流程;`GET /admin/audit` 列表(倒序翻页)。live:PUT pricing / POST deactivate 留痕,GET 不记。 +- [x] 护栏拦截事件落库 `guardrail_event` ✅ —— Guardrail 中间件命中(blocked/suspect)best-effort 落库(actor/kind/reason/signals/path/ip) + `GET /admin/guardrail-events`;live:注入 payload→422 硬拦+事件留痕。 +- [x] 前端「审计 & 安全」页 ✅ —— `/audit`(运维组)接 /admin/audit + /admin/guardrail-events:安全事件(护栏拦截/灰区)+ 操作审计(方法配色/状态码/actor/ip/时间)+ 顶栏统计 + 30s 自刷;live 验证。 +- [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S ← T4.B 唯一剩项(可选小项) + +### [ ] T4.C 真实成本计费 +- [ ] token×单价 落库 `token_usage`(现仅 Redis 按天计数 TTL48h,task_handler.go:532)| L +- [ ] 消费聚合 + 账单生成,Pricing 表与真实消费挂钩(现表建了未挂)| —(并入上条) + +### [ ] T4.D AI 核心深化 +- [ ] 记忆 Consolidate 层:盲目 upsert→记忆腐烂,补 Mem0 式 ADD/UPDATE/DELETE 对账(mcp-go/internal/memory/store.go,见 [[memory-upgrade-plan]])| M +- [ ] prompt 灰度% A/B:激活某版按小流量验证再全量(shared/prompts,现激活即全量,见 [T2.3])| M +- [ ] 外部接入做实:`mcp-go/internal/external/apis.go:13` Call 空壳 → provider 路由 + 统一鉴权/重试/限流/审计 | M +- [ ] mcp-py 算法层去桩:文档解析接 PaddleOCR/magic-pdf(mineru.py:11 返回空块)| M +- [ ] 报告原生 PDF(现仅 Word,office/unioffice.go)| M + +### [x] T4.E 编排引擎边角 ✅(5/5 全清) +- [x] Map 节点错误传播 + 汇总 ✅ —— writeSection 返回 (body,err) 仅真·LLM 失败计错(预算/无模型是主动降级不算);writeSections 汇总失败数、失败项 Body 带可见标记;mapNode 全失败→置 b.fatalErr(判 failed 非静默 done-空)、部分失败→trace+流式告警。report handleReport 同步提示部分失败。3 单测(全失败/部分精确计数/mapNode 置 fatalErr)。 +- [ ] 熔断器接回 failover(harness/circuitbreaker.go 与 llm/failover.go 未接合,可能长卡备用)| M +- [x] coordinator 专家超时 ✅ —— specialistTool 加 timeout(默认 specialistTimeout=3min),InvokableRun 包 WithTimeout;超时作为"观察"跳过该专家(err=nil,lead 据其余综合,不中断协调)。2 单测(超时~50ms 跳过 / 正常不受影响)。 +- [x] Branch else 兜底 ✅ —— branchNode 识别 default/else 边,主选择(true/false)未命中时走它;trace 明确区分「走 default / 未匹配收口结束(END) / 正常选路」,杜绝静默落 END 被误当 bug。3 断言单测(default 命中/条件真走 true/无 default 仍空)。compose 层原有 len==0→END 收口保留。 +- [x] DSL 拓扑校验 ✅ —— gateway ParseAndAssemble 加 validateTopology:拦重复/空 id + 悬挂边(source/target 指向不存在节点);对空图/报告任务{topic}/非标准载荷宽松放过不误伤。单测(合法/重复id/空id/悬挂边×2/空端点/空图放过) + live(悬挂边→422,合法→202)。节点-工具映射校验需工具注册表,留后续。 +- [x] 熔断器接回 failover ✅ —— failover 加每模型熔断器(阈值3/冷却20s):主持续失败→熔断→后续请求跳过主直连备用(省重复白试);冷却半开探测回主、成功自动恢复(无需外部通知)。WithTools 重包共享 breakers(状态不清零,关键坑)。harness 加 NewCircuitBreakerWith 参数化构造。3 新单测(熔断跳过/WithTools共享状态/冷却恢复)。**澄清**:原审计"卡在备用"不准——failover 每次都重试主,真问题是宕机时每次白试主,本项修的是这个。 + +### [ ] T4.F 健壮性 / 安全 / 性能收口(多为 S,可穿插着做) +- [ ] 关键 DB 写失败上浮 5xx(现 best-effort 返 200,前端无感,task_handler.go:69 等)| M +- [x] KB 级联删「事务化」✅(2026-07-06)—— 类事务:先删依赖存储(三库/MinIO)、PG 记录**最后删**; + 任一存储删失败→不删 PG、报错可重试(三库删幂等),杜绝「PG 删了却在三库/MinIO 留不可删孤儿」。 + milvus/bleve.deleteDoc + blob.Delete 改返 error;rag.DeleteDoc 三库全试+聚合错误(原只回 Neo4j 的错)。 + **live:杀 mcp-go→删→502「未删除请重试」+文档保留;mcp-go 活→删→200+清空**(清场僵尸进程后验证)。 +- [ ] 预算硬顶兜底(budget.go ≤0 即无限,单任务可无限烧)| M +- [ ] 审批 checkpoint 落盘失败重试(compose_compiler.go:323 现只 log → 任务永卡 waiting)| M +- [~] 安全收口(部分): + - [x] CORS 生产默认收紧 ✅ —— 开发期缺省仍 `*`(便利);生产(APP_ENV=prod/GIN_MODE=release)未显式配 CORS_ALLOW_ORIGIN 则**不发 ACAO 头**(浏览器同源拦截),逼显式配置。 + - [x] 限流按登录用户 ✅ —— RateLimit 键改「已认证按 uid、未认证按 IP」;middleware 顺序 Auth 前置于 RateLimit。live 冒烟通过(登录/认证请求正常,dev CORS 仍 *)。 + - [ ] `X-Session-ID` 防伪 / 开发 JWT 密钥外置 / 输入校验补强 | S(待) +- [ ] 性能:ListModels 去 O(N²)(admin.go:48)、KB/Agent 列表分页、报告分章检索缓存、Milvus 批量 ensure | S~M +- [ ] 可观测:panic 进 trace span + 回写通知、TTFT/token-s/各路检索耗时指标 | M +- [x] 🔴 模型健康/熔断态 surface 到管理端 ✅(2026-07-06)—— harness `CircuitBreaker.Snapshot()` + + `llm.Pool.ModelHealth()`(主备链每模型 provider/model/role/state/fails,同包读 failoverModel.breakers) → + dispatcher 心跳带 `models[]` → gateway `/admin/overview` 独立超时 Ping dispatcher 合并 → admin 概览 + 「模型路由」新增「运行时链路态(实时)」逐模型状态点(🟢在线/🔴熔断中+失败数/🟡半开/单点)。 + 单测(Snapshot/ModelHealth 名字↔态配对) + **live:配坏主→提交任务打熔断→概览 broken-demo 显示「熔断中·失败3」**。 +- [ ] 配置化:切块大小 / history 轮数 / 各并发度 收口为统一可配 | S +- [x] 拆除 `search.Hybrid` 残骸 ✅ —— 该包空转(NewHybrid 返空、Query 返 nil)、构造后存进 Gateway 却从不调用(真 RAG 走 rag.Engine)。删整个 internal/search 包 + gateway/main.go 接线。build/vet 干净。(审计原说"死代码删文件"不准:它是接了线的残骸,需拆接线) + +--- + +## 进度回顾 + +| Tier | 完成 / 总 | +|---|---| +| T0 激活 | 2 / 2 ✅ | +| T1 收口 | T1.1 ✅ / T1.2 进行中 / T1.3 进行中 | +| T2 深化 | 4 / 4 ✅ | +| T3 硬化 ⏸ | 0 / 5 | +| **T4 后端做实** | 0 / 6 组(A–F) | + +> 推进顺序:T0/T1.1/T2 已清。当前在 T1.2/T1.3(前端深化 + 管理端做实)。 +> **后端下一步:T4.B(审计可溯源 + admin/overview)** —— 内聚、全 M、喂管理端、不依赖大重构。 +> 之后按价值挑:T4.D(AI 核心) / T4.E(编排边角) / T4.F(健壮性收口,可穿插) / T4.A(RBAC 大地基) / T4.C(计费)。 diff --git a/EINO_ADOPTION.md b/EINO_ADOPTION.md new file mode 100644 index 0000000..b3774c8 --- /dev/null +++ b/EINO_ADOPTION.md @@ -0,0 +1,202 @@ +# Eino 全面采纳迁移方案 + +> 目标:从"只借 Eino 的类型"演进为 **Eino-native 的 Agent 编排核心**,充分发挥框架的组件 / ADK / 编排 / 可观测能力。 +> 原则:**分阶段、不推倒重写、每步保持现有测试绿、可随时回退**。自研 `graph.go` 解释器演进为 `compose.Graph`,而非盲删。 + +--- + +## 进度 + +- [x] **Phase A · 地基**:`llm.Pool` 换 Eino ChatModel 组件(commit d84b1ec,验收通过) +- [x] **Phase B · 质变**:MCP 工具→`InvokableTool` + ReAct agent(模型自主调工具,验收 7/7 命中) +- [x] **Phase C · 编排归一**:✅ 全图 `DSL→compose.Graph` 编译器(全节点 + branch + DAG 并行调度)+ callbacks→ExecEvent 归一,等价回归通过(EINO_COMPOSE 灰度开关,默认关;过渡期后 graph.go 退役) +- [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM / ✅ HITL 人工审批中断(审批节点暂停→waiting→批准回 running / 拒绝→rejected,NATS 决定回传 + AckWait 续租,全栈含 Studio 审批节点 + 运行抽屉批准条)/ ⬜ 多智能体(按场景) + +**组件化补完(A)**:检索 → `ragRetriever`(`components/retriever.Retriever`,`eino_components.go`);提示词 → `buildMessages` 改用 `prompt.FromMessages`+`MessagesPlaceholder`;工具 → `mcpTool`(`InvokableTool`,模型自主调用)。至此终态架构 8 层中 模型/工具/检索/提示词/编排/智能体(单)/可观测 均已 Eino 组件化;剩 人机交互(中断恢复,按场景)。 +**自主 agent 工具集动态化**:agent 工具集不再硬编码——mcp-go 注册表(单一事实源)每个工具声明 `agent/params/inject`,`list_tools` 上报,dispatcher `agentTools()` 动态发现并建 `InvokableTool`、运行时注入 `user_id/session_id/kb`(不暴露给模型)。**加工具只改 mcp-go 注册表,dispatcher 零改动。** 当前暴露 4 个(wiki_search/recall_user_memory/remember_user_fact/history_get);实测模型自主调用新暴露的 remember_user_fact 成功(参数自生成、user_id 服务端注入)。 +**性能注记**:compose 每任务编译实测 ~13µs(基准 BenchmarkComposeCompile),相对 LLM 秒级可忽略 → 编译图缓存判定为 premature optimization,暂不做;真正的并行效率已由 Phase C 的 DAG 调度(`AllPredecessor`)拿到。 + +--- + +## 0. 现状审计(2026-06-22) + +实际跑在 `sundynix-dispatcher/internal/` 下: + +| 维度 | 现状 | 用没用 Eino | +|---|---|---| +| 消息/流类型 | `schema.Message`、`StreamReader`、`Pipe` 全链路在用 | ✅ 真用(承重) | +| ChatModel | `llm/pool.go` 自研 OpenAI 兼容客户端(手写 SSE 流解析) | ❌ 直连,绕过框架 | +| `eino/model.go` 的 `poolModel` | 实现了 `model.BaseChatModel`,但 `newPoolModel` **从未被调用** | ⚠️ 空接缝 | +| 工具调用 | NATS request-reply(`ToolCall`/`ToolResult`),图里**画死** | ❌ 自研,无函数调用 | +| 编排 | `eino/graph.go` 自研解释器(拓扑/分支真假边/map 并行),有测试 | ❌ 非 `compose` | +| 检索 | `mcp-go` RAG(Milvus/Bleve/Neo4j RRF),经 `wiki_search` 工具 | ❌ 自研 | +| 提示词 | `report.go` / `memory_extract.go` 手拼字符串 | ❌ | +| 可观测 | 自研 `ExecEvent` 轨迹流 + Prometheus + eval 护栏 | ❌(不缺) | +| 人机交互 | 无 | ❌ | + +**结论**:卡在最底层——没有真正的 ChatModel 组件,ADK / 编排都用不起来。采纳必须**自底向上**。 + +### Eino v0.9.9 可用包(已核对本地 module) + +``` +github.com/cloudwego/eino/schema # ✅ 已用 +github.com/cloudwego/eino/components/model # ChatModel 接口 +github.com/cloudwego/eino/components/tool # Tool 接口 +github.com/cloudwego/eino/components/retriever# Retriever 接口 +github.com/cloudwego/eino/components/prompt # ChatTemplate +github.com/cloudwego/eino/compose # Graph / Workflow / branch / tool_node / lambda +github.com/cloudwego/eino/flow/agent/react # ReAct agent +github.com/cloudwego/eino/flow/agent/multiagent +github.com/cloudwego/eino/adk # Agent Development Kit(agent_tool / react / callback) +github.com/cloudwego/eino/callbacks # 回调/可观测 +github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(openai 等),尚未拉取,Phase A 新增 +``` + +--- + +## 终态架构 + +| 层 | 现在 | 终态 | +|---|---|---| +| 模型 | 自研 `llm.Pool` | `eino-ext` openai 组件 + 我们的热更新/熔断包一层 | +| 工具 | NATS `CallTool`,画死 | 每个 MCP 工具包成 `components/tool.InvokableTool`,`BindTools` 给模型动态选 | +| 检索 | `wiki_search` 工具 | `mcp-go` RAG 包成 `components/retriever.Retriever` | +| 提示词 | 手拼 | `components/prompt.ChatTemplate` | +| 编排 | 自研 `graph.go` | `compose.Graph`(流式 reduce/branch + 类型化边) | +| 智能体 | 无 | `flow/agent/react` 或 `adk` 的 ReAct agent;后续多智能体 | +| 可观测 | ExecEvent + Prom | `callbacks` 桥接到现有 ExecEvent,统一 | +| 人机交互 | 无 | 中断/恢复(审批场景,需 checkpoint store) | + +--- + +## Phase A · 地基:接真 ChatModel 组件 〔P0〕✅ 已完成(commit d84b1ec) + +> 落地:`llm.Pool` 内部已换成 `eino-ext/openai` 的 ChatModel(`ChatStream→Stream`、`Chat→Generate`),对外签名不变;`go get eino-ext/components/model/openai v0.1.13`;真实链路验证通过(提交任务流式出答复 + eval 1.00)。`poolModel` 接缝保留给 B/C。 + +**目标**:模型调用走 Eino 官方 ChatModel 组件,接上空着的 `poolModel` 接缝;行为对现有用例不变。 + +**依赖**:`go get github.com/cloudwego/eino-ext/components/model/openai`(确认与 eino v0.9.9 兼容版本)。 + +**改动**: +- `sundynix-dispatcher/internal/llm/pool.go` + - 内部把手写 HTTP/SSE 换成 eino openai 组件作为 backend;`SetConfig` 时用激活配置 `New` 一个 ChatModel 实例(热更新 = 重建实例,加读写锁)。 + - 保留对外签名:`Chat` / `ChatStream` / `StreamText` / `Ready` / `ModelName`,让 `graph.go`/`report.go`/`memory_extract.go` **零改动**。 + - 熔断器(`harness.CircuitBreaker`)仍包在 `Pool` 这层。 +- `sundynix-dispatcher/internal/eino/model.go` + - 让 `poolModel` 真正包住组件后的 Pool(或直接暴露底层 `model.BaseChatModel`),供 Phase B/C 用。 + +**验收**: +- `make test-go`(含 `eino/graph_test.go`、`integration_test.go`、`memory_extract_test.go`)全绿。 +- 桌面端跑一次编排 / 一次报告 / 触发一次记忆 consolidate,输出与现状一致。 +- 「服务状态」面板 dispatcher 仍显示模型名 + 运行时长。 + +**风险/回退**:低。出问题回退到手写客户端(保留旧代码一版,绿后再删)。 + +--- + +## Phase B · 质变:函数调用 + ADK 单智能体 〔P1〕✅ 已完成 + +> 落地:新增 `eino/react_agent.go`——`mcpTool` 把 MCP 工具(NATS)适配成 `tool.InvokableTool`;`agent` 节点带 `autonomous:true` 时走 `flow/agent/react`(首批工具:`wiki_search`、`recall_user_memory`,context 参数 user_id 服务端注入、不暴露给模型)。验收:自主工具调用 **7/7 命中**,args={} 证明注入生效,完整闭环(模型→tool→MCP→观察→答复)。 +> +> **两个工程发现**: +> 1. **`StreamToolCallChecker` 必须扫整段流**:默认只看首个流片段,deepseek 常先吐文本再给 tool call → 漏判致不调工具。已用 `streamHasToolCall` 扫全流修复(命中率 ~0 → 7/7)。 +> 2. **eval 看不到工具结果 → 误判**:LLM 评委只见 query+answer,agent 用召回记忆作答会被判"虚构"。Phase C 用 callbacks 把工具上下文喂给 eval 修正。 + +**目标**:模型能**自主选择并调用** MCP 工具(ReAct),而非只跑画死的工具节点。这是"工作流执行器 → Agent 平台"的关键一跳。 + +**改动**: +- 新增 `sundynix-dispatcher/internal/eino/tools.go` + - 把每个 MCP 工具包成 `components/tool.InvokableTool` 适配器:`InvokableRun` 内部走 `subscriber.CallTool(ToolSubjectGo/Py, ToolCall)`。 + - 工具 schema(参数)从哪来:① 先手写少量核心工具的 JSON Schema;② 后续让 MCP `list_tools` 连参数 schema 一起上报(已有中文名/作用,扩字段即可)。 +- 新增 `agent` 节点类型 + - `dsl/compile.go` 识别 `kind=="agent"` 且开启"自主工具"开关 → 走 ReAct(`flow/agent/react` 或 `adk`)。 + - agent 绑定:ChatModel(Phase A)+ 一组 InvokableTool(按节点配置或 owner 可用工具集)。 +- `graph.go`:`agent` 节点分支委托给 ReAct runner,复用现有 ExecEvent 回流。 + +**验收**: +- 画一个「agent + 若干工具」的图,给一句需要检索/记忆的提问,观测**模型自己决定调了哪个 MCP 工具**(ExecEvent 里出现 tool 调用轨迹)。 +- 旧的静态工具流不受影响。 + +**风险**:中。ReAct 多轮会放大 token 成本与时延 → 设最大步数 + 复用熔断器;工具 schema 不准会导致乱调 → 先小工具集灰度。 + +--- + +## Phase C · 编排归一:迁到 compose.Graph 〔P2〕✅ 已完成(并存灰度,等价回归通过) + +> 全图编译器:`compose_compiler.go` 把整张 DSL 图编译为 `compose.Graph`—— +> - 每个节点 = 一个 Lambda,节点体复用现有逻辑(`execDSLNode`:input/memory/retriever/tool/agent/aggregate/render/map/output),黑板进 compose 本地状态(`WithGenLocalState` + `ProcessState`)。 +> - branch = `AddBranch` + 状态感知条件(复用 `branchNode` 选路);边载荷用空 `flowSignal`(注册 no-op 合并支持 fan-in),真实数据全走黑板。 +> - `WithNodeTriggerMode(AllPredecessor)` DAG 模式:无依赖节点并行调度(效率)。 +> - `Handle → executeGraph` 按 `EINO_COMPOSE` 开关选 compose / graph.go;compose 编译失败自动降级回 graph.go(安全网)。 +> - **等价回归**:线性图、分支图经解释器与 compose 两路径产出逐字一致(单测);live 多节点分支图 compose 路径 2800 字答复 eval 1.00、FSM done、0 幽灵。 +> +> 待过渡期 soak 后把默认翻到 compose、退役 graph.go。**性能后续**:编译图按 DSL-hash 缓存(当前每任务编译一次)。 + + + +> 落地(并存+等价回归策略):对话主流程已可跑在 `compose.Graph` 上—— +> - `compose_graph.go`:`runConversation` 按 `EINO_COMPOSE` 开关分流;`runComposeConversation` 建图 `START→ChatModel→END`、`Compile`→`Stream`,token 回流;模型未就绪/编译失败降级回 `runAgent`。**默认关,graph.go 仍是默认且权威。** +> - `compose_callbacks.go`:`composeTracer` 用 `utils/callbacks` 把 ChatModel/Tool 的 start/end/error 桥到 ExecEvent(可观测归一)。 +> - 测试:compose 图编译+运行、compose 对话流式回流、开关关→走 runAgent,三个单测;live 实测 compose 路径出 54 字答复 + eval 1.00,默认路径 eval 1.00。 +> - **顺带修真 bug**:`SubjectTaskStatus` 原为 `sundynix.tasks.status`,落在任务流捕获通配 `sundynix.tasks.>` 内 → 状态事件被当成"幽灵任务"自我放大(实测污染 2300+ 条)。已挪到 `sundynix.status.task` + dispatcher 加空任务护栏。 +> +> 剩余:branch / map / render / retriever / prompt 等节点逐步迁 compose(同并存+等价回归),对齐后 graph.go 退役。 + + + +**目标**:自研解释器退役,DSL 图编译为 `compose.Graph`,吃到流式 reduce/branch、类型化边、自动并发、callbacks。 + +**改动**: +- `sundynix-dispatcher/internal/dsl/compile.go`(兑现文件头那句 TODO:"演进为 compose.NewGraph 的完整多节点编译") + - 写 `DSL Flow → compose.Graph` 编译器:节点映射为 ChatModel / ToolNode / Retriever / `Lambda`;连线 → 边;branch 真假 → `compose` 分支;map → 并发分支。 +- `callbacks`:实现 handler 把 Eino 回调桥接到现有 `ExecEvent`(节点 start/end/error + 耗时),统一可观测,不重复造。 +- `report.go`:报告多步流水线改用 compose(章节并行 + 汇聚天然契合)。 +- 检索节点 → `components/retriever`(包 mcp-go RAG);提示词 → `components/prompt.ChatTemplate`。 +- **对齐策略**:compose 版与 graph.go 版**并存**,用 `graph_test.go`/`integration_test.go` 做等价回归;全绿且灰度通过后,`graph.go` 才退役。 + +**验收**:分支路由、map 并行、报告流水线在 compose 版下与旧版**输出等价**;ExecEvent 轨迹不丢。 + +**风险**:高(动编排核心)。靠"并存 + 等价测试 + 灰度"控制,绝不一刀切。 + +--- + +## Phase D · 状态化执行 〔later,按场景〕 + +主题:把"执行"从一次性 DAG 升级为**可持久化、可恢复的状态机**。三件事同一条线,一起做。 + +- **任务生命周期 FSM** ✅ 已完成:`submitted → running → done / failed / timeout` 显式状态机。 + - dispatcher 经新主题 `sundynix.tasks.status` 回写(`TaskStatusEvent`):进入执行→running、收尾→done/failed、整体超时上限 `taskExecTimeout=3min`→timeout;网关 `SubscribeTaskStatus` 落 PG(`Task.Status/Detail`)。 + - UI 轮询:`GET /api/v1/tasks/:id` 返回 `{status, detail}`。 + - 验收:submitted→running→done 实测流转、PG 持久化、3min 超时兜底——根治"卡运行中看不出来"。 + - 桌面端轮询接线仍可补(当前后端 + 端点已就绪)。 +- **中断/恢复(HITL)**:审批型工业流程(生成中途人工确认)。需 checkpoint 持久化(PG/Redis)。等有具体审批用例再做。 +- **多智能体协同**(`flow/agent/multiagent`):出现真实多角色编排需求时再上,现在无用例。 + +--- + +## 暂时不做 + +- **Ollama 官方组件**:与"开发期不拉本地 Ollama"策略冲突(见记忆 llm-provider-strategy)。 +- **拿 callbacks 当唯一理由迁移**:已有 ExecEvent + Prometheus + eval 护栏,callbacks 仅在 Phase C 顺带桥接。 + +--- + +## 落地顺序与依赖 + +``` +A(ChatModel 组件)── 地基,解锁全部 + └─> B(Tool 适配 + ADK ReAct)── 质变:模型自主调工具 + └─> C(compose 编排 + callbacks + retriever/prompt)── 编排归一 + └─> D(状态化执行:任务生命周期 FSM / HITL 中断恢复 / 多智能体)── 按场景 +``` + +逐项实现,每 Phase 一个(或多个)提交,跑 `make test-go` + 桌面端冒烟后再进下一阶段。 + +--- + +## 总验收/回归基线(每阶段都要过) + +1. `make test-go`:shared / gateway / dispatcher / mcp-go 全绿。 +2. 桌面端冒烟:编排运行(含分支/并行)、报告生成+导出、记忆 consolidate、知识库检索。 +3. 管理端「服务状态」:四服务 + 五基建 + 工具注册正常。 +4. 行为等价:迁移前后同输入输出一致(Phase C 重点)。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20e4677 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +专有软件许可 / Proprietary License + +Copyright (c) 2026 sundynix-agentix. 保留所有权利 / All Rights Reserved. + +本软件及其源代码、文档、构建产物(统称“本软件”)为版权所有者的专有财产。 +未经版权所有者事先书面授权,任何个人或实体不得复制、修改、合并、发布、分发、 +再许可、出售本软件,或将其用于商业或生产用途。 + +获授权方(如签署商业许可/独立部署协议者)的使用范围、期限与限制,以双方另行 +签署的书面协议为准;本文件不授予任何此类协议之外的权利。 + +本软件按“现状”提供,不附任何明示或默示担保,包括但不限于适销性、特定用途 +适用性与不侵权的担保。版权所有者不对任何索赔、损害或其他责任负责。 + +--- + +This software is proprietary. No rights are granted except under a separate +written commercial/deployment agreement with the copyright holder. + +注:授权模型(专有 / 源码可见 BSL / 双授权等)为商业决策,可按需替换本文件。 diff --git a/LOAD_TEST_REPORT.md b/LOAD_TEST_REPORT.md new file mode 100644 index 0000000..4776523 --- /dev/null +++ b/LOAD_TEST_REPORT.md @@ -0,0 +1,125 @@ +# sundynix-agentix 容量压测报告(首版) + +> 日期:2026-06-26 | 环境:单机本地(macOS / Docker 基建)| 撰写:Opus 4.8(基于本次实测,刻意压低水分) +> 部署相关讨论留待开发完成后另议;本报告只回答一个问题:**平台自身扛得住多少、瓶颈在哪。** + +--- + +## 1. 目的 + +回答两个一直悬而未决的问题: + +1. 平台(去掉 LLM 推理本身)的吞吐天花板是多少?单任务固有开销多大? +2. 把系统拆成「网关 / 总线 / 调度 / 工具」多服务,到底有没有意义? + +--- + +## 2. 方法 + +- **加压工具**:`sundynix-dispatcher/cmd/loadtest`。闭环加压(N 个 worker 不停 `submit → 等收尾 → 再来`), + 阶梯提升并发,每档跑 10s。 +- **完成判定**:经 **SSE token 流**读到收尾(而非轮询状态)。 + > 早期版本用 25ms 轮询,结果把"轮询放大"自身测成了瓶颈(海量 status 请求压垮网关); + > 改用流后每任务仅 1 条推送连接,量到的才是真实任务管线吞吐。 +- **隔离 LLM**:dispatcher 开 `LLM_FORCE_STUB=1` + `LLM_STUB_TTFT_MS=0` + `LLM_STUB_INTERTOKEN_MS=0`, + 绕开真实模型推理与计费,**只压全链路 plumbing**: + `网关 HTTP → NATS/JetStream → 调度消费 → 图执行 → Token 回流(SSE) → PG 状态写`。 +- **配置**:单 dispatcher 实例,`DISPATCHER_CONCURRENCY=64`,网关放开限流(`RATE_LIMIT_PER_MIN`)。 + +--- + +## 3. 实测数据 + +| 并发 | 吞吐 (任务/s) | p50 | p95 | max | 说明 | +|---:|---:|---:|---:|---:|---| +| 1 | 23 | 42ms | 46ms | 54ms | **单任务纯平台开销 ≈ 42ms** | +| 2 | 37 | 53ms | 56ms | 70ms | | +| 4 | 54 | 74ms | 77ms | 83ms | | +| 8 | 76 | 103ms | 115ms | 157ms | | +| 16 | 96 | 161ms | 176ms | 257ms | | +| 32 | **112** | 269ms | 345ms | 402ms | **吞吐峰值** | +| 64 | 112 | 557ms | 676ms | 707ms | 饱和:延迟翻倍、吞吐不增 | +| 128 | 102 | 1173ms | 1590ms | 1629ms | 过载,优雅降速(未崩)| +| 256 | 0 | — | — | — | 硬崩(见 §4.3)| + +吞吐随并发上升到 ~32 后见顶(~110/s),之后增并发只增延迟不增吞吐 —— 典型饱和曲线。 + +--- + +## 4. 分析 + +### 4.1 单节点平台天花板 ≈ 110 全链路任务/秒 +单任务固有开销 ~42ms,由这些串起来:网关收请求 → JetStream 持久化发布(含 fsync)→ 调度消费 → +建图执行 → Token 经 core NATS 回流 + Redis 录制 → 3 次 PG 状态写(submitted / running / done)。 + +### 4.2 瓶颈不是数据库连接数,是「每任务多跳」 +把 PG 连接池从 25 调到 80,吞吐**纹丝不动**(仍 ~110/s)。说明瓶颈不在某一处资源,而是 +每个任务要走的**一长串同步跳数**(JetStream 落盘 + 多次 NATS 回写 + Redis 录制 + 多次 PG 写)的累加成本。 +要把单节点吞吐再往上抬,得减少每任务的同步跳数(如状态写批量化/异步化),而非简单加资源。 + +### 4.3 C=256 的「停摆」:单节点饱和,非 bug +现象:C=256 在 10s 窗口内 0 完成。 + +初判曾归因于 DSN 用 `localhost` 致 pgx 每连接 DNS 解析、极端并发下 DNS 查询被取消。 +**已把 DSN 改为 `127.0.0.1`**(确实消除了 DNS 那一层 + 每连接解析开销,是值得的卫生改进), +但复测 C=256 仍 0 完成 —— 错误从 `lookup localhost` 变成 `dial tcp 127.0.0.1:5432: operation was canceled`。 + +**真实根因**:单节点在 256 并发下整体饱和 —— 任务排队 + PG 状态写路径(每任务 3 写、池 25)+ 网关 +handler/连接 顶不住,单任务延迟超过测试窗口,故窗口内零完成(那些 "canceled" 是窗口到点取消在途请求的尾部噪声)。 +**这不是要修的 bug,是单节点容量上限的表现**:C≤128 优雅降速可用,256 已远超单 dispatcher 的 ~110/s 甜区。 +**正解是横向扩**(加 dispatcher 副本,队列组自动分摊)——正好印证 §4.4 的判断。 +单节点要再抬高,则需状态写批量/异步化(降低每任务的同步 PG 跳数)。 + +### 4.4 核心判断:平台不是瓶颈,GPU 才是 +- 平台固有开销 **~42ms / 任务**。 +- 真实 LLM 出一轮答案(哪怕自部署 Qwen 32B)是 **秒级**(几百 ms ~ 数秒)。 +- 即 **平台开销比 LLM 推理小 1~2 个数量级**,淹没在模型延迟里、可忽略。 + +→ 一个 dispatcher 副本就能喂 ~110 turns/s 的调度能力;而单张 GPU 跑 32B 大约出 +**几十 turns/s**。**平台调度能力 > 单 GPU 产能**,意味着: +- 平台永远不会先于 GPU 成为瓶颈; +- 要提总吞吐,正确做法是**加 dispatcher 副本 + 加 GPU**(NATS 队列组自动负载均衡),而非优化平台代码。 + +--- + +## 5. 架构 / 服务拆分是否成立?—— 成立 + +本次压测正面验证了"拆服务"的设计前提: + +| 设计选择 | 压测给出的判断 | +|---|---| +| 网关 / 调度 / 工具 拆成独立服务 | ✅ 成立。各层可独立横向扩;调度是无状态消费者,加副本即扩吞吐 | +| NATS 队列组做负载均衡 | ✅ 成立。这是"加副本即扩容"的基础,且天花板(110/s)远高于单 GPU 产能 | +| 调度与工具用消息总线解耦 | ✅ 成立。42ms 的总线开销相对 LLM 秒级延迟可忽略,换来的解耦/弹性是划算的 | +| dispatcher 不持有 DB、状态经总线回网关落库 | ✅ 成立。让调度保持无状态、可水平复制 | + +**一句话**:对"喂满自部署 GPU 集群、按 LLM 产能横向扩"这个生产目标,**当前拆分是对的**。 +总线那 ~42ms 的代价,买到了无状态、可水平扩、故障隔离 —— 在 LLM 秒级延迟面前完全值。 + +### 但有三个要正视的点(不是"拆错了",是"拆了之后要补齐的") +1. **网关目前是单点且偏重**(HTTP + 每任务一个 Token 录制 goroutine + 多个订阅者)。横向扩/HA 还没做(P1)。 +2. **PG 是全局共享瓶颈**:每任务 3 次状态写。规模再上一个量级时,状态写需要批量/异步化。 +3. **单机数字 ≠ 集群数字**:本报告是单节点天花板;多副本下还需验证 NATS 集群、PG 连接总量、跨副本一致性。 + +--- + +## 6. 待补(后续) +- ✅ DSN `localhost → 127.0.0.1`(已修,消除每连接 DNS 解析;但不解决 §4.3 的单节点饱和)。 +- 长稳压测(小时级):看内存 / 文件句柄 / goroutine 是否泄漏。 +- 真实 LLM 端到端容量:受在线 provider 限流,需自部署模型后再测。 +- 状态写批量/异步化探索(抬高单节点天花板)。 +- 多副本 + NATS 集群下的容量复测(验证横向扩是否线性)。 + +--- + +## 复现 + +```bash +# dispatcher 以 stub 模式起(绕开 LLM),网关放开限流 +LLM_FORCE_STUB=1 LLM_STUB_TTFT_MS=0 LLM_STUB_INTERTOKEN_MS=0 DISPATCHER_CONCURRENCY=64 ./sdx-dispatcher +RATE_LIMIT_PER_MIN=1000000 ./sdx-gateway + +# 加压(阶梯并发,每档 10s) +go run ./cmd/loadtest -url http://localhost:8080 \ + -email -pass -levels 1,2,4,8,16,32,64,128,256 -dur 10s +``` diff --git a/MULTI_AGENT.md b/MULTI_AGENT.md new file mode 100644 index 0000000..c00d682 --- /dev/null +++ b/MULTI_AGENT.md @@ -0,0 +1,77 @@ +# 多智能体方案(Eino × Anthropic 融合) + +> 现状:单 agent 自主调工具(ReAct)已落地;多 agent 静态接力(DSL 图)已落地。 +> 缺的是 **LLM 自主在 agent 之间路由/委派** —— 即"多智能体协同"。本方案是它的落地设计。 + +## 设计原则:Eino 出机器,Anthropic 出脑子 + +| 层 | 谁的强项 | 怎么用 | +|---|---|---| +| 运行时机器 | **Eino** | `react.Agent` + ToolsNode(并行工具调用)+ 流式 + 预算/熔断 + callbacks 观测 + checkpoint | +| 编排认知 | **Anthropic** | lead 的"分解→定额→写定制简报→综合"全在**提示词**;worker 返回**精炼结论**而非原始堆料 | +| 闭环纪律 | 两者皆有现成 | evaluator-optimizer = 复用 harness **低分自动纠偏**;成本天花板 = 复用**预算护栏** | + +**关键判断:用 agent-as-tool,不用 `host.NewMultiAgent`。** +Eino 的 host 模式里,specialist 拿到的是**原始输入**(preHandler `return state.msgs`),host 的工具参数只是路由理由、不传给专家当任务。而 Anthropic 多智能体的精髓正是 **lead 给每个 worker 写定制简报**。host 表达不了,故用 **agent-as-tool**:orchestrator 自己 emit 工具调用,参数 `brief` 就是它写的简报。host 留作"无简报简化版"备选。 + +> 业界对照:本方案 = OpenAI Agents SDK 的 `agent.as_tool()` + handoff 思路,= Anthropic 研究系统的 orchestrator-worker。心智模型与头部项目通用、可迁移。 + +## v1 架构 + +``` +coordinator 节点 + └─ orchestrator = react.Agent(lead 提示词 = Anthropic 配方) + ├─ tool: specialist_A (= 包成工具的子 react.Agent / ChatModel) + ├─ tool: specialist_B + └─ (可选) MCP 工具 + 流程:lead 分解任务 → 给每个专家写 brief → 并行派发(并行 tool-call) + → 收齐精炼结论 → lead 在最后一轮综合成稿 +``` + +- **并行**:orchestrator 一轮 emit 多个 tool-call → Eino ToolsNode 并发跑专家(Anthropic 的并行 breadth)。 +- **综合**:ReAct 天然在最后一轮综合,不需要单独 summarizer。 +- **上下文隔离**:每个专家是独立 react.Agent、独立消息上下文(原生)。 +- **定制简报**:专家工具入参 `brief` = lead 写给它的子任务/期望输出/边界。 +- **精炼返回**:专家系统提示词追加"只回结论要点,不堆原文",省 orchestrator 综合时的上下文。 + +## 与现有 harness 的融合点(零/少新代码) + +- **evaluator-optimizer**:协调者综合稿照常进 harness 评测 + 低分自动纠偏 —— 已有。 +- **成本天花板**:协调者 + 专家 I/O 共享 `harness.Budget`,触顶中止 —— 已有,专家 I/O 在 agent-as-tool 边界计入。 +- **观测**:每次派发落一条 `kind=agent` exec 事件(brief + 精炼结论)+ OTel 嵌套 span。 + +## 落地增量 + +### 增量 1 —— 后端核心(dispatcher/internal/eino/coordinator.go)✅ 本次 +- `specialistTool`:react.Agent / ChatModel 包成 InvokableTool,入参 `brief`,精炼返回。 +- `parseSpecialists` / `buildSpecialists`:从节点 config 的 `agents[]` 建专家工具集(带工具→react;不带→ChatModel;MCP 工具按 `spec.tools` 过滤)。 +- `runCoordinator`:orchestrator = react.Agent(lead 提示词),双路接入 `execDSLNode`(compose)+ `runGraph`(graph.go)的 `case "coordinator"`。 +- 护栏:禁套娃(专家是内联叶子,不能再是 coordinator)/ MaxStep / 共享 Budget / 降级(无 ToolCallingModel 或 0 专家 → 退 `runAgent`)。 +- `streamAgentReply`:抽出 runReactAgent 与 runCoordinator 共用的流式回流尾段。 + +### 增量 2 —— 前端(desktop Studio) +- nodeCatalog 加 `coordinator` 节点 + `agentList` 字段(子智能体卡片:名称/用途/系统提示词/工具多选)。 +- 运行轨迹:专家调用渲染成可展开子节点(显示 brief + 精炼结论)。 + +### 增量 3(可选,按场景)—— handoff / 可中断多智能体 +- 真需要"专家间多轮协商/控制权移交"才上:迁 `adk`(`SetSubAgents` + `Transfer`),复用 HITL 的 checkpoint 做可中断恢复。不推翻 v1。 + +## 开放决策 + +| 决策 | v1 取舍 | +|---|---| +| 专家定义 | 内联(写在协调者节点);注册表(跨节点复用)留 v1.5 | +| Summarizer | 不单设,ReAct orchestrator 末轮自综合 | +| 专家带工具 | 支持但可选(无 tools = 纯对话专家,有 tools = react 子 agent) | + +## DSL 形态 + +```json +{ "id":"coord", "kind":"coordinator", "config":{ + "system":"任务背景(可选)", + "agents":[ + {"name":"legal", "use":"法律条款/合规问题", "system":"你是法律专家…", "tools":["wiki_search"]}, + {"name":"finance", "use":"财务/数字测算", "system":"你是财务专家…"} + ] +}} +``` diff --git a/Makefile b/Makefile index edc28a1..73b8419 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: infra infra-down devnats demo e2e test test-go test-web test-py gateway dispatcher mcp-go mcp-py mcp-py-setup web admin desktop desktop-build tidy +.PHONY: infra infra-down devnats demo e2e test test-go test-web test-py gateway dispatcher mcp-go mcp-py mcp-py-setup web admin webface desktop desktop-build tidy infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j) docker compose up -d @@ -54,11 +54,14 @@ web: ## 桌面端前端 dev(Vite :5173,连本地 Gateway:8080) admin: ## 运维控制台 dev(Vite :5174,配置 LLM 模型等控制面) cd sundynix-admin && npm install && npm run dev -desktop: ## 真实桌面端开发模式(Wails 原生窗口 + 热重载;需先 make infra + 后端 + 配模型) - cd sundynix-desktop && GOWORK=off wails dev +webface: ## 薄 Web 面 dev(Vite :5175,租户自助:注册/组织/团队/账单) + cd sundynix-web && npm install && npm run dev -desktop-build: ## 打包桌面端为原生应用(产物在 sundynix-desktop/build/bin/) - cd sundynix-desktop && GOWORK=off wails build +desktop: ## 真实桌面端开发模式(Wails 原生窗口 + 热重载;需先 make infra + 后端 + 配模型) + cd sundynix-desktop && GOWORK=off wails3 dev + +desktop-build: ## 打包桌面端为原生 .app(产物在 sundynix-desktop/bin/) + cd sundynix-desktop && GOWORK=off wails3 package tidy: cd sundynix-gateway && go mod tidy diff --git a/PROGRESS.md b/PROGRESS.md index 167a945..af464f5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -8,6 +8,9 @@ ## 第 1 层 · CLIENT(sundynix-desktop) + + + - [x] React 19 + TypeScript + Tailwind 工业级 UI(🟡 自建 UI primitives,未用 shadcn —— 与架构图有偏差) - [x] React Flow 编排画布 + JSON DSL 导出(含 branch 真/假边手柄) - [x] Wails 本地 Go 运行时 + TS/Go 强绑定 + 本地文件 I/O(另存为 / 系统打开 / 系统通知) @@ -57,7 +60,8 @@ - [x] 会话历史写回 - [x] Harness 熔断降级中心(真三态状态机 Closed/Open/HalfOpen + 单测含 -race;熔断时回流提示并收尾流,不静默丢弃) - [x] Harness LLM 自动化评测(规则检查 + LLM-as-judge,异步 off 热路径评分记录 + 单测) -- [x] 长期偏好记忆抽取(writeback 异步:LLM 抽取 → 解析画像去重 → memory_upsert;纯逻辑单测) +- [x] 长期记忆 P1:异步攒批 Consolidate(每3轮1次 LLM 对账→ADD/UPDATE/DELETE/NOOP)+ 软删 + Profile 加 importance/last_seen(为 Generative Agents 打分铺路);纯逻辑+store集成单测 +- [x] 长期记忆 P2:读路径按 Score(Recency+Importance) 排序+衰减+截断top30(rank单测);桌面端记忆面板可看/改/删(软删);memory_list/delete 工具+端点;🟡 Relevance(Milvus)待P3 ## 第 5 层 · MCP TOOLS diff --git a/README.md b/README.md index 4263ae2..c76a84d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,18 @@ ## 快速开始 +### 生产 / 独立部署 — 一条命令拉起整套(应用 + 基建) + +```bash +cp .env.example .env # 按提示填密钥(openssl rand -base64 32) +docker compose -f docker-compose.prod.yml up -d --build +# 控制台 http://:80 | 网关 http://:8080 | 链路 UI http://:16686 +``` + +应用全部容器化(gateway / dispatcher / mcp-go / mcp-py / admin-nginx),经服务名互连, +基建端口不对外;模型 API key 部署后在控制台配(加密存库)。详见 **[DEPLOY.md](DEPLOY.md)**、 +备份恢复见 `scripts/backup.sh`。 + ### 无 Docker — 一键验证任务流(推荐先跑这个) ```bash @@ -43,7 +55,7 @@ SSE 客户端: event:token data:已 event:token data:编 ... event:done ← ### 完整环境(git clone 后零配置直跑) **前置**:Docker、Go ≥1.24、Node ≥20、Python ≥3.11。 -跑**真实桌面端**还需:Wails CLI(`go install github.com/wailsapp/wails/v2/cmd/wails@latest`);macOS 再装 Xcode 命令行工具(`xcode-select --install`,webkit 编译用)。 +跑**真实桌面端**还需:Wails v3 CLI(`go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117`,版本须与 `sundynix-desktop/go.mod` 一致);macOS 再装 Xcode 命令行工具(`xcode-select --install`,webkit 编译用)。 > 各服务默认配置已对齐 docker-compose(NATS:4222 / PG sundynix\@5432 / Redis:6379 / > Milvus:19530 / Neo4j neo4j-sundynix\@7687),**clone 后无需改任何配置**即可连上。 @@ -55,6 +67,7 @@ make dispatcher make mcp-go make mcp-py # 首次自动建 venv + 装解析依赖 make admin # 3) 运维控制台 (Vite :5174) +make webface # 3b) 薄 Web 面:租户自助注册/组织/团队/账单 (Vite :5175) ``` 4) 打开运维控制台(`:5174`),在「模型 / 数据源」登记并激活:**对话模型(chat)** 与 **Embedding 模型** diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..2fe44d8 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,65 @@ +# 发版清单(桌面端 Release) + +打 `vX.Y.Z` 标签即触发 [`.github/workflows/release.yml`](.github/workflows/release.yml): +GitHub 用 macOS/Windows runner `wails3` 出包并发布到 **Releases**(mac 出 universal `.app` 的 zip、Windows 出 `.exe`); +旧版用户的 App 启动时查 `releases/latest`,发现新版即弹横幅提示下载 +(逻辑见 [`version.ts`](sundynix-desktop/frontend/src/lib/version.ts))。 + +> 版本号一律用语义化 `主.次.补`(如 `0.1.1`);git tag 带 `v` 前缀(`v0.1.1`),App 内不带。 + +--- + +## 一、发版前:改版本号(必须三处对齐) + +| 文件 | 字段 | 作用 | +|---|---|---| +| `sundynix-desktop/frontend/src/lib/version.ts` | `APP_VERSION` | **功能性**:App 拿它和 GitHub 最新版比,决定是否提示更新 | +| `sundynix-desktop/frontend/package.json` | `version` | 前端包版本(保持一致,便于追溯) | +| `sundynix-desktop/build/config.yml` | `info.version` | 原生包元数据版本(macOS `Info.plist` 的 `CFBundleShortVersionString` 由它生成) | + +> ⚠️ 改完 `build/config.yml` 必须跑 `wails3 task common:update:build-assets` 重新生成 +> `build/darwin/Info.plist` 等资产,**光改 config.yml 不生效**——生成物是提交进仓库的。 +> (这坑踩过:config.yml 填好了但没重新生成,`.app` 里还是脚手架模板值,直接起不来。) + +⚠️ **`APP_VERSION` 必须等于即将打的 tag(去掉 v)**,否则更新提示会错乱: +新包内嵌的 APP_VERSION = 新版本;旧用户 App 内是旧版本,比对才会提示。 + +## 二、发版步骤 + +```bash +# 1. 改完上面三处版本号,提交 +git add -A && git commit -m "release: v0.1.1" + +# 2. 先 push 代码(release 从被打标签的提交构建;同时触发 CI 回归) +git push origin main # 或当前分支 dev + +# 3. 打标签 + push 标签 → 触发 release 构建 +git tag -a v0.1.1 -m "v0.1.1:<一句话变更>" +git push origin v0.1.1 +``` + +## 三、验证 + +1. GitHub → **Actions** → 看 `Release` 工作流 macOS / Windows 两个 job 是否绿。 +2. GitHub → **Releases** → 确认 `v0.1.1` 下有两个资产: + - `sundynix_desktop_macos_universal.zip` + - `sundynix_desktop_windows_amd64.exe` +3. 用一台装了**旧版**的机器打开 App → 顶部应出现「新版本 v0.1.1 可用」横幅。 + +## 四、出错回滚 / 重发 + +```bash +# 删标签(本地 + 远程),改完重打 +git tag -d v0.1.1 +git push origin :refs/tags/v0.1.1 +# 修复后重新执行「二、发版步骤」 +``` + +## 五、注意事项 + +- **仓库需 public**:Release 资产要让终端用户直接下载;private 仓库的资产下载需 token,不适合分发。 +- **代码签名未做**(待办): + - macOS 未公证 → 用户首次打开被 Gatekeeper 拦,需「右键 → 打开」或系统设置放行。正式分发需 Apple 开发者证书 + `notarize`。 + - Windows 未签名 → SmartScreen 警告。需代码签名证书。 +- **GitHub API 限流**:更新检查未认证 60 次/小时/IP,按"每用户启动查一次"完全够用。 +- CI(`ci.yml`)在 push 到 `main`/`dev` 或 PR 时自动跑回归;Release 仅在 push `v*` 标签时触发。 diff --git a/SAAS_DESIGN.md b/SAAS_DESIGN.md new file mode 100644 index 0000000..cd5b39a --- /dev/null +++ b/SAAS_DESIGN.md @@ -0,0 +1,156 @@ +# sundynix-agentix · 多租户 SaaS 化设计(收敛版) + +> 版本:v1 | 日期:2026-07-06 +> 前提(已确认):**① 目标是真·多租户商业化 ② 桌面端仍是主产品**(不做 Web-first 重写) +> 基线:`ARCHITECTURE_DESIGN.md`(当前架构)+ `ARCHITECTURE_REVIEW.md`(评审) +> 立场:这是对 `sundynix-agentix-saas-refactor-plan.md`(GPT 方案)的**收敛替代**——保留其正确诊断, +> 砍掉与本项目阶段/定位不符的过度设计。原则:**保架构、按需求拉动、每步可回退,不一次大重构。** + +--- + +## 0. 一句话设计 + +**不改架构骨架,只往里穿一条"多租户 + 计费"的脊柱。** 桌面端保持主产品、变租户感知;加一个很薄的 +Web 面做注册/计费/团队(桌面端不适合放这些)。后端按"地基→用量→enforcement→支付"分阶段长出 SaaS 能力, +**每一块都由验证过的需求拉动,而不是提前把 7 个服务的底座全建出来。** + +--- + +## 1. 与 GPT 方案的取舍(明确保留 / 砍掉) + +| GPT 方案主张 | 我的判断 | 理由 | +|---|---|---| +| **tenant_id 全链路前置** | ✅ **采纳,最高优先** | 现在便宜、以后要命;多租户地基 | +| **Usage Meter 先落事件(不接支付)** | ✅ **采纳** | 事件溯源式用量→再计费,顺序对;现 billing 太薄 | +| **NATS subject 规范化 + Envelope(带 tenant_id)** | ✅ **采纳(轻量)** | 做多租户本就要给消息带 tenant,顺手;但**不拆多根总线** | +| **AutoMigrate → migration 工具** | ✅ **采纳** | 生产必须、可控变更 | +| Web App 主产品化(重写前端) | ❌ **砍掉** | **你定了桌面端为主**;改为桌面端租户感知 + 薄 Web 面 | +| 拆 7 个物理服务(task-runtime/model-gateway/tool-policy 独立服务) | ⚠️ **降级为"包/边界",不拆服务** | 单团队扛不动 7 服务运维;先做成 dispatcher 内的清晰模块,真扛不住再拆 | +| Task Runtime 独立层 + Temporal | ❌ **砍掉(现阶段)** | 现有 FSM+JetStream+checkpoint 够用;加 cancel/retry 收口即可,别上 Temporal | +| Remote MCP Gateway(OAuth/registry 整套) | ❌ **砍掉(现阶段)** | 远程 MCP 市场是远期产品赌注,无近期需求不建 | +| Tool Policy 强制拦每次工具调用 | ⚠️ **降级** | 内部工具先只做 tenant 作用域 + 用量计数;策略引擎等有不可信工具再上 | + +**核心分歧**:GPT 把它当"推翻重来做成标准 SaaS 平台";我把它当"给现有平台穿一条多租户脊柱"。 + +--- + +## 2. 多租户数据模型(Phase 1 地基) + +### 2.1 层级 +``` +Tenant(组织/账户,计费单位) + └── Workspace(工作区) + └── Project(项目) + └── Task / KB / Agent / ... +User ── (via TenantMember, 带 role) ── Tenant +``` +桌面端主产品下,多数个人用户 = 一个单人 Tenant(免费档);团队/企业 = 多成员 Tenant。 + +### 2.2 新增表(雪花 id + BaseModel,沿用 `sundynix_` 前缀) +- `sundynix_tenant`(id, name, slug, plan, status) +- `sundynix_tenant_member`(tenant_id, user_id, role, status) — 唯一(tenant_id,user_id) +- `sundynix_workspace`(id, tenant_id, name, created_by) +- `sundynix_project`(id, tenant_id, workspace_id, name) + +### 2.3 tenant_id 全链路(这是重点,也是最大工作量) +现有 owner_id 隔离 → 升级为 tenant 作用域。**所有核心表加 `tenant_id`**(Task/Eval/KB/Doc/DocLink/Agent/Prompt/AuditLog/GuardrailEvent…)+ 索引。隔离规则: + +| 层 | 现状 | 加 tenant | +|---|---|---| +| PostgreSQL | owner 过滤 | 全表加 tenant_id,**所有查询默认带 tenant_id**(用 gorm scope 统一强制,防漏) | +| Redis key | 无前缀 | `sundynix:{tenant_id}:...`(限流/流/会话) | +| NATS envelope | 裸 payload | 统一 Envelope 带 tenant_id(见 §3) | +| MinIO path | 平铺 | `tenants/{tenant_id}/workspaces/{ws}/projects/{proj}/...` | +| Milvus | kb 字段 | collection 不变,加 tenant 到 metadata filter(或 partition) | +| Neo4j | kb 属性 | 节点/关系加 `tenant_id` 属性 | + +> ⚠️ 最易漏的是"查询默认带 tenant_id"。用一个**统一的 gorm scope + code review 清单**强制,别靠人肉记。 + +### 2.4 RBAC(Phase 1 同期) +角色:`owner / admin / member / viewer / billing_admin`。权限动作(示例): +`tenant.manage / billing.manage / workspace.create / project.create / task.run / task.approve / model.manage / tool.use / kb.write / audit.read`。 +实现:中间件从 JWT 取 user → 查 TenantMember 得 role → 按 role→permission 表判权。 +`RequireAdmin` 升级为按 `tenant.manage` 权限判定(替代现在的开发期放行 + env 白名单)。 + +--- + +## 3. NATS Envelope + subject(Phase 1,轻量) +**不拆总线**(保持单 NATS,HA 是 T3)。只做两件卫生: +- 统一 `Envelope{id,type,version,tenant_id,workspace_id,user_id,task_id,trace_id,created_at,payload}` 包裹消息。 +- subject 按语义规范前缀(现已半规范):`cmd.* / events.* / streams.* / tools.* / control.* / health.*`。 +命令/事件区分先做最小:任务提交=command,状态变更落 `task_events`(见 §4)。 + +--- + +## 4. 任务运行收口(Phase 1,轻量——不做独立服务) +不建 Task Runtime 服务、不上 Temporal。现有 FSM + JetStream + checkpoint 上补: +- 加 `task_events`(tenant_id, task_id, run_id, event_type, payload, trace_id) 事件日志(可回放 + 计费溯源)。 +- 补 **cancel / retry**(现有 resume/超时已在)——在 dispatcher 消费侧加取消信号 + 幂等重试。 +- 这些是 dispatcher 内的能力增强,不新增服务。 + +--- + +## 5. Usage & Billing(Phase 2,事件优先、不急接支付) +### 5.1 先落用量事件 +`sundynix_usage_event`(tenant_id, user_id, task_id, run_id, resource_type, quantity, unit, unit_price, cost, currency, created_at)。 +计量点:**模型 token(in/out/cached)· 工具调用次数 · 沙箱时长 · 解析页数 · 入库量 · 存储 · 任务时长**。 +### 5.2 聚合 +`sundynix_usage_daily_rollup`(tenant_id, date, resource_type, quantity, cost) —— 按天汇总,admin 展示成本。 +### 5.3 分阶段 +`①仅记 usage_event → ②按天 rollup + 后台成本可视 → ③套餐 quota + 超额限制 → ④接支付 → ⑤企业账单`。 +**现在只做 ①②**(把已有的 Redis token 计数升级为落库的 usage_event)。 + +--- + +## 6. Model Gateway(Phase 4,扩展现有、不重建服务) +不拆独立服务。把现有 `llm.Pool`(已有 failover/熔断/缓存/热配置)**扩成 model runtime**: +- 每次调用 emit `usage_event`(token + 成本,按 tenant)。 +- 按 tenant 套餐限制可用模型 + quota;BYOK(租户自带 key)。 +- Provider Adapter 接口化(当前是 openai-compatible 单形态)便于接 vLLM/Ollama/未来 Claude。 +真扛不住/要独立扩缩容再拆服务。**先在包边界内做清楚。** + +--- + +## 7. Tool Policy(Phase 4,降级版) +不建独立 Policy 服务、不强制拦内部工具。分阶段: +- **现在**:工具调用带 tenant 作用域 + 计 usage_event(复用 §5)。 +- **有不可信工具时**(远程 MCP / shell / 本地连接器):再上 allow/deny/approval/quota 的策略前置 + `tool_call_event` 审计。 + +--- + +## 8. 客户端(Phase 3,桌面端为主 + 薄 Web 面) +**不重写 Web App。** +- **桌面端(主产品)**:变**租户感知**——登录后带 tenant 上下文;所有数据 tenant 作用域;工作区/项目切换;团队成员看到共享资源。原生能力(文件/另存为)不变。 +- **管理端(现有 React)**:长出**租户/成员/套餐/用量账单/审计**管理(运维 + 商业后台合一)。 +- **新增薄 Web 面**(只做桌面端不适合的):注册/登录/组织创建、**订阅计费、团队邀请**——因为这些要在装桌面端**之前**就能用,且计费/组织管理放桌面端别扭。技术上可以是管理端的一部分,或一个极简独立页,**不是完整产品重写**。 + +--- + +## 9. 分阶段落地(我的收敛排序 vs GPT 的全家桶) + +| 阶段 | 内容 | 为什么这个顺序 | +|---|---|---| +| **P1 地基**(先做,最关键) | tenant/workspace/project 表 + **tenant_id 全链路** + RBAC + 各存储隔离 + migration 工具 | 没这个,多租户什么都无从谈;schema 越小越便宜 | +| **P2 用量** | usage_event + daily rollup(模型/工具/存储计量),admin 成本可视 | 计费前必须先有可信用量;低风险 | +| **P3 客户端租户化** | 桌面端 tenant 上下文 + 管理端长出租户/账单页 + 薄 Web 注册/计费面 | 有了后端隔离才谈得上前端多租户;不重写 | +| **P4 enforcement** | 套餐 quota + model runtime 成本/quota/BYOK + 工具 tenant 作用域 | 有用量数据后才谈限额/套餐 | +| **P5 支付 + 硬化** | Stripe 接入 + NATS 集群 + DB HA + TLS(= T3) | 有真实付费流量再付分布式硬化的税 | + +**明确不做(现阶段)**:Web 前端重写、7 服务物理拆分、Temporal、Remote MCP Gateway、独立 Tool Policy 服务。 +这些等"验证过的需求"拉动再上——不提前建地基。 + +--- + +## 10. 现在就能起手的第一刀 + +**P1 的 tenant_id 是整件事的地基,也是唯一"越早越省"的。建议第一步只做最小闭环:** +1. 建 `tenant` + `tenant_member` 表;注册时给每个新用户建一个单人默认 tenant。 +2. `contract` + 核心表加 `tenant_id`;migration 脚本给存量数据回填一个默认 tenant。 +3. 一个统一的 gorm scope 强制所有查询带 tenant_id + 鉴权中间件注入 tenant 上下文。 +4. 先只覆盖 PG(Redis/MinIO/向量/图谱的 tenant 前缀随后一层层加),跑通"注册→建库→提交任务"在 tenant 作用域下端到端。 + +做完这一刀,多租户地基就立住了,后面 P2–P5 都能增量长上去。 + +--- + +*本设计对应「做什么」;对当前架构本身的评价见 `ARCHITECTURE_REVIEW.md`,当前架构描述见 `ARCHITECTURE_DESIGN.md`。* diff --git a/SAAS_P2_DESIGN.md b/SAAS_P2_DESIGN.md new file mode 100644 index 0000000..4441f22 --- /dev/null +++ b/SAAS_P2_DESIGN.md @@ -0,0 +1,142 @@ +# SaaS P2 —— 用量计量设计(Usage Metering) + +> 承接 P1(tenant_id 全链路已闭合,见 `SAAS_DESIGN.md` / DEPTH_ROADMAP T4.A)。 +> 本阶段目标:让 token 用量可**按租户持久归集**,成为**计费 / 配额**的事实源。 +> 纯后端、自包含;桌面端不改;不接支付(P5)。 + +--- + +## 1. 目标与非目标 + +**目标** +- token 用量按 **tenant** 维度持久落库(不再只有易失的 Redis 日计数)。 +- 计量单位为**积分(credits)**:可配 token↔积分汇率,扣量走扣积分; + **「按 token 直计」= 汇率 1:1 的退化情形**(同一条代码路径,配置切换,不做两套)。 +- 每条用量同时按 **Pricing 折算成金额**(内部账/毛利),可对账、可回溯重算。 +- 按 **租户 / 天** rollup,供 admin 查用量 + 积分消耗 + 成本趋势。 + +**非目标(划清边界,不过早做)** +- ❌ 支付 / 发票 / 订阅扣款 —— P5。 +- ❌ 套餐配额 enforcement(超额拒绝)—— P4,需先有 plan→quota 的产品定义。 +- ❌ 实时流式计量 —— 收尾一次事件已够,不做 per-token 流。 +- ❌ 前端计费页 —— 本阶段只出接口 + 数据,页面后续。 + +--- + +## 2. 现状(已有的一半) + +``` +提交(gateway) task.Meta[MetaUserID]=userID ──PublishTask──▶ dispatcher +dispatcher 收尾 emitUsage ── UsageEvent{task,user,model,tok...} ──▶ gateway +gateway SubscribeUsage ── AddUsage(user,day,tok) ──▶ Redis 计数(48h TTL) +门控 提交前 GetUsage(user,today) vs USER_DAILY_TOKEN_BUDGET +``` + +**够什么**:单用户当日预算门控。 +**缺什么**:① 无 tenant 维度;② Redis 48h 易失,计费要永久可审计;③ 有 `Pricing`(input/output per 1K)但没折算金额;④ 无聚合,查趋势要现算。 + +--- + +## 3. 设计 + +### 3.1 tenant_id 打通链路(接 P1) +- **提交**:`task.Meta[MetaTenantID] = tenantID(c)`(网关已有 `tenantID(c)` 助手)。 +- **契约**:`contract.UsageEvent` 加 `TenantID string`。 +- **dispatcher**:`emitUsage` 从 `t.Meta[MetaTenantID]` 读租户,带进事件。 +- 一处新增 Meta 键、一个契约字段、一行读取 —— 复用今天刚打通的 owner/tenant 提交链。 + +### 3.2 持久明细表 `sundynix_usage_event`(追加式,只增不改) +| 列 | 说明 | +|---|---| +| id / created_at | 雪花 + 时间(BaseModel) | +| tenant_id / owner | 租户 + 提交者 user.id(隔离 + 归属) | +| task_id | 关联任务 | +| model | 计费模型名 | +| prompt_tok / comp_tok / total_tok | 输入 / 输出 / 合计 | +| credits_micro | 折算积分,**int64 微积分**(×10⁻⁶),避取整损耗;展示时约整 | +| cost_micros | 折算金额,**int64 微单位**(币种最小单位 ×10⁻⁶),避浮点累积误差 | +| currency | 币种(跟 Pricing) | +| exceeded | 是否触顶单任务预算被中止 | + +- 每条 `UsageEvent` 落一行 —— **审计 / 对账 / 重算的事实源**。 +- 为何不复用 Redis:Redis 48h 就没了;计费要永久、可审计、可按新价重算历史。 +- 受租户表(标 `isTenantScoped()`);但消费在 background ctx(NATS 回写)→ tenant 从事件显式带,不靠插件自动填(与 Eval 同模式)。 + +### 3.3 成本折算 +- 落库时查 `Pricing[model]` → `cost = prompt/1000*input_per_1k + comp/1000*output_per_1k`,存 `cost_micros`(整数)。 +- **Pricing 缺失** → cost=0 + 照落事件(**计量不因缺价而丢量**;缺价可事后补价重算)。 +- tokens 是「估算」(契约原注释)→ 计费口径认这个近似;要精确则后续接 provider 真实 usage(已知项,非本轮)。 + +### 3.4 聚合表 `sundynix_usage_rollup`(租户 / 天 快照) +| 列 | 说明 | +|---|---| +| tenant_id + day | 联合唯一(`20060102`) | +| total_tok / credits_micro / cost_micros / task_count | 当天累加 | +| currency | 币种 | + +- 消费 `UsageEvent` 时**同步 upsert**(`ON CONFLICT(tenant_id,day) DO UPDATE ... = 原值 + 增量`)。 +- 明细表(event)给审计/重算,快照表(rollup)给快速读 —— 经典 append + materialized。 +- 配额/账单查 rollup **一行**,不扫明细。 + +### 3.6 计量单位:积分(credits)与 token 汇率 +**核心**:积分是租户面唯一计量/扣量单位;token 直计 = 汇率 1:1 的退化。一条路径,不做两套。 + +**汇率配置**(admin 可配) +- 全局默认 `TOKENS_PER_CREDIT`(如 1000 token = 1 积分;设 1 即 token 直计)。 +- 可选 **每模型权重** `credit_weight`(premium 模型每 token 烧更多积分)——复用 `Pricing` + 表加一列 `credit_weight float64`(缺省 1.0),不新建表。 +- 落库:`credits_micro = total_tok * 1e6 / TOKENS_PER_CREDIT * credit_weight`(整数运算,保精度)。 + +**积分账本 `sundynix_credit_ledger`(append-only,扣量的事实源)** +| 列 | 说明 | +|---|---| +| tenant_id | 租户 | +| kind | `grant`(充值/发放,+) / `usage`(消耗,−) / `adjust`(人工校正) | +| credits_micro | 带符号增量 | +| ref | 关联(usage→task_id,grant→订单/发放单号) | +| memo | 备注 | + +- 租户当前余额 = `SUM(credits_micro)`;为快取在 `sundynix_tenant.credit_balance_micro` + 维护物化余额(消费/充值时增量更新),查询读一列,不 SUM 全账本。 +- **一致性**:先写 ledger 明细,再增量改 balance;balance 可由 ledger 随时重算兜底。 +- 幂等:`usage` 分录对 `(tenant_id, ref=task_id, kind)` 唯一,防重投重复扣分。 + +**扣量 vs 拦截(分级,别过早强制)** +- **P2(本阶段,记账)**:每条 usage 记 `usage` 分录 + 扣物化余额。余额可为负(软扣,先记账不拦)。 +- **P4(enforcement)**:提交前查余额≤0 则拒绝新任务(硬闸)。需先有**套餐→发放积分**的产品定义,故 enforcement 留后。 + 开关 `CREDIT_ENFORCE`(默认 off) 控制软/硬,schema 现在就备好,改行为时不改表。 + +### 3.5 观测接口(admin,系统级) +- `GET /api/v1/admin/usage?tenant=&from=&to=` → 从 rollup 出某租户(或全平台)用量 + 积分消耗 + 成本趋势,附租户当前积分余额。 +- 走 `store.WithoutTenant`(系统级跨租户,与 admin/overview 同口径)。 +- 前端页后续;本阶段先接口 + 数据。 + +--- + +## 4. 落地顺序(增量) + +- **增量1 — 链路 + 明细 + 折算**:`MetaTenantID` + `UsageEvent.TenantID` + dispatcher 带租户; + `Pricing.credit_weight` 列 + `TOKENS_PER_CREDIT` 配置;`usage_event` 表 + 消费时折算 credits/cost 落明细。 + live 验证:跑一个任务 → 一行带 tenant + owner + credits + cost。 +- **增量2 — 账本 + 余额 + rollup**:`credit_ledger` 表(usage 分录,幂等)+ `tenant.credit_balance_micro` + 物化余额(软扣,可负);`usage_rollup` 表 + upsert。 + live 验证:多任务 → 余额递减正确、rollup 累加正确。 +- **增量3 — 观测接口**:`GET /admin/usage`(用量 + 积分 + 成本 + 余额)。 +- (前端用量页 / P4 硬拦截 `CREDIT_ENFORCE` / 充值发放 —— 后续,需求拉动) + +--- + +## 5. 权衡与已知风险(诚实标注,不一次做满) + +1. **投递可靠性**:`UsageEvent` 现走普通 NATS、收尾 best-effort,**丢事件=丢计费**。 + → 本轮先落表(已远强于 Redis)。若要**严格计费**,把 usage 升级到 **JetStream 持久投递**(像入库队列那样带重投/幂等)——列为已知项,等"要精确对账"的需求真来了再上,不提前做。 + → 幂等兜底:`usage_event` 可对 `task_id` 加唯一约束(一任务一计量),防重投重复计费。 +2. **成本依赖 Pricing 配全**:缺价记 0 不丢量,可事后补价重算明细 → 修 rollup。 +3. **token 估算**:计费认近似值;要精确接 provider usage 是后续。 +4. **rollup 与 event 一致性**:先写 event 再 upsert rollup;若 rollup 失败,event 仍在 → 可由 event 重建 rollup(提供一个重算命令兜底)。 + +--- + +## 6. 与 GPT 重构方案的关系 +- 采纳其正确诊断:**usage 先落事件、不接支付**(P2 与 P5 解耦)。 +- 不引入独立计费服务 / 消息中间件重构 —— 复用现有 NATS + PG,按需求拉动。 diff --git a/SPACE_DESIGN.md b/SPACE_DESIGN.md new file mode 100644 index 0000000..c5b5edb --- /dev/null +++ b/SPACE_DESIGN.md @@ -0,0 +1,182 @@ +# sundynix-agentix · 共享工作区设计(Space 抽象) + +> 版本:v1 | 日期:2026-07-13 +> 定位:`SAAS_DESIGN.md` **P1 增量3(资源共享 / 共享工作区)** 的落地设计。 +> 前提(已确认):**核心要灵活、不写死**——不做"整租户一刀切共享",要支持"针对某项目临时组队贡献"。 +> 基线事实:KB/Agent/Doc 已带 `tenant_id` 且受 gorm 租户插件过滤(见 `store/tenant_scope.go`), +> 但仍按 `owner`(userID) 锁到个人;存储作用域键是 `owner/name`。 +> 相关:`SAAS_DESIGN.md`(多租户脊柱)、RBAC 落地(`store.RoleRank` + `middleware.RequireTenantRole`)。 + +--- + +## 0. 一句话设计 + +**资源不再直接绑 `user` 或 `tenant`,而是绑一个中间层「Space(工作区/项目空间)」——一个可大可小、可临时可长期的协作容器。** +个人私有、项目临时组队、整租户共享三种场景,全从同一个 Space 模型里长出来。作用域键从 `owner/name` 换成 `space_id/name`, +就这一处把"锁"从人解到空间,彻底不写死。 + +--- + +## 1. 为什么不能"整租户一刀切" + +用户的核心诉求:**灵活**。评估过的两个"简单"方案都不行: + +| 方案 | 问题 | 结论 | +|---|---|---| +| **A. 整租户统一共享**(资源 owner→tenant) | 只有"全私有 / 全租户"两态,没有中间态;一个用户无法"私有一批 + 项目X共享一批 + 项目Y共享一批" | ❌ 死板,且将来想灵活要**再迁一次** | +| **B. 每资源加 shared 标记 + ACL** | 无天然分组,"一队人围着一个项目协作这 5 个库"很难表达;per-resource ACL 迅速失控 | ❌ 乱 | +| **C. Space 中间容器**(本方案) | 一个抽象覆盖三种场景;匹配"一队人围着一个项目贡献"的心智;一次迁到正确容器、永不返工 | ✅ 采纳 | + +这是成熟产品的通用解法:Notion teamspace、Slack channel、Linear team、GitHub repo+协作者。 + +--- + +## 2. 核心抽象:租户 > Space > 成员 + +**层级**:`Tenant`(计费/隔离边界) → `Space`(共享容器) → `SpaceMember`(谁在里面)。 +资源(KB/Agent/Doc/Run)挂在 Space 上,不再挂在 user 上。 + +**三种场景 → 一个模型**: + +| 场景 | Space 表达 | kind | +|---|---|---| +| 个人私有(= 现状) | 每人**自动一个个人 Space**(1 成员 = 自己),新资源默认落这里 | `personal` | +| **针对项目临时组队贡献** | 任意成员**建个 Space**,拉租户里几个人进来,共建 KB/编排,完事**归档** | `project` | +| **整租户共享("开关")** | 一个成员 = 全租户的**"全员 Space"**(开关开 = 自动建它、自动纳入全部成员) | `tenant` | + +**"临时"是免费的**:临时团队 = 建 `project` Space → 拉人 → 贡献 → 归档(软删,可选自动过期)。临时只是生命周期属性,不是另一套机制。 +**grain 提醒**:共享绑 **Space(项目)**,不要绑单次 task(task 是一次执行、跑完即逝);一个 Space 可关联多次 task 运行。 + +--- + +## 3. 现状盘点(改造起点,已核实) + +三层"锁到个人",尽管 tenant_id 已存在: + +| 锁 | 现状(file 参考) | 改造 | +|---|---|---| +| ① 查询显式 `WHERE owner=?` | `ListKB/ListAgents/ListVault/GetDocByID…`(`store/model.go`、`store/tenant.go`)在插件的 tenant 过滤之上又按 userID 过滤 | 去掉 owner 过滤,改按 `space_id`;`owner` 降级为"创建人"归属 | +| ② 唯一索引 `(owner,name)` | `KB.idx_kb_owner_name` / `Agent.idx_agent_on` / `Doc.idx_doc_okn`(`store/model.go`) | 改 `(space_id,name)`——名字**按空间唯一** | +| ③ 存储作用域键 `owner/name` | `scopedKB = userID+"/"+name`(`handler/kb.go:38`),作 mcp-go 工具 `kb` 参数 | 改 `space_id+"/"+name` | + +**关键有利事实**(决定迁移不重): +- **Milvus 用标量字段 `kb`(VarChar) 过滤**(`kb == "owner/name"`,`rag/milvus.go:112/207`),**不是物理分区** → re-key = 改字段值 / 从 MinIO 原文重灌,不用重建分区。 +- **MinIO 对象键不透明**(`owner/kb/doc`,存进 `Doc.ObjectKey`,按 key 精确取,`handler/kb.go:154`)→ **不用迁**:老对象照常可读,新对象走新键。 +- **Agent 纯 PG**(Graph JSON,`store/model.go` `ListAgents/SaveAgent/DeleteAgent`,零 Milvus/Neo4j/MinIO 依赖)→ **可先在 Agent 上把整套 Space 打样,零存储风险**。 +- **偏好记忆(`recall_user_memory`/`remember_user_fact`)保持个人**,不进 Space(个人偏好共享无意义)。 + +--- + +## 4. 数据模型 + +**+2 张表**: + +``` +Space { + id snowflake + tenant_id string // 归属租户(隔离/计费边界仍在租户) + name string + kind string // personal / project / tenant + creator string // user.id + archived bool // 软归档(临时空间完事即归档) +} +// 唯一:(tenant_id, kind='personal', creator) 保证每人每租户仅一个个人空间 + +SpaceMember { + space_id string + user_id string + role string // 复用租户角色语义:owner/admin/member/viewer(见 §5) +} +// 唯一:(space_id, user_id) +``` + +**资源改造**(KB / Agent / Doc / DocLink / Task-Run): +- 加 `space_id string`(`index`);保留 `tenant_id`(防御纵深 + 跨租户查询仍被插件挡);保留 `owner` 作**创建人归属**。 +- 唯一索引 `(owner,name)` → `(space_id,name)`。 +- 作用域键 `owner/name` → `space_id/name`。 + +**注**:租户隔离仍靠 `tenant_id`(Space 属租户,`space.tenant_id` 兜底);主作用域切到 `space_id`。 + +--- + +## 5. 权限(接已落地的 RBAC) + +每个 Space 有自己的成员角色,直接复用 `store.RoleRank` 阶梯(owner>admin>member>viewer/billing_admin): + +| 动作 | viewer | member | admin/owner | +|---|:--:|:--:|:--:| +| 看 / 搜索空间内资源 | ✅ | ✅ | ✅ | +| 建 KB/Agent、入库、改**自己建的** | ❌ | ✅ | ✅ | +| 改 / 删**别人建的** | ❌ | ❌ | ✅ | +| 管理空间成员(拉人/改角色/移除) | ❌ | ❌ | ✅ | + +- 新增中间件 `RequireSpaceRole(db, minRole)`(照 `RequireTenantRole` 套路,从 ctx 取 space_id + user 判 `SpaceMember.role`)。 +- **这才让 owner/admin/member/viewer 五个角色真正有区别**——RBAC 落地时留的"架子"在此点亮。 + +--- + +## 6. 迁移方案 + +**一次性迁到正确容器(个人空间),个人租户单成员无冲突**: + +1. **建个人空间**:给每个用户在其每个所属租户建一个 `personal` Space(幂等,启动回填)。 +2. **资源 re-key**:存量 KB/Agent/Doc/DocLink 的 `space_id` 回填为其 owner 在**对应租户**的个人空间 id;唯一索引 `(owner,name)`→`(space_id,name)`(AutoMigrate 不删旧索引,需显式 migration)。 +3. **存储层**: + - Milvus:`kb` 字段 `owner/name`→`space_id/name`(从 MinIO 原文重灌,原文安全兜底)。 + - Neo4j:`MATCH (n) WHERE n.kb STARTS WITH 'owner/' SET n.kb = ...` 批量 Cypher。 + - MinIO:**不动**(不透明键)。 +4. **命名冲突**:`(space_id,name)` 上唯一约束前,多成员租户里两人同名资源需先改名(个人空间单成员天然无冲突,只有后续"移进共享空间"时才判重)。 + +**"把个人资源共享进项目"= 一等操作**:改资源 `space_id` + re-key 存储 `kb` 字段(单资源,非全量)→ 天然支持"默认私有 + 显式共享"。 + +--- + +## 7. 分层落地(先 Agent 打样,再 KB) + +### 阶段 3a:Agent 编排共享 —— 纯 PG,轻,先行 ✅ 推荐起点 +- 建 `Space`/`SpaceMember` 表 + `Agent.space_id`;个人空间自动建 + 存量 Agent re-key。 +- `ListAgents/SaveAgent/DeleteAgent` 去 owner 过滤、改按 space_id;唯一 `(space_id,name)`;owner 存创建人。 +- 空间管理接口:建 project 空间 / 拉人(邮箱,复用 `AddMemberByEmail` 思路)/ 改角色 / 移除 / 归档。 +- `RequireSpaceRole` 门控 + 前端**空间切换器**(顶栏,类似租户切换器)+ StudioView 按空间角色 readOnly。 +- **一周内端到端验证协作 + 权限 + 切换 UX,零存储风险。** + +### 阶段 3b:KB 知识库共享 —— 存储 re-key,随后 +- 把同一套 `space_id` 作用域推到 KB/Doc/DocLink + `scopedKB` 改 `space_id/name`。 +- Milvus 重灌 / Neo4j 批量 SET / MinIO 不迁 / 存量数据迁移 + 命名冲突处理(§6)。 +- 模式已被 3a 验证,风险最低。 + +--- + +## 8. 计费衔接(无新工作) + +- 运行共享 Agent / 搜共享 KB 的**计费目标不变**:仍由 `ResolveBillingTenantID(user, activeTenant)` 按活跃租户 + `shared_billing` 决定记谁的池子(`SAAS_DESIGN.md` 增量2)。 +- Space 只管"看得见/改得动谁的资源",**不改"谁付钱"**——两个维度正交,互不干扰。 + +--- + +## 9. 开放问题(待定,别提前写死) + +| 议题 | 倾向 | 说明 | +|---|---|---| +| **默认落哪** | 个人空间 | 新资源默认私有,要共享才移/复制进项目空间 | +| **删别人建的** | admin/owner 才行 | member 只能删自己建的(§5) | +| **Space 嵌套** | v1 不做 | 扁平:租户下平铺 Space;有需求再说 | +| **临时空间自动过期** | v1 手动归档 | 先给 `archived` + 手动归档;自动过期(TTL)后置 | +| **跨租户 Space** | 不做 | Space 严格属单租户,隔离边界不破 | +| **个人偏好记忆** | 保持个人 | 不进 Space | + +--- + +## 10. 明确不做(防过度设计) + +- ❌ 不做 per-resource ACL(用 Space 分组替代)。 +- ❌ v1 不做嵌套空间 / 跨租户空间 / 空间模板市场。 +- ❌ 不动 MinIO 对象键、不重建 Milvus 分区(现状标量字段 + 不透明键让迁移可控)。 +- ❌ 不碰计费目标解析(正交,复用增量2)。 + +--- + +## 附:一句话给未来的自己 + +**Space 相对"整租户统一"的额外成本 ≈ 两张小表 + 空间成员管理 + 切换器 UI;最重的存储 re-key 两方案一样。 +用对容器几乎免费,还省掉二次迁移。先拿纯 PG 的 Agent 打样跑通协作+权限+切换,再上 KB 存储 re-key。** diff --git a/STATE_MACHINE_AGENT_LOOP.svg b/STATE_MACHINE_AGENT_LOOP.svg new file mode 100644 index 0000000..0b90029 --- /dev/null +++ b/STATE_MACHINE_AGENT_LOOP.svg @@ -0,0 +1,75 @@ + +任务状态机与 agent loop 的三层嵌套(sundynix-agentix / dispatcher) +外层任务状态机由 NATS 事件驱动、状态落 PG;running 展开为 Eino compose.Graph 的 DAG 调度;每个 agent 节点内是 Eino react.Agent 的推理-调工具-观察循环。 + + + + + + + + +任务状态机 & agent loop —— 三层嵌套 + + +Eino 编排 / 循环 + +状态 / 结构 + +外部:工具 / 总线 + + +① 任务状态机(dispatcher 无状态,转移走总线) + +submitted + + +running + + +done · failed · timeout +rejected · waiting(HITL 暂停) +转移 = setStatus → NATS(status.task) → 网关 UpdateTaskStatus 落 PG + +▼ running 展开为 + + +② Eino compose.Graph —— 按 DAG 调度节点 + +agent 节点 + + +branch ◇ + + +节点 ∥ 节点 +无依赖并行 +真假边路由 · HITL→checkpoint + +▼ 每个 agent 节点放大为 + + +③ Eino react.Agent —— 节点内的 ReAct 循环 + +推理 (LLM) + + +调工具 +MCP / NATS + + +观察结果 + + +流式输出 +停止 + + +循环:还要调工具 → 再推理(至 MaxStep 或不再调工具) + diff --git a/analysis-6.27.md b/analysis-6.27.md new file mode 100644 index 0000000..ec7d74f --- /dev/null +++ b/analysis-6.27.md @@ -0,0 +1,320 @@ +# sundynix-agentix 项目分析报告(2026-06-27) + +> **分析时间**:2026 年 6 月 27 日(dev 分支,commit 148+) +> +> **一句话定位**:一个面向企业的 **AI Agent 编排平台**,支持可视化 DSL 构建 Agent 工作流、多模型热切换、RAG 知识库、长期记忆、自动评测闭环、人工审批(HITL)、桌面端原生应用 + Web 运维控制台,一键 Docker 部署。 + +--- + +## 一、整体架构(五层微服务) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 第 1 层 · 客户端 │ +│ ┌───────────────────────┐ ┌───────────────────────┐ │ +│ │ sundynix-desktop │ │ sundynix-admin │ │ +│ │ (Wails + React + TS) │ │ (Vite + React + TS) │ │ +│ │ 桌面原生应用 │ │ Web 运维控制台 │ │ +│ └──────────┬────────────┘ └──────────┬─────────────┘ │ +│ │ HTTP/SSE │ HTTP │ +│ ═══════════╪══════════════════════════╪═════════════════════════ │ +│ 第 2 层 · API 网关 │ +│ ┌──────────▼──────────────────────────▼─────────────┐ │ +│ │ sundynix-gateway (Go · Gin) │ │ +│ │ DSL 解析 / JWT 鉴权 / 护栏(Tier1) / SSE 流 / │ │ +│ │ 状态机 / 评测落库 / 配置控制面 / MinIO │ │ +│ │ Postgres + Redis + MinIO │ │ +│ └──────────┬────────────────────────────────────────┘ │ +│ │ NATS JetStream + core pub/sub │ +│ ═══════════╪══════════════════════════════════════════════════ │ +│ 第 3 层 · 调度与编排 │ +│ ┌──────────▼──────────────────────────────────────┐ │ +│ │ sundynix-dispatcher (Go · Eino) │ │ +│ │ DSL 图引擎 / ReAct Agent / 流式推理 / 记忆对账 / │ │ +│ │ 报告多步编排 / 自动评测+纠偏 / 熔断 / 预算护栏 │ │ +│ └────────┬───────────────────────────┬──────────────┘ │ +│ │ NATS request-reply │ │ +│ ═════════╪═══════════════════════════╪═════════════════════ │ +│ 第 4 层 · 共享契约与总线 │ +│ ┌────────▼───────────────────────────▼────────────┐ │ +│ │ sundynix-shared (Go) │ │ +│ │ contract / bus(NATS) / otelx / secrets │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ═══════════════════════════════════════════════════════════ │ +│ 第 5 层 · MCP 工具微服务 │ +│ ┌──────────────────────────────┐ ┌──────────────────────┐ │ +│ │ sundynix-mcp-go (Go) │ │ sundynix-mcp-py (Py) │ │ +│ │ RAG(Milvus+Bleve+Neo4j) / │ │ 文档解析(Word/PDF) / │ │ +│ │ 记忆(PG) / 历史(Redis) / │ │ 代码沙箱(Docker) │ │ +│ │ 报告渲染(docx) / 搜索 / │ │ 算法型 MCP 工具 │ │ +│ │ 知识库管理 / 图谱 / 外部工具 │ │ │ │ +│ └──────────────────────────────┘ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、技术栈全景 + +### 后端(Go 1.25) + +| 领域 | 技术选型 | 用途 | +|------|---------|------| +| HTTP 框架 | Gin 1.12 | 网关 REST API + SSE | +| Agent 编排 | CloudWeGo Eino | ReAct Agent、compose.Graph、流式推理 | +| 消息总线 | NATS JetStream | 任务分发、Token 回流、工具调用、配置广播、审批 | +| 关系库 | PostgreSQL 16 + GORM | 用户、任务、评测、画像、知识库元数据 | +| 缓存/流 | Redis 7 + go-redis | Session、Token 日预算、Redis Stream 回放 | +| 向量库 | Milvus 2.4 | RAG 向量检索 | +| 全文索引 | Bleve v2 | RAG 全文路 + RRF 融合 | +| 图数据库 | Neo4j 5 | 知识图谱三元组(GraphRAG) | +| 对象存储 | MinIO | 文件/文档/报告附件 | +| 链路追踪 | OpenTelemetry + Jaeger | 全链路 trace(NATS 传播) | +| 指标监控 | Prometheus client_golang | HTTP/任务/模型指标 | +| 认证鉴权 | JWT (golang-jwt/v5) + bcrypt | 用户注册/登录/token | +| 密钥管理 | AES-256-GCM (自建 secrets 包) | API Key 落库/过线加密 | +| ID 生成 | 雪花算法 (bwmarrin/snowflake) | 全局唯一 ID | +| 桌面端 | Wails v2 | Go + WebView 原生桌面应用 | + +### 前端 + +| 领域 | 技术选型 | 用途 | +|------|---------|------| +| 框架 | React 19 + TypeScript | 桌面端 & 管理端 | +| 构建 | Vite 5 | 前端构建 | +| 样式 | TailwindCSS 3 | UI 样式 | +| 路由 | react-router-dom 7 | SPA 路由 | +| 图编辑 | @xyflow/react 12 (React Flow) | DSL 可视化编排画布 | +| 图谱可视化 | react-force-graph-2d | 知识图谱力导向图 | +| 图标 | lucide-react | UI 图标 | +| 测试 | Vitest + Testing Library | 前端单测 | + +### Python(mcp-py) + +| 领域 | 技术选型 | 用途 | +|------|---------|------| +| 运行时 | Python 3.11+ | 算法型工具 | +| 消息总线 | nats-py | 接入 NATS 骨干网 | +| 文档解析 | python-docx / openpyxl / pypdf | Word/Excel/PDF 解析 | +| 代码沙箱 | docker SDK | Docker 隔离执行环境 | + +### 基础设施 + +| 组件 | 镜像 | 用途 | +|------|------|------| +| NATS | nats:2-alpine | 消息骨干网 (JetStream 持久化) | +| PostgreSQL | postgres:16-alpine | 关系存储 | +| Redis | redis:7-alpine | 缓存 / Stream / 预算 | +| Milvus | milvusdb/milvus:v2.4.13 | 向量数据库 | +| etcd | quay.io/coreos/etcd:v3.5.14 | Milvus 元数据 | +| MinIO | minio/minio | 对象存储 | +| Neo4j | neo4j:5-community | 图数据库 | +| Jaeger | jaegertracing/all-in-one:1.60 | 链路追踪 UI | + +--- + +## 三、已实现功能清单 + +### 🧠 核心 AI 能力 + +| 功能 | 实现程度 | 关键文件 | +|------|---------|---------| +| **DSL 图编排引擎** | ✅ 完整 | `dispatcher/internal/eino/graph.go` — 11 种节点 (input/memory/retriever/tool/agent/aggregate/render/branch/approval/map/output),拓扑排序执行 | +| **ReAct Agent(自主工具调用)** | ✅ 完整 | `dispatcher/internal/eino/react_agent.go` — Eino ReAct 循环,自动发现 MCP 工具目录,inject 参数绑定 | +| **流式推理** | ✅ 完整 | `orchestrator.go` — ChatStream / StreamText 双模,reasoning 思考过程入轨迹 | +| **多模型热切换** | ✅ 完整 | 控制面 NATS 广播 → Dispatcher/mcp-go 热更新,支持 OpenAI 兼容 / vLLM / Ollama | +| **报告多步编排** | ✅ 完整 | `report.go` — 规划大纲 → 并行(RAG+撰写) → 汇聚 → 渲染 Word/PDF/Markdown | +| **RAG 混合检索** | ✅ 完整 | `mcp-go/internal/rag/` — Milvus向量 + Bleve全文 + Neo4j图谱 → RRF融合 → 可选 Rerank | +| **知识图谱(GraphRAG)** | ✅ 完整 | LLM 抽三元组 → Neo4j 存储 → 检索时图谱路参与 RRF 融合 | +| **长期记忆(Generative Agents 式)** | ✅ 完整 | `memory/store.go` — Recency×Importance 加权排序,Consolidate 对账,Top-N 截断 | +| **短期多轮历史** | ✅ 完整 | Redis 存会话历史,自动召回注入上下文 | +| **记忆对账(Mem0 式 Consolidate)** | ✅ 完整 | `memory_extract.go` — 每 N 轮 LLM 对账 ADD/UPDATE/DELETE/NOOP | +| **多 Agent 协作** | ✅ 完整 | 黑板模式:agentOut 接力,前序 agent 产出注入下游 | + +### 🛡️ 安全与治理(Harness) + +| 功能 | 实现程度 | 关键文件 | +|------|---------|---------| +| **输入护栏 Tier1** | ✅ 完整 | `gateway/internal/guardrail/` — 归一化(去零宽/同形字/base64)→高精度注入正则→敏感词黑名单 | +| **输入护栏 Tier2** | ✅ 完整 | `dispatcher/internal/harness/jailbreak.go` — LLM 越狱分类器,灰区裁决 | +| **输出脱敏** | ✅ 完整 | `harness/output.go` — 跨分片 StreamRedactor (密钥/JWT/PII 手机号/邮箱/身份证),防碎片泄漏 | +| **自动化评测** | ✅ 完整 | `harness/eval.go` — 规则检查 + LLM-as-judge + RAG 忠实度评测(幻觉检测) | +| **低分自动纠偏** | ✅ 完整 | `orchestrator.go#refine` — 评语驱动重生成,取更优者(恒温器闭环) | +| **熔断器** | ✅ 完整 | `harness/circuitbreaker.go` — 连续失败触发熔断,快速拒绝新任务 | +| **Token 预算** | ✅ 完整 | 单任务硬上限(TASK_TOKEN_BUDGET) + 单用户日预算(USER_DAILY_TOKEN_BUDGET) | +| **JWT 鉴权** | ✅ 完整 | `gateway/internal/auth/` — 注册/登录/中间件 | +| **密钥加密** | ✅ 完整 | `shared/secrets/` — AES-256-GCM 落库/过线,生产环境强制设密钥 | + +### 🔍 可观测性 + +| 功能 | 实现程度 | 关键文件 | +|------|---------|---------| +| **全链路 Trace** | ✅ 完整 | OTel → Jaeger,NATS 消息头传播 trace_id | +| **执行可视化轨迹** | ✅ 完整 | ExecEvent 回流 — 节点点亮/工具入参产出/耗时/分阶段 | +| **Redis 回放** | ✅ 完整 | Token 流 + 执行轨迹 Redis Stream 录制 → SSE 断点续传 | +| **运行历史复盘** | ✅ 完整 | PG 持久化(output+trace),过 Redis TTL 仍可读库复盘 | +| **服务健康灯** | ✅ 完整 | 聚合 6 依赖(gateway/nats/db/redis/milvus/neo4j)实时状态 | +| **Prometheus 指标** | ✅ 基础 | `/metrics` 端点可用 | + +### 📱 客户端 + +| 功能 | 实现程度 | 说明 | +|------|---------|------| +| **桌面原生应用** | ✅ 完整 | Wails v2 — Go 后端 + React/TS 前端 | +| **可视化 DSL 画布** | ✅ 完整 | React Flow 拖拽编排 Agent 图 | +| **实时仪表盘** | ✅ 完整 | 任务/评测/知识库/Token/服务状态聚合展示 | +| **运行·观测页** | ✅ 完整 | 三栏布局:历史列表 / 轨迹面板 / Markdown 输出 | +| **知识库管理** | ✅ 完整 | 入库进度实时回流、图谱可视化 | +| **偏好记忆面板** | ✅ 完整 | 查看/编辑/删除个人偏好 | +| **人工审批(HITL)** | ✅ 完整 | 审批节点暂停 → UI 弹批准/拒绝 → 决定回传 | +| **运维控制台** | ✅ 完整 | 模型配置(chat/embedding) / 服务状态 / 计费 | +| **Markdown 渲染** | ✅ 完整 | 表格/代码围栏/图表/流式渲染 | + +### 🚀 部署与运维 + +| 功能 | 实现程度 | 说明 | +|------|---------|------| +| **Docker 容器化** | ✅ 完整 | 5 个服务各自 Dockerfile | +| **一键 Docker Compose 部署** | ✅ 完整 | `docker-compose.prod.yml` — 应用+基建一把拉起,安全配置 | +| **优雅停机** | ✅ 完整 | SIGTERM → drain 排空在途任务/工具 → 超时兜底 | +| **HA 多副本安全** | ✅ 完整 | 事件订阅队列组(QueueGateway),杜绝重复落库/计费 | +| **Go Workspace** | ✅ 完整 | `go.work` 管理 4 个 Go 模块 | +| **Makefile** | ✅ 完整 | 一键启停、测试、构建 | + +--- + +## 四、代码规模 + +| 指标 | 数据 | +|------|------| +| **Go 源码** | ~15,600 行 | +| **Go 测试** | ~3,400 行 | +| **TypeScript/TSX** | ~6,300 行 | +| **Python** | ~640 行 | +| **总代码** | **~26,000 行** | +| **Git 提交** | 148 次 | +| **Go 模块** | 4 个 (shared/gateway/dispatcher/mcp-go) | +| **微服务** | 6 个 (gateway/dispatcher/mcp-go/mcp-py/admin/desktop) | +| **基础设施组件** | 7 个 (NATS/PG/Redis/Milvus/etcd/MinIO/Neo4j) | +| **Docker 服务** | 12 个 (5 应用 + 7 基建) | + +--- + +## 五、项目成熟度评级 + +### ⭐ 综合评定:**准生产级(Pre-Production / Late-Beta)** + +对标业界分级: + +| 维度 | 评分 (1-5) | 说明 | +|------|:---:|------| +| **架构设计** | ⭐⭐⭐⭐⭐ | 五层解耦、NATS 骨干网、契约驱动、降级容错、热更新 —— 架构非常成熟 | +| **功能完整度** | ⭐⭐⭐⭐☆ | AI Agent 编排的核心闭环完整,HITL/评测/纠偏/护栏/记忆均已落地 | +| **代码质量** | ⭐⭐⭐⭐☆ | 注释详实(中文),错误处理层次分明,降级模式完备,接口抽象合理 | +| **测试覆盖** | ⭐⭐⭐☆☆ | 关键路径有 e2e/集成/单测(3400行),但覆盖率还有提升空间 | +| **可观测性** | ⭐⭐⭐⭐⭐ | 全链路 trace + 执行轨迹 + Redis 回放 + 健康灯 + 评测落库 —— 业界标杆级 | +| **安全治理** | ⭐⭐⭐⭐☆ | 多层护栏 + 密钥加密 + JWT + 输出脱敏 + 预算控制 —— 极其完善 | +| **部署运维** | ⭐⭐⭐⭐☆ | 一键 compose + 优雅停机 + HA + Makefile,缺 k8s/Helm 和 CI/CD | +| **文档** | ⭐⭐⭐⭐☆ | 多份分析/架构/部署/进度文档,缺 API 文档(Swagger)和用户手册 | + +--- + +## 六、优势亮点 🌟 + +### 1. 架构设计精良 +- **五层解耦**:Gateway/Dispatcher/MCP 职责清晰,NATS 骨干网松耦合 +- **全面降级**:每个依赖不可用都有 graceful degradation(Milvus 挂→不检索、Redis 挂→不缓存、Neo4j 挂→不图谱,从不阻断主流程) +- **热更新**:模型配置经 NATS 广播实时生效,无需重启 +- **契约驱动**:`sundynix-shared/contract` 统一定义所有跨服务数据结构与通信约定 + +### 2. AI 编排能力深度 +- **DSL 图引擎** 支持 11 种节点类型,拓扑排序 + branch 条件剪枝 + map 并行 fan-out +- **ReAct Agent** 与 MCP 工具目录自发现联动:新增工具只需在 MCP 注册,无需改编排代码 +- **Mem0 式记忆对账**:不是简单 kv 覆盖,而是 LLM 对账 ADD/UPDATE/DELETE + Importance 打分 + Recency 衰减 +- **自动评测闭环**:规则检查 → LLM-as-judge → 忠实度(幻觉检测) → 低分自动纠偏 → 评测结果落库 + +### 3. 安全治理(Harness)体系完整 +- **输入护栏**:Tier1(归一化反绕过 + 正则 + 黑名单) → Tier2(LLM 越狱裁决),两级过滤成本最优 +- **输出护栏**:跨分片流式脱敏(StreamRedactor),解决了流式场景密钥碎片泄漏的工程难题 +- **成本护栏**:单任务 token 硬上限 + 单用户日预算 + 熔断器,三重保护防成本失控 + +### 4. 可观测性一流 +- **全链路 Trace**:Gateway → NATS → Dispatcher → MCP,trace_id 跨消息总线传播 +- **执行轨迹(ExecEvent)**:每个节点、每次工具调用都有结构化事件,前端可实时点亮/回放 +- **Redis Stream 录制**:Token 流 + 轨迹事件录进 Redis → SSE 断点续传,根治"连晚/重连丢数据" + +### 5. 工程实践规范 +- 注释全中文,设计意图/降级策略/约束原因写得极其清楚 +- 错误处理一致:降级 vs 上抛 vs 快速拒绝各有明确策略 +- 接口抽象(`TokenSink`/`ToolCaller`/`StatusSink`/`LLM`)便于测试注入 +- 雪花 ID + GORM 软删 + BaseModel 统一规约 + +--- + +## 七、不足与改进方向 ⚠️ + +### 1. 测试覆盖需扩展 +- **现状**:关键路径有测试(e2e/集成/单测 3400 行),但占源码比 ~22% +- **缺失**:前端组件测试、Gateway handler 单测、MCP 工具完整覆盖 +- **建议**:引入覆盖率工具(go tool cover),目标核心包 ≥ 70% + +### 2. API 文档缺失 +- **现状**:无 Swagger/OpenAPI 文档,API 契约只能看代码 +- **建议**:集成 swag(Gin 生态),自动生成 API 文档 + +### 3. CI/CD 流水线未建 +- **现状**:`.github/` 目录存在但未见完整 CI 配置 +- **建议**:GitHub Actions — lint → test → build → push image → deploy staging + +### 4. 前端状态管理偏原始 +- **现状**:桌面端直接 fetch + useState,无全局状态管理 +- **建议**:引入 zustand 或 jotai,统一 WebSocket/SSE 状态 + +### 5. 知识库功能缺分片与多租户隔离 +- **现状**:知识库按 `owner/kb` 名称隔离,但 Milvus Collection 粒度较粗 +- **建议**:生产环境引入 Partition Key 或多 Collection 策略 + +### 6. Python 微服务较薄 +- **现状**:mcp-py 约 640 行,文档解析和代码沙箱为桩/初步实现 +- **建议**:完善 MinerU OCR 接入、MCP 协议标准化 + +### 7. 缺乏 k8s/Helm 部署方案 +- **现状**:仅 Docker Compose 编排 +- **建议**:为生产环境提供 Helm Chart,支持 HPA、PDB、健康检查 + +### 8. 缺少限流与 Rate Limiting +- **现状**:成本护栏有 token 预算,但 HTTP 层无速率限制 +- **建议**:Gin 中间件加速率限制(令牌桶/滑动窗口),配合 Redis + +### 9. 日志结构化不完整 +- **现状**:混用 `log.Printf` 与 `slog` +- **建议**:统一 slog + JSON 输出,便于 ELK/Loki 采集 + +### 10. 前端无 i18n +- **现状**:UI 文案硬编码中文 +- **建议**:如需国际化,引入 i18next + +--- + +## 八、与同类产品对标 + +| 对标产品 | sundynix-agentix 对比 | +|---------|---------------------| +| **Dify** | 架构更工程化(微服务 vs 单体),护栏/评测/纠偏闭环更完整,但 UI 完成度不如 Dify | +| **Coze** | 类似的 Agent 编排理念,sundynix 是完全开源自建可控,支持私有化部署 | +| **LangGraph** | sundynix 的 DSL 图引擎类似 LangGraph 的状态机,但额外内置 HITL/熔断/预算/评测 | +| **Mem0** | sundynix 的记忆系统借鉴 Mem0 的 consolidate 模式,且与编排引擎深度集成 | + +--- + +## 九、总结 + +经过 3 天的密集改造,项目已经从一个基础的 Agent 框架跃升到**准生产级的 AI Agent 编排平台**。核心突破集中在三个方面: + +1. **Harness 治理栈完整落地**:输入护栏(两级) → 输出脱敏(跨分片) → 自动评测 → 低分纠偏 → 熔断 → 预算控制,形成完整的「恒温器」闭环 +2. **可观测性达到业界标杆**:全链路 Trace + 执行轨迹 + Redis 回放 + 历史复盘 + 健康灯,任何一次运行都可以"时光倒流"复盘 +3. **部署与 HA 就绪**:Docker Compose 一键部署 12 个服务、优雅停机、多副本安全、密钥加密 + +项目最大的技术亮点是**将 AI 治理(安全/质量/成本)作为一等公民嵌入架构**,而非事后附加——这在开源 Agent 框架中相当稀缺。 + +> **一句话评价**:架构成熟度已达准生产级,补齐测试覆盖、API 文档和 CI/CD 后即可上线。 diff --git a/deploy-6.27.md b/deploy-6.27.md new file mode 100644 index 0000000..f07ed02 --- /dev/null +++ b/deploy-6.27.md @@ -0,0 +1,92 @@ +# sundynix-agentix 服务部署与基建解耦方案分析(2026-06-27) + +> **分析背景**:在当前版本的部署架构中,网关、调度中心、Go/Python MCP 服务连接底层基建(NATS/PostgreSQL/Redis/Milvus/Neo4j)的配置依赖于系统环境变量(`os.Getenv`)。在本地开发时回退至硬编码的 `localhost` 默认值,在生产环境则需通过 `.env` 和 `docker-compose.prod.yml` 强绑定注入。 +> +> **核心痛点**:缺乏应用启动时的动态引导机制,服务包与特定环境配置紧密耦合,无法在应用首次运行(如 Admin 控制台启动时)动态引导用户完成数据库和消息总线的连接配置并持久化,限制了软件的“开箱即用”体验。 + +--- + +## 一、 “鸡生蛋” 引导瓶颈分析 + +在微服务体系下,通过 Web UI 配置底层连接会遇到经典的**自引导(Bootstrapping)矛盾**: + +``` +┌────────────────────────────────────────────────────────┐ +│ 自引导矛盾 │ +├────────────────────────────────────────────────────────┤ +│ 1. Gateway 必须连接 Postgres 才能存储配置 │ +│ 2. Gateway 必须连接 NATS 才能与其它服务通信 │ +│ 3. 用户必须通过 Gateway API 才能访问 Admin 控制台 │ +│ │ +│ 结论:Gateway 连接“打通前”,无法使用基于系统的配置中心。 │ +└────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、 三种解耦设计方案对比 + +为了打破上述瓶颈,实现基建安装部署与服务的平滑解耦,有以下三种方案可供选择: + +### 方案 A:云原生标准模式(配置文件挂载) +- **设计原理**:服务在启动时,不直接读取环境变量,而是读取映射挂载到容器内的特定配置文件(如 `/etc/sundynix/config.yaml` 或 `.env`)。如果文件不存在,服务在控制台报错并退出。 +- **配置流转**: + ``` + [外部挂载卷/本地文件] ──> [服务加载] ──> [建立连接池] + ``` +- **适用场景**:面向 DevOps 工程师、IT 运维人员的私有化部署。 +- **优缺点**: + - *优点*:符合云原生 **12-Factor App** 规范,便于集成 K8s ConfigMap,逻辑简单、系统无额外状态。 + - *缺点*:需要操作服务器文件(通过命令行编辑),非技术人员上手门槛高。 + +### 方案 B:开箱即用引导模式(内存自备/SQLite 引导 + 重写配置文件) +- **设计原理**:网关在检测不到基建配置时,自动进入 **安装模式(Setup Mode)**。此时网关不需要外部 PG 或 NATS,只在内存中运行轻量 HTTP 服务,或用单文件本地数据库(SQLite)临时保存连接状态,并渲染出“安装向导”网页。 +- **配置流转**: + ``` + [无配置启动] ──> [启动本地 SQLite] ──> [Web 安装向导引导用户输入] + └─> [测试外部基建连接] ──> [成功] ──> [重写本地 env/config] ──> [自动重启服务] + ``` +- **适用场景**:面向企业普通 IT 管理员的商业化/独立包部署(类似于 WordPress、Jira、GitLab 的首次安装向导)。 +- **优缺点**: + - *优点*:用户体验极佳,不需要接触命令行,完全通过 Web 页面拖拽配置即可完成“一把拉起”。 + - *缺点*:服务内部逻辑变得复杂,代码需支持“Setup 状态”与“Running 状态”的切换;涉及重写宿主机配置并自动重启进程的权限问题。 + +### 方案 C:企业级微服务模式(外部配置中心) +- **设计原理**:应用包内只配置一个非常轻量的配置中心连接信息(如 Nacos, Consul, Apollo 地址),服务启动后从配置中心拉取 NATS、DB 的连接串,并监听连接变更。 +- **配置流转**: + ``` + [拉取配置中心连接] ──> [从 Consul/Nacos 获取 DB/NATS 凭据] ──> [热重载连接池] + ``` +- **适用场景**:大型企业分布式集群部署、SaaS 平台。 +- **优缺点**: + - *优点*:企业内网运维极其标准化,连接凭据集中管理,支持动态热变更。 + - *缺点*:引入了新的基建组件依赖(如必须先部署 Nacos),增加了中小微企业私有化部署的成本。 + +--- + +## 三、 对比矩阵与架构建议 + +| 维度 | 方案 A(配置文件挂载) | 方案 B(Web 安装向导) | 方案 C(外部配置中心) | +| :--- | :---: | :---: | :---: | +| **上手门槛** | 中等(需编辑文件) | **极低(Web 界面)** | 高(需运维配置中心) | +| **开发复杂度** | **极低** | 高(需状态机与重启机制) | 中等(需集成配置 SDK) | +| **部署维护性** | **优秀(云原生标准)** | 中等(文件读写权限限制) | 优秀(集中管理) | +| **额外基建依赖** | 无 | 无 | **有(Nacos/Consul)** | +| **企业接受度** | 80% | 95% | 60% | + +### 🛠️ 改造路线建议 + +为了兼顾开发效率与用户体验,建议采用**渐进式重构路线**: + +1. **短期优化(方案 A 的增强版)**: + 规范化 `sundynix-gateway/config/config.yaml` 的加载逻辑。网关启动时,依次尝试读取以下来源的配置: + 1. 系统环境变量(最高优先级,便于 Compose 和 K8s 部署) + 2. 挂载在外部的 `config.yaml`(宿主机持久化) + 3. 代码内硬编码的本地开发兜底值 + +2. **中期重构(方案 B,引入轻量引导)**: + 在网关增加 `Setup` 路由及状态机: + - 检查外部存储配置文件是否存在。若无,将网关状态置为 `StateSetup`。 + - 在 `StateSetup` 下,API 仅暴露 `/api/v1/setup` 供 Admin 端提交 Postgres DSN、Redis 地址和 NATS 凭据。 + - 提交后,网关在后台利用 Go 的底层 `driver` 测试连接有效性。 + - 通过测试后,使用 `os.WriteFile` 重写 `.env` 文件,并调用系统信号(或使用 Supervisor/Docker 重启机制)实现进程重启,从而无缝滑入正常运行状态。 diff --git a/desktop-industrial-plan.md b/desktop-industrial-plan.md deleted file mode 100644 index 4a86158..0000000 --- a/desktop-industrial-plan.md +++ /dev/null @@ -1,62 +0,0 @@ -# 桌面端工业化升级规划(desktop-industrial-plan) - -承接 [desktop-ui-plan.md](desktop-ui-plan.md)(IA/能力规划)。本文聚焦把现有桌面端从 -"深色控制台 MVP"打磨到**工业级桌面产品**的质量提升路线。 - -> 现状基线(2026-06-12 实测):`sundynix-desktop/frontend` = React 19 + TS + Tailwind + -> @xyflow/react,**无 UI 组件库**(README 写的 shadcn/ui 实际未安装),图标用 Unicode 字符, -> Wails Go 桥已生成(`wailsjs/`)但前端从未调用。已有视图:工作台/编排/知识库/报告/运行·观测/记忆。 - -## 诊断:为什么"不够工业级" - -问题不在配色,而在**缺产品级骨架**: - -1. **无设计系统** — 按钮/输入/卡片/徽标全是手搓内联 class、复制粘贴、不一致;强调色 - (violet/cyan) 是散落字面量,无法换肤。 -2. **图标是 Unicode 字符**(▤◆▣▦▸◇⌧⚙)— 最掉档次。 -3. **Wails Go 桥未用** — 本质是"套了窗口的网页":无原生文件框、拖拽入库、本地读盘、 - 原生通知、自定义标题栏、"用系统 Word 打开报告"。这是"工业级桌面"最大缺口。 -4. **无应用级 UX** — 无 toast / 全局错误边界 / 命令面板(⌘K) / 快捷键 / 确认弹窗 / 骨架屏。 -5. **健康灯 5 个只 2 个真**(NATS/Milvus/Neo4j 灰)— 缺网关 `/health` 聚合。 -6. **状态全在 App useState,刷新即丢** — 无运行历史/最近项持久化。 -7. **输出裸 `
`** — 报告是 Markdown 却不渲染;无自动滚动/复制/速率。
-8. **布局密度** — 为 1280 设计但窗口 min 1100,面板挤;固定高度;无可拖拽分隔。
-
-## 升级阶段(按性价比/依赖排序)
-
-### A. 设计系统地基(最高优先,其它都依赖它)
-- 装 `lucide-react` 图标;自建轻量 primitives(不引整套 shadcn,保零重依赖、clone 即跑):
-  `Button` `Input` `Textarea` `Select` `Card` `Badge` `Tabs` `Tooltip` `Dialog` `Toast`
-  `Skeleton` `EmptyState`,统一 `cn()`。
-- Tailwind 令牌补全:语义色(brand/accent/success/warn/danger)+ 半径/间距/字阶 token;
-  强调色字面量收敛到 token。
-- 6 个视图 + shell 迁到 primitives,去掉复制粘贴。
-
-### B. 真·桌面集成("工业级桌面"最大缺口)
-- 启用 Wails Go 桥:`app.go` 增 `OpenFileDialog`/`SaveFileDialog`/`SaveReport`/
-  `OpenInSystem`/`ReadFile`,前端经 `wailsjs` 调。
-- 知识库**拖拽 + 原生选择框**入库;报告**原生"另存为 .docx"** + "用系统 Word 打开"。
-- 原生通知(报告完成弹系统通知)、最近文件、窗口标题随任务变。
-- 自定义无边框标题栏(macOS 红绿灯内嵌 + 可拖拽区)。
-
-### C. 应用级 UX 骨架
-- 全局 `ToastProvider`(替代散落 err 文本)、`ErrorBoundary`、确认 `Dialog`。
-- 命令面板 `⌘K`(跳转/新建报告/搜知识库/运行)+ 全局快捷键。
-- 骨架屏 + 有插画的空状态(取代"规划中"文字)。
-
-### D. 内容与可信度
-- Markdown 渲染(报告/输出)+ 代码高亮 + 复制 + 流式自动跟随底部 + tokens/s 速率表。
-- 网关 `GET /health` 聚合 NATS/Milvus/Neo4j/PG/Redis → 5 灯全真。
-- 运行历史持久化(Wails 配置或 localStorage),RunsView 列出历史可回看。
-
-### E. 布局与密度
-- 可拖拽分隔的面板、响应式断点、记忆面板宽度。
-
-## 建议路线
-先 **A(设计系统)** 打底 → **B(真桌面集成)**,这两个一起把"网页套壳"扭转成"工业级桌面应用",
-感知提升最大。C/D/E 增量补。
-
-## 边界/原则
-- **零重依赖**:自建 primitives 而非整套组件库,守住 "clone 即跑、无重 native 依赖" 目标。
-- 不破坏既有真实链路(任务流/工具调用/Token 流/执行事件/报告)。
-- 深色优先;令牌化后保留未来切浅色的可能。
diff --git a/desktop-ui-plan.md b/desktop-ui-plan.md
deleted file mode 100644
index 6753ca5..0000000
--- a/desktop-ui-plan.md
+++ /dev/null
@@ -1,133 +0,0 @@
-# sundynix-agentix · 桌面端 UI 规划
-
-> 第 1 层 Client(`sundynix-desktop`,Wails + React 19 + React Flow)的产品级 UI 规划。
-> 目标:不止是连 Gateway 的 UI,而是承载 **RAG 知识库 / 报告生成 / 垂直行业平台级复制** 的驾驶舱。
-> 定位已定:**真 Desktop** —— 用上 Wails 本地能力(文件 I/O、TS/Go 强绑定、本地配置、离线嵌入)。
-
-## 0 · 设计原则 & 角色
-
-- **三类用户,一个壳**:**构建者**(搭 Agent 图、建知识库)/ **操作者**(跑工作流、看报告)/ **管理员**(开垂直、管租户)。UI 按 BUILD / RUN / MANAGE 分组,按角色露不同模块。
-- **专业工具范式**:IDE / 节点编辑器布局——左导航 + 主区 + 右检查器 + 底部抽屉。信息密度高。
-- **可信优先**:RAG 必带**引用**;记忆**可看可改**;生成**有评测门**。透明是平台级底线。
-- **垂直白标**:整个壳由当前垂直 Pack 的配置驱动(名称/主题/启用模块/术语/默认模板)。换行业 = 换配置,不改 UI。
-
-## 1 · 信息架构(左导航)
-
-```
-■ 工作台          首页:概览 / 最近 / 快捷
-─ BUILD ─
-◆ 编排 Studio     React Flow Agent 图构建器(核心)
-▣ 知识库 (RAG)    KB 管理 / 入库 / 检索调试 / 图谱
-▤ 报告            模板 / 生成 / 产出库
-─ RUN ─
-▸ 运行 · 观测     实时执行 / 轨迹 / 历史
-─ MANAGE ─
-◇ 记忆            画像 / 会话历史
-⌧ 市场 · Packs    垂直包 / Agent 模板 / 开通
-⚙ 管理            租户 / 用户计费 / 护栏 / 模型 / 设置
-```
-
-## 2 · 全局外壳
-
-```
-┌──────────────────────────────────────────────────────────────────┐
-│ [垂直▾ 法律版] · 搜索/命令(⌘K) · ●GW ●NATS ●Milvus ●Neo4j · 用户▾ │ 顶栏
-├────┬──────────────────────────────────────────┬───────────────────┤
-│ 左 │              主区(随模块切换)              │   右:检查器/详情   │
-│ 导 ├──────────────────────────────────────────┴───────────────────┤
-│ 航 │   底部抽屉:输出 / 轨迹 / 工具调用 / 引用 / 评测   ▴展开▾收起    │
-└────┴──────────────────────────────────────────────────────────────┘
-```
-- **垂直切换器**:切租户上下文 + 白标。
-- **⌘K 命令面板**:跳转 / 跑模板 / 开库 / 查文档。
-- **健康灯**:GW/NATS/Milvus/Neo4j/LLM 在线状态(可观测性)。
-- **底部抽屉全局常驻**:任何模块跑任务,流式/轨迹/引用都在此看。
-
-## 3 · 各模块详规
-
-### ① 工作台
-概览卡(KB 数 / 文档数 / 近期运行 / 待办报告 / 配额计费)· 快捷区(跑模板 / 新建图 / 入库)· 最近列表。
-
-### ② 编排 Studio(核心)
-左节点面板 · 中画布 · 右检查器 · 顶工具栏。
-
-节点类型(按颜色分类,沿用架构图配色):
-
-| 节点 | 干什么 | 配置(检查器) |
-|---|---|---|
-| 输入 | 用户提问 / 文件 / 变量 | 来源、占位符 |
-| 检索(RAG) | 绑定某 KB 混合检索 | 选 KB、topK、融合权重、rerank、元数据过滤 |
-| Agent/LLM | 调模型生成 | 模型、系统提示词、温度、可用工具 |
-| 工具 | 绑定 MCP 工具 | parse_document/render_doc/外部API… + 参数 |
-| 记忆 | 注入画像/历史/语义 | 勾选注入项 |
-| 分支 | 条件路由 | 条件表达式 |
-| 并行/Map | fan-out(逐章节) | 拆分依据 |
-| 汇聚 | reduce/合并 | 合并策略 |
-| 渲染 | UniOffice 出 docx/pdf | 模板、样式 |
-| 输出 | 展示/导出/落盘 | 目标(屏/文件) |
-| 子图 | 嵌套可复用模板 | 选模板 |
-
-顶工具栏:加节点 · ▶运行 · ■停止 · 保存/打开 `.dsl.json`(Wails 落盘) · **校验**(按后端 DSL schema 标红) · 版本。
-运行时:节点逐个点亮 + 状态徽标(待/跑/流/完/错/降级) + token 数/耗时。
-
-### ③ 知识库(RAG)—— 流水线,每段都露出
-左 KB 列表 · 中主区(Tab) · 右文档/块详情。
-- **入库监控**:拖文件/文件夹 → 流水线进度(解析→切块→向量化→三路建索引)、队列深度、失败重试。
-- **检索调试台**:query → 三路召回 + 融合 + rerank 中间结果,每条带来源徽标(Bleve/Milvus/Neo4j)+ 分数。
-- **文档/块浏览**:切块、向量邻居、引用回链。
-- **知识图谱**:Neo4j 实体-关系可视化(GraphRAG 的脸)。
-- **检索评测**:跑 golden 集,召回质量/忠实度(接 harness eval)。
-
-### ④ 报告 —— 长程多节点编排的成品界面
-左大纲树 · 中预览 · 右章节检查器;顶部进度。
-- 模板库(尽调/行业分析/合规,预制 Eino 子图)· 大纲编辑(增删改/拖排序/指定 KB)。
-- 生成中:各章节独立进度(检索/撰写/复核),并行 fan-out 可见。
-- 中预览:实时拼装(目录/图表/表格/内联引用)。
-- 导出条:→ docx/pdf(UniOffice) → Wails 原生保存落盘。产出库:历史/重跑/版本对比。
-
-### ⑤ 运行 · 观测
-实时回 Studio 看节点点亮 + 抽屉五 Tab:输出(SSE) / 轨迹(节点时间线) / 工具调用 / 引用 / 评测。运行历史可复盘。
-
-### ⑥ 记忆(透明可控)
-"模型记得我什么"(画像逐条,改/删,来源)· 会话 & 历史(多轮,可清空)· 注入开关。
-
-### ⑦ 市场 · Packs(垂直复制入口)
-垂直包市场(法律/医疗/金融包)· Agent 模板市场(拉进 Studio 当子图)· 开通向导(选包→配置→建租户→入库→注册模板→应用配置)。
-
-### ⑧ 管理
-租户/工作区(多租户隔离/配额)· 用户 & 计费 · 护栏(脱敏/免责/强制引用)· 模型 & 连接(LLM 池/embedding/后端地址/健康)· 设置(本地目录/主题/离线)。
-
-## 4 · 跨模块复用组件(设计系统)
-
-引用 chip `[1]`(悬浮:源文档+块+分数+来源徽标)· 来源徽标(三色) · 节点卡(统一状态机) · 流式 token 视图 · KB/模型/工具选择器 · 运行时间线(甘特) · 评测徽章。
-
-## 5 · 垂直白标
-顶栏切垂直 → 壳按 Pack 的 `branding` 重渲染:名称/Logo、主色、启用模块、术语标签、默认模板、护栏横幅。同一引擎,不同皮 + 内容。
-
-## 6 · 状态设计
-空态(引导) · 加载/流式(骨架+token+进度) · 降级(横幅:"语义检索降级,已用全文兜底") · 错误/重试。
-
-## 7 · 落地优先级
-
-| 层级 | 做什么 | 价值 |
-|---|---|---|
-| **MVP** | 外壳(顶栏+导航+健康灯+⌘K) · ② Studio 类型化节点+检查器+校验 · 运行抽屉(输出+轨迹) | 搭图→跑→看 闭环 |
-| **平台核心** | ③ 知识库(入库+检索台+引用) · ④ 报告(模板+大纲+预览+导出) · ⑥ 记忆做厚 | RAG/报告 成形 |
-| **平台级** | ⑦ 市场+开通向导 · ⑧ 多租户/护栏 · ⑤ 白标 · 图谱/评测台 | 垂直复制 |
-
-> UI 的"平台核心/平台级"依赖后端先就绪:③④依赖 RAG 核心链(embedding + 入库 worker + 真实混合检索),⑦依赖多租户 + Pack 格式。UI 与后端并行排,在"知识库/报告"界面会合。
-
-## 8 · 后端依赖链(提醒)
-
-```
-Embedding + 真实 LLM   ← 命门(现为桩)
-   ↓
-RAG 核心(入库worker + 真Hybrid检索 + rerank)   ← 拱心石
-   ↓
-报告生成(多节点 Eino 图 + UniOffice 渲染)
-   ↓
-多租户 + Pack 格式 + Provisioning   ← 垂直复制
-```
-
----
-*(本规划随实现推进更新。当前进度见 git log 与 memory。)*
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..2e3e95a
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,183 @@
+# sundynix-agentix · 生产/独立部署编排(应用 + 基建一把拉起)
+#
+#   cp .env.example .env   # 然后按提示填密钥(见 DEPLOY.md)
+#   docker compose -f docker-compose.prod.yml up -d --build
+#
+# 设计:
+# - 应用经服务名互连(postgres:5432 等),不依赖 127.0.0.1;基建端口不对宿主暴露(仅内网)。
+# - 仅暴露 admin 控制台(:${HTTP_PORT}) 与 gateway(:${GATEWAY_PORT},供桌面端直连) + Jaeger UI(可选)。
+# - 引导密钥从 .env 注入(SUNDYNIX_SECRET_KEY/JWT_SECRET/各库密码);模型 API key 仍在控制台配、加密存 PG。
+# - APP_ENV=production → 强制校验密钥、锁 CORS、启用管理员白名单。
+
+x-secret-key: &secret-key ${SUNDYNIX_SECRET_KEY:?需在 .env 设置(openssl rand -base64 32)}
+
+services:
+  # ---------- 应用层 ----------
+  gateway:
+    build: { context: ., dockerfile: sundynix-gateway/Dockerfile }
+    image: sundynix/gateway:${TAG:-latest}
+    restart: unless-stopped
+    environment:
+      APP_ENV: production
+      GATEWAY_ADDR: ":8080"
+      NATS_URL: nats://nats:4222
+      POSTGRES_DSN: postgres://sundynix:${POSTGRES_PASSWORD:?}@postgres:5432/sundynix?sslmode=disable
+      REDIS_ADDR: redis:6379
+      MINIO_ENDPOINT: minio:9000
+      MINIO_ACCESS_KEY: minioadmin   # minio 仅 compose 内网可达,不对宿主暴露
+      MINIO_SECRET_KEY: minioadmin
+      SUNDYNIX_SECRET_KEY: *secret-key
+      JWT_SECRET: ${JWT_SECRET:?需在 .env 设置(openssl rand -base64 32)}
+      ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
+      CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
+      OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
+    ports: ["${GATEWAY_PORT:-8080}:8080"]
+    depends_on:
+      postgres: { condition: service_healthy }
+      redis: { condition: service_started }
+      nats: { condition: service_started }
+
+  dispatcher:
+    build: { context: ., dockerfile: sundynix-dispatcher/Dockerfile }
+    image: sundynix/dispatcher:${TAG:-latest}
+    restart: unless-stopped
+    environment:
+      APP_ENV: production
+      NATS_URL: nats://nats:4222
+      SUNDYNIX_SECRET_KEY: *secret-key
+      OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
+      DISPATCHER_CONCURRENCY: ${DISPATCHER_CONCURRENCY:-8}
+      TASK_TOKEN_BUDGET: ${TASK_TOKEN_BUDGET:-200000}
+    depends_on:
+      nats: { condition: service_started }
+
+  mcp-go:
+    build: { context: ., dockerfile: sundynix-mcp-go/Dockerfile }
+    image: sundynix/mcp-go:${TAG:-latest}
+    restart: unless-stopped
+    environment:
+      APP_ENV: production
+      NATS_URL: nats://nats:4222
+      POSTGRES_DSN: postgres://sundynix:${POSTGRES_PASSWORD:?}@postgres:5432/sundynix?sslmode=disable
+      REDIS_ADDR: redis:6379
+      MILVUS_ADDR: milvus:19530
+      NEO4J_URI: neo4j://neo4j:7687
+      NEO4J_USER: neo4j
+      NEO4J_PASS: ${NEO4J_PASSWORD:?}
+      SUNDYNIX_SECRET_KEY: *secret-key
+      OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
+    depends_on:
+      nats: { condition: service_started }
+      milvus: { condition: service_healthy }   # mcp-go 须在 Milvus 后起,否则工具 no responders
+
+  mcp-py:
+    build: { context: sundynix-mcp-py, dockerfile: Dockerfile }
+    image: sundynix/mcp-py:${TAG:-latest}
+    restart: unless-stopped
+    environment:
+      NATS_URL: nats://nats:4222
+    # secure_sandbox(代码隔离执行)需访问宿主 Docker 拉起一次性容器。
+    # 安全敏感:等于把宿主 Docker 暴露给该服务;不需要代码执行工具可注释掉这行。
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+    depends_on:
+      nats: { condition: service_started }
+
+  admin:
+    build: { context: sundynix-admin, dockerfile: Dockerfile }
+    image: sundynix/admin:${TAG:-latest}
+    restart: unless-stopped
+    ports: ["${HTTP_PORT:-80}:80"]   # 运维控制台 + 反代网关 API(一个 URL 访问整套)
+    depends_on:
+      gateway: { condition: service_started }
+
+  # ---------- 基础设施(端口不对宿主暴露,仅 compose 内网) ----------
+  nats:
+    image: nats:2-alpine
+    command: ["-c", "/etc/nats/nats-server.conf"]
+    restart: unless-stopped
+    volumes:
+      - ./deploy/nats/nats-server.conf:/etc/nats/nats-server.conf:ro
+      - nats_data:/data/jetstream
+
+  postgres:
+    image: postgres:16-alpine
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: sundynix
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
+      POSTGRES_DB: sundynix
+    volumes: ["pg_data:/var/lib/postgresql/data"]
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U sundynix"]
+      interval: 5s
+      timeout: 5s
+      retries: 10
+
+  redis:
+    image: redis:7-alpine
+    restart: unless-stopped
+    volumes: ["redis_data:/data"]
+
+  jaeger:
+    image: jaegertracing/all-in-one:1.60
+    restart: unless-stopped
+    environment:
+      COLLECTOR_OTLP_ENABLED: "true"
+    ports: ["${JAEGER_UI_PORT:-16686}:16686"]   # 链路追踪 UI(演示可看 span 瀑布图;不需要可去掉)
+
+  milvus-etcd:
+    image: quay.io/coreos/etcd:v3.5.14
+    restart: unless-stopped
+    environment:
+      ETCD_AUTO_COMPACTION_MODE: revision
+      ETCD_AUTO_COMPACTION_RETENTION: "1000"
+      ETCD_QUOTA_BACKEND_BYTES: "4294967296"
+      ETCD_SNAPSHOT_COUNT: "50000"
+    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
+    volumes: ["milvus_etcd:/etcd"]
+
+  minio:
+    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
+    restart: unless-stopped
+    environment:
+      MINIO_ACCESS_KEY: minioadmin   # 内网专用(不对宿主暴露端口),与 milvus/gateway 默认对齐
+      MINIO_SECRET_KEY: minioadmin
+    command: minio server /minio_data
+    volumes: ["minio_data:/minio_data"]
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 30s
+      timeout: 20s
+      retries: 3
+
+  milvus:
+    image: milvusdb/milvus:v2.4.13
+    command: ["milvus", "run", "standalone"]
+    restart: unless-stopped
+    environment:
+      ETCD_ENDPOINTS: milvus-etcd:2379
+      MINIO_ADDRESS: minio:9000
+    volumes: ["milvus_data:/var/lib/milvus"]
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
+      interval: 15s
+      timeout: 10s
+      retries: 10
+    depends_on: [milvus-etcd, minio]
+
+  neo4j:
+    image: neo4j:5-community
+    restart: unless-stopped
+    environment:
+      NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?}
+    volumes: ["neo4j_data:/data"]
+
+volumes:
+  nats_data:
+  pg_data:
+  redis_data:
+  milvus_etcd:
+  minio_data:
+  milvus_data:
+  neo4j_data:
diff --git a/docker-compose.yml b/docker-compose.yml
index 97e2fe3..850dcdf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,6 +23,16 @@ services:
     image: redis:7-alpine
     ports: ["6379:6379"]
 
+  # --- Jaeger 分布式链路追踪 (all-in-one:OTLP 收集 + 存储 + 查询 UI) ---
+  jaeger:
+    image: jaegertracing/all-in-one:1.60
+    environment:
+      COLLECTOR_OTLP_ENABLED: "true"   # 开启 OTLP 收集端点(各服务经此上报 span)
+    ports:
+      - "16686:16686"   # Web UI:浏览器看 span 瀑布图
+      - "4318:4318"     # OTLP/HTTP 入口(各 Go 服务默认上报到这里)
+      - "4317:4317"     # OTLP/gRPC 入口(备用)
+
   # --- Milvus 向量数据库 (standalone 需 etcd + minio) ---
   milvus-etcd:
     image: quay.io/coreos/etcd:v3.5.14
diff --git a/go.work b/go.work
index 0f3d4eb..9abdd3f 100644
--- a/go.work
+++ b/go.work
@@ -1,7 +1,8 @@
-go 1.25.0
+go 1.25.8
 
-// 后端运行链路的 4 个 Go 模块。sundynix-desktop(Wails) 不入工作区:
-// 它需先 npm run build 生成 frontend/dist 供 //go:embed,单独 wails build 即可。
+// 后端运行链路的 4 个 Go 模块。sundynix-desktop(Wails v3) 不入工作区:
+// 它需先构建前端生成 frontend/dist 供 //go:embed,单独 GOWORK=off wails3 package 即可
+// (wails3 的任务链会自己带上 npm install + 前端构建 + generate:bindings)。
 use (
 	./sundynix-dispatcher
 	./sundynix-gateway
diff --git a/go.work.sum b/go.work.sum
index 1b72334..20d8d87 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1,3 +1,155 @@
+4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
+4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
+cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
+cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
+cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
+cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
+cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
+cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
+cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
+cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
+cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
+cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
+cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
+cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
+cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
+cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
+cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
+cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
+cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
+cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic=
+cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
+cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
+cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
+cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
+cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
+cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
+cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
+cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
+cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s=
+cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
+cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
+cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
+cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
+cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
+cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
+cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o=
+cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4=
+cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
+cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
+cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
+cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
+cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
+cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ=
+cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
+cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
+cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
+cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
+cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
+cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
+cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
+cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
+cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
+cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
+cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
+cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
+cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
+cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
+cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
+cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
+cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
+cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
+cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
+cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
+cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
+cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
+cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
+cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
+cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao=
+cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
+cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
+cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
+cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
+cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
+cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
+cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg=
+cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
+cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
+cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
+cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
+cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
+cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
+cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
+cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
+cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
+cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
+cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
+cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
+cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
+cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
+cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU=
+cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
+cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
+cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
+cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
+cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
+cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
+cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
+cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk=
+cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
+cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
+cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
+cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY=
+cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
+cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
+cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
+cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
+cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
+cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
+cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
+cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
+cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
+cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
+cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
+cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
+cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
+cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
+cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
+cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
+codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
+codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
+dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
+dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE=
+github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
+github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4=
+github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY=
+github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
+github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ=
+github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
+github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4=
+github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
+github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
+github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
+github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
+github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
+github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
+github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
+github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
+github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
+github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
+github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
+github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
+github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
+github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
 github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:kDy+zgJFJJoJYBvdfBSiZYBbdsUL0XcjHYWezpQBGPA=
 github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A=
 github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI=
@@ -6,55 +158,246 @@ github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLs
 github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg=
 github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc=
 github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc=
+github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
+github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
+github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o=
+github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
+github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
+github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
+github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
 github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
 github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
 github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
+github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
+github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
 github.com/couchbase/ghistogram v0.1.0 h1:b95QcQTCzjTUocDXp/uMgSNQi8oj1tGwnJ4bODWZnps=
 github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
 github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o=
 github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
 github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
+github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
+github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ=
+github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
+github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
+github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
+github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
+github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
+github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
+github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw=
+github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY=
+github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4=
+github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA=
+github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
+github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
+github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
+github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
+github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
+github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8=
+github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
+github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
+github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
+github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
+github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk=
+github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
+github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw=
+github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
+github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc=
+github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY=
+github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
+github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
+github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
+github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
+github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
+github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
+github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
+github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
+github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w=
+github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
 github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo=
 github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
+github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
+github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
+github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
+github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
+github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
+github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
+github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
+github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
+github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
+github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
+github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
+github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
+github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc=
+github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
+github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
+github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
+github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw=
 github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
+github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
-github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
+github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
+github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng=
+github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
+github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
+github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
+github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU=
+github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
+github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
+github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
+github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE=
+github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
+github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
+github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
 github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
 github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
+github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
+github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
+github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
+github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
+github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
+github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
+github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
+github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
 github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
+github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
+github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
+github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
+github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
+github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
+github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
+github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
+github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
+github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
+go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
+go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
+go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
+go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
+go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
+go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
 go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
 go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
 golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
 golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
 golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
 golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
 golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
@@ -65,6 +408,9 @@ golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -80,19 +426,35 @@ golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
 golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
 golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
 golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
 golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
 golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
 golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
 golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
 golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
 golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
 golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
+google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
+mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
+mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU=
 nullprogram.com/x/optparse v1.0.0 h1:xGFgVi5ZaWOnYdac2foDT3vg0ZZC9ErXFV57mr4OHrI=
 rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/memory_industry_analysis.md b/memory_industry_analysis.md
index 265d865..dc067df 100644
--- a/memory_industry_analysis.md
+++ b/memory_industry_analysis.md
@@ -226,3 +226,83 @@ memory_upsert 时同时写入 Neo4j:
 > **最务实的改造优先级**:Phase 1(Consolidate)> Phase 2(语义检索)> Phase 3(图谱)。
 > Phase 1 改动最小(只改 `memory_extract.go` 的抽取 prompt 和处理逻辑),
 > 但效果最显著——直接解决记忆腐烂这个最大的 Day-2 问题。
+
+---
+
+# 落地方案(修正版 · 实施计划)
+
+> 在上面分析基础上做了三处修正:**① Consolidate 改异步攒批(不逐轮,省成本);② DELETE 用 soft-delete(失效不物删);③ Phase 2 语义检索暂缓(当前画像小,全量注入够用)**。并把读路径目标定为 Generative Agents 检索打分公式。
+
+## 设计四原则
+
+1. **对账而非盲写** —— 引入 Consolidate(ADD / UPDATE / DELETE / NOOP),解决 exact-key 去重不可控导致的记忆腐烂。
+2. **异步攒批而非逐轮** —— 每 N 轮跑一次"抽取+对账",一次 LLM 调用,省成本,对齐 ChatGPT 周期整理。
+3. **失效不物删** —— DELETE = GORM 软删(`BaseModel.DeletedAt` 已具备,零成本),白拿审计/可恢复。
+4. **按当下规模右尺寸** —— 小画像继续全量注入;语义检索/图谱留接口、暂不做。
+
+## 数据模型(扩 mcp-go 的 `Profile`)
+
+```go
+type Profile struct {
+    BaseModel              // 已含 id/created/updated/deleted_at(软删)
+    UserID     string
+    Key        string      // (user_id,key) 唯一
+    Value      string
+    Importance float64     // 1~10,consolidate 时 LLM 打分(poignancy)→ 读路径权重
+    Confidence float64     // 0~1 信念强度(可选,强弱偏好)
+    Source     string      // "user"(显式) / "extracted"(推断)
+    LastSeenAt time.Time   // 最近被印证时间 → Recency 衰减依据
+}
+```
+
+## 写路径:异步攒批 Consolidate(核心)
+
+```
+每轮结束 → 只 append history(已有,便宜)
+            ↓ 每 N 轮(用 history 长度判断,无需新基建)
+        Consolidate(1 次 LLM 调用,extract + reconcile 合一,Mem0 式):
+          输入 = 近 K 轮对话 + 当前 active 画像(带 key/importance)
+          输出 = [{op: ADD|UPDATE|DELETE|NOOP, key, value, importance, reason}]
+            ↓ 按 op 执行
+          ADD/UPDATE → memory_upsert(带 importance, last_seen=now)
+          DELETE     → memory_delete(软删)
+          NOOP       → bump last_seen(印证即强化)
+```
+
+- 决策(LLM 对账)在 dispatcher;落库在 mcp-go 工具(新增 `memory_delete` 软删 + `memory_upsert` 收 importance)。
+- 触发:`memorize` 里 append 后看 `history_get` 长度,跨 N 轮才跑。
+
+## 读路径目标 ★ Generative Agents 检索打分公式
+
+最终读路径按此公式排序、取 top-K(论文 Park et al. 2023):
+
+```
+Score = w1·Relevance + w2·Recency + w3·Importance
+```
+
+| 项 | 取值 | 本项目映射 | 何时上 |
+|---|---|---|---|
+| **Importance** | 写入时 LLM 打 1~10(存下,不在检索时算)| `Profile.Importance`,consolidate 顺手打分 | **P1 就存** |
+| **Recency** | `decay^(距 last_seen)` 指数衰减 | `LastSeenAt` + 衰减 | **P2** |
+| **Relevance** | query×memory 余弦相似 | 需 embedding → Milvus | **P3(画像大才上)** |
+
+- 三项各 min-max 归一到 [0,1],加权求和;**w1/w2/w3 设为可配**。
+- 现在画像小、全量注入:先用 **Recency + Importance** 两项排序/截断/遗忘(无向量、便宜);画像变大后补 Relevance 凑齐三项。
+- `memory_get(user_id, query?)` 签名预留 `query`,将来接 Milvus 不破坏调用方。
+
+## 分阶段
+
+| 阶段 | 内容 | 解决 |
+|---|---|---|
+| **P1** | 异步攒批 Consolidate(ADD/UPDATE/DELETE/NOOP)+ soft-delete + Profile 加 Importance/LastSeenAt(consolidate 时 LLM 打 importance 分)| 记忆腐烂 + 成本 |
+| **P2** | 读路径:按 Score(Recency+Importance) 排序 + 衰减遗忘 + 截断 top-N;桌面端记忆面板可看/改/删 | 噪声 + 遗忘 + 用户控制 |
+| **P3(暂不做)** | 补 Relevance:memory 存时 embedding→Milvus,读时 query top-K,凑齐三项公式;Neo4j 图谱记忆 | 大规模 / 关系推理 |
+
+**故意不做**:双时态完整模型(soft-delete + last_seen 已够)、Letta 式 Agent 自管理(与现有外部编排架构不合)。
+
+## P1 改动点(下午动手)
+
+- `mcp-go/internal/memory/store.go`:`Profile` 加 `Importance/LastSeenAt`;`Upsert` 收 importance + 置 last_seen;新增 `Delete`(软删)。
+- `mcp-go/internal/mcp/gateway.go`:新增 `memory_delete` 工具;`memory_upsert` 透传 importance。
+- `dispatcher/internal/eino/memory_extract.go`:`extractMemory` → `consolidateMemory`:合并 extract+reconcile 为一次 LLM 调用,输出 op 列表(含 importance 打分),按 op 调 upsert/delete;触发改为每 N 轮(看 history 长度)。
+- 纯逻辑(op 解析 / 应用决策)可单测。
diff --git a/production_readiness.md b/production_readiness.md
new file mode 100644
index 0000000..76c7988
--- /dev/null
+++ b/production_readiness.md
@@ -0,0 +1,677 @@
+# sundynix-agentix · 工业生产级演进蓝图
+
+> 基于对当前代码库的深度审计(6 个服务模块 + 共享层 + 基础设施),从 8 个维度系统梳理"从高完成度 MVP 到工业生产级"还需引入的能力。
+
+---
+
+## 当前基线评估
+
+在分析差距之前,先盘点**已有的生产级基因**(这些是优势,不需要从零开始):
+
+| ✅ 已有能力 | 现状 |
+|---|---|
+| Prometheus 指标 | Gateway 已埋 `sundynix_http_requests_total` / `duration` / `in_flight` 三个核心指标 ([observability.go](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-gateway/internal/middleware/observability.go)) |
+| 结构化访问日志 | Gateway 已用 `slog.JSONHandler` 输出包含 request_id/method/route/status/latency 的 JSON 日志 |
+| Request ID 透传 | `X-Request-ID` 生成 + 上下文注入已实现 |
+| 熔断降级 | Dispatcher 有完整的三态熔断器(Closed → Open → HalfOpen)([circuitbreaker.go](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-dispatcher/internal/harness/circuitbreaker.go)) |
+| 输入护栏 | 正则检测提示词注入 + 超大请求体拦截 ([guardrail.go](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-gateway/internal/guardrail/guardrail.go)) |
+| 输出护栏 | 流式脱敏疑似密钥/令牌 ([output.go](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-dispatcher/internal/harness/output.go)) |
+| LLM 自动评测 | 规则 + LLM-as-judge 双路评分 ([eval.go](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-dispatcher/internal/harness/eval.go)) |
+| CI/CD 基础 | GitHub Actions CI(Go build+vet+test / 前端 tsc / Python sandbox test)+ Release(Wails 跨平台构建)|
+| 限流 | Redis 会话级 IP 限流 120/min ([middleware/guardrail.go](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-gateway/internal/middleware/guardrail.go#L39-L51)) |
+| 安全沙箱 | Python Code Interpreter 有 Docker 隔离(禁网/non-root/丢 cap/限内存 CPU PID/只读根/tmpfs)+ AST 静态守卫 |
+| 管理员权限 | RequireAdmin 中间件 + 生产模式强制显式配置 `ADMIN_USER_IDS` |
+
+---
+
+## 一、可观测性 (Observability)
+
+> **目标**:任何线上问题在 5 分钟内能定位到根因节点。
+
+### 1.1 分布式链路追踪 (Tracing) — ✅ 已实现
+
+**已落地**:OpenTelemetry 全链路追踪,一次任务从 Gateway → NATS → Dispatcher → MCP 工具的调用链在 Jaeger 里呈现为一棵 span 树。
+- 启动器 `sundynix-shared/otelx`:各服务 main 调 `otelx.Init(ctx, "服务名")`,OTLP/HTTP 导出到 `OTEL_EXPORTER_OTLP_ENDPOINT`(默认 `localhost:4318`,docker 里的 Jaeger);`OTEL_SDK_DISABLED=true` 整体关闭导出(仍保留传播器)。Jaeger UI: `http://localhost:16686`。
+- **HTTP 入口**:gateway 挂 `otelgin` 中间件,每个请求一个 server span(链路根 + 提取上游 traceparent)。
+- **NATS 跨进程传播**(关键):`sundynix-shared/bus` 在 `PublishTask`/`CallTool` 把 W3C traceparent 注入消息头,`ConsumeTasks`/`ServeTool` 抽出续上——这是链路能跨总线连成一棵树的核心。
+- **Eino 图埋点**:`task.execute` → 每个 `node.` → `tool.call`/`tool.serve`(跨服务成对)→ `llm.stream`/`llm.generate`,层级与 ExecEvent 对齐。
+- 实测一次「input→retriever→agent」任务出 14 span / 3 服务,瓶颈(kb_search 692ms、llm 1597ms)一眼可见。
+
+- **日志 ↔ 链路互跳**:`otelx.SetupSlog(服务名)` 安装链路感知的全局 slog(JSON + service 标签),`traceHandler` 让任意 `slog.InfoContext(ctx,...)` 自动带 `trace_id`/`span_id`。gateway 访问日志、dispatcher 任务生命周期日志均已带 trace_id——Jaeger 里拿 trace_id 即可过滤日志,反之从报错日志跳回链路。实测一致命中。
+
+> Prometheus(metrics)+ slog(logs,带 trace_id)+ OTel(traces)= 可观测性三件套已闭环。下一步可补:mcp-py 接入、采样策略、legacy log.Printf 渐进迁移到 ctx-aware slog。
+
+**实现细节(原规划,已照此落地)**:
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                    OpenTelemetry Tracing                        │
+│                                                                 │
+│  Gateway        NATS 消息头       Dispatcher       MCP Tools    │
+│  ┌────────┐    ┌──────────┐     ┌────────────┐   ┌──────────┐  │
+│  │ Span A │───▶│ W3C Ctx  │────▶│  Span B    │──▶│ Span C   │  │
+│  │(HTTP)  │    │(Header)  │     │(图编排)    │   │(工具调用)│  │
+│  └────────┘    └──────────┘     └────────────┘   └──────────┘  │
+│                                                                 │
+│  → 导出到 Jaeger / Tempo                                       │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+| 组件 | 方案 | 说明 |
+|---|---|---|
+| **SDK** | `go.opentelemetry.io/otel` | Go 标准 OTel SDK |
+| **Gin 自动埋点** | `otelgin` 中间件 | 替代/补充现有 Observe(),每个 HTTP 请求自动创建 span |
+| **NATS 跨进程透传** | 在 NATS 消息 Header 中注入 `traceparent` (W3C TraceContext) | bus.PublishTask / ConsumeTasks 双端抽 inject/extract |
+| **Eino 图节点埋点** | 每个节点 (agent/retriever/tool/branch) 创建 child span | 与现有 ExecEvent 对齐,自动关联 |
+| **后端** | Jaeger (开发) / Grafana Tempo (生产) | Tempo 对接 Grafana 可视化 |
+
+**关键收益**:一条 trace 覆盖 HTTP 入口 → DSL 解析 → NATS 发布 → Dispatcher 消费 → 记忆召回 → 图节点 1→2→3 → 工具调用 → Token 回流 → SSE 推送,任何环节慢/错一眼可见。
+
+### 1.2 指标体系完善 (Metrics) — ⚠️ 部分
+
+**现状**:Gateway 有 3 个 HTTP 指标,Dispatcher/MCP 无指标。
+
+**需补充**:
+
+| 新增指标 | 归属 | 说明 |
+|---|---|---|
+| `sundynix_tasks_total{status}` | Dispatcher | 任务处理计数(submitted/done/failed/timeout)|
+| `sundynix_task_duration_seconds` | Dispatcher | 任务端到端耗时直方图 |
+| `sundynix_llm_requests_total{model,status}` | Dispatcher | LLM 调用计数 |
+| `sundynix_llm_tokens_total{model,direction}` | Dispatcher | Token 用量(input/output)— **计费核心** |
+| `sundynix_llm_latency_seconds{model}` | Dispatcher | LLM 响应延迟 (TTFT / 总耗时) |
+| `sundynix_tool_calls_total{tool,status}` | MCP-Go/Py | 工具调用计数 |
+| `sundynix_rag_search_duration_seconds{path}` | MCP-Go | 检索延迟(向量/全文/图谱/融合)|
+| `sundynix_circuitbreaker_state` | Dispatcher | 熔断器当前状态 (gauge) |
+| `sundynix_nats_msgs_total{subject}` | Shared | NATS 消息流量 |
+
+**推荐栈**:Prometheus + Grafana (已有 prometheus client),补 Grafana 仪表盘。
+
+### 1.3 日志标准化 — ⚠️ 部分
+
+**现状**:Gateway 用 `slog` ✅,但 Dispatcher / MCP-Go / MCP-Py 仍用 `log.Printf` / `fmt.Printf`。
+
+**需引入**:
+
+| 变更 | 说明 |
+|---|---|
+| 全局切换到 `log/slog` (Go 1.21+) | 统一 JSON 格式,所有日志携带 `request_id` / `task_id` / `service` 字段 |
+| 日志级别控制 | 生产 INFO,调试 DEBUG,按环境变量切换 |
+| 关联 trace_id | 日志注入 `trace_id` + `span_id`,Grafana Loki 可跳到对应 trace |
+| Python 端 structlog | MCP-Py 引入 structlog,输出 JSON 格式,对齐 Go 端 |
+| 日志采集 | Grafana Loki / ELK (Fluentd → Elasticsearch → Kibana) |
+
+### 1.4 全栈观测仪表盘
+
+```
+Grafana Dashboard 体系:
+├── Overview:    服务健康 / 请求量 / 错误率 / P99 延迟
+├── LLM:         Token 用量 / 模型延迟 / 成功率 / 成本估算
+├── RAG:         入库速率 / 检索延迟 / 命中率 / 图谱规模
+├── Tasks:       任务状态分布 / 排队深度 / 处理耗时
+├── NATS:        消息吞吐 / 消费者延迟 / 积压深度
+└── Alerts:      错误率>5% / P99>3s / 熔断打开 / 任务超时率>10%
+```
+
+---
+
+## 二、安全加固 (Security Hardening)
+
+### 2.1 密钥管理 — ⚠️ 部分(API Key 加密已落地)
+
+**已实现**:
+- ✅ **LLM `api_key` 加密存储 + 端到端密文**:`sundynix-shared/secrets`(AES-256-GCM,密钥由 `SUNDYNIX_SECRET_KEY` 经 SHA-256 派生)。
+  网关保存时加密落库(`enc:1:` 前缀),密文经 NATS 原样下发,消费方(dispatcher/mcp-go)在 bus 层解密——
+  **磁盘与线缆上均无明文**,仅在构建 LLM 客户端时于内存短暂还原。历史明文行自动透传,下次保存即升级为密文。
+  生产模式(`APP_ENV=production` / `GIN_MODE=release`)下未设 `SUNDYNIX_SECRET_KEY` 直接 fatal(`secrets.MustHaveKeyInProd`);
+  **各服务必须配置相同的密钥**。
+- ✅ JWT 签名密钥:生产强制 `JWT_SECRET`(未设即 fatal)。
+
+**仍待引入**:
+- 配置文件中明文 DSN(含密码)→ 见下表
+
+| 方案 | 说明 | 推荐 |
+|---|---|---|
+| **Vault / SOPS** | 进一步把 `SUNDYNIX_SECRET_KEY` 等根密钥托管、支持轮换 | HashiCorp Vault(生产首选)或 Mozilla SOPS(轻量) |
+| **K8s Secrets + CSI** | 集群内密钥挂载 | Secrets Store CSI Driver + Vault Provider |
+| **NATS TLS** | NATS 客户端↔服务端全链路 TLS,消息传输加密 | 配置 nats-server TLS + 客户端证书 |
+| **PG SSL** | 数据库连接启用 `sslmode=require` | 当前 DSN 为 `sslmode=disable` |
+| **环境变量注入** | 敏感配置一律不入代码/配置文件 | `.env.production` + Docker secrets |
+
+### 2.2 认证与授权深化 — ⚠️ 部分
+
+**现状**:JWT 鉴权 ✅ + RequireAdmin 白名单 ✅,但:
+- 无 Token 刷新机制(过期即登出)
+- 无 RBAC 角色体系
+- SSE/EventSource 无法带 Authorization 头(当前按 task_id 寻址,无鉴权)
+
+**需引入**:
+
+| 能力 | 方案 |
+|---|---|
+| **Refresh Token** | JWT access_token (15min) + refresh_token (7d),静默续期 |
+| **RBAC** | 角色模型:`admin / operator / user / viewer`,PG 存角色表 |
+| **SSE 鉴权** | 连接时带 `?token=xxx` query param → 验证后升级为 SSE(或 WebSocket 时握手带 token)|
+| **OAuth2 / SSO** | 企业客户需要 OIDC(接 KeyCloak / Auth0 / Azure AD)|
+| **API Key 鉴权** | 对外开放 API 时,支持 `X-API-Key` 鉴权(与 JWT 并行)|
+
+### 2.3 网络安全 — ⚠️ 缺失
+
+| 能力 | 当前 | 需引入 |
+|---|---|---|
+| **CORS** | 未见显式配置 | Gin CORS 中间件,生产严格限制 Origin |
+| **HTTPS** | 无 TLS 终端 | 前置 Nginx/Caddy/Traefik 做 TLS 终端 + HTTP→HTTPS 重定向 |
+| **mTLS** | 无 | NATS 集群间 + 服务间 mTLS(零信任网络)|
+| **WAF** | 无 | 前置 Cloudflare / ModSecurity 或 Nginx WAF 规则 |
+| **网络隔离** | docker-compose 共享网络 | K8s NetworkPolicy:前端→Gateway→NATS→后端 严格分网段 |
+
+### 2.4 审计日志 — 🔴 缺失
+
+**需引入**:
+
+```go
+// 关键操作审计(写 PG sundynix_audit_log):
+// - 用户登录/登出
+// - 模型配置变更
+// - 知识库创建/删除/入库
+// - 任务提交/执行
+// - 管理员操作(改角色/改限额)
+type AuditLog struct {
+    ID        string    `gorm:"primaryKey;size:24"`
+    UserID    string    // 操作者
+    Action    string    // login / config_update / kb_ingest / task_submit / ...
+    Resource  string    // 操作对象
+    Detail    string    // JSON 详情
+    IP        string
+    CreatedAt time.Time
+}
+```
+
+---
+
+## 三、高可用与容灾 (HA & Disaster Recovery)
+
+### 3.1 NATS 集群 — 🔴 必须
+
+**现状**:单节点 NATS(docker-compose),是**全局单点故障**。
+
+**需引入**:
+
+```yaml
+# NATS 3 节点集群 + JetStream Raft
+nats-1:
+  image: nats:2-alpine
+  command: ["-c", "/etc/nats/cluster.conf", "--name", "nats-1"]
+nats-2:
+  image: nats:2-alpine
+  command: ["-c", "/etc/nats/cluster.conf", "--name", "nats-2"]
+nats-3:
+  image: nats:2-alpine
+  command: ["-c", "/etc/nats/cluster.conf", "--name", "nats-3"]
+```
+
+- JetStream 自带 Raft 共识,3 节点即可容忍 1 节点故障
+- 客户端连接串改为 `nats://nats-1:4222,nats-2:4222,nats-3:4222`
+- `bus.ConnectWithRetry` 已支持重连 ✅
+
+### 3.2 数据库高可用
+
+| 组件 | 当前 | 生产方案 |
+|---|---|---|
+| **PostgreSQL** | 单节点 | Patroni + etcd 主从自动切换;或云 RDS (高可用版) |
+| **Redis** | 单节点 | Redis Sentinel (3 节点) 或 Redis Cluster |
+| **Milvus** | Standalone | Milvus Cluster 模式(需独立 Pulsar/Kafka + 多 QueryNode)|
+| **Neo4j** | 单节点 Community | Neo4j Enterprise Causal Cluster(3 Core + Read Replicas)|
+| **MinIO** | 随 Milvus | 独立 MinIO Distributed (4+ 节点 erasure coding) |
+
+### 3.3 服务多副本与负载均衡
+
+| 服务 | 扩容方式 | 注意事项 |
+|---|---|---|
+| **Gateway** | N 副本 + Nginx/Traefik 负载均衡 | SSE 连接需要 sticky session 或用 NATS 做中转 |
+| **Dispatcher** | N 副本 | NATS 队列组(`ConsumerDurable`)已天然支持负载均衡 ✅ |
+| **MCP-Go** | N 副本 | NATS `QueueSubscribe` 队列组已天然支持 ✅ |
+| **MCP-Py** | N 副本 | 同上 ✅ |
+
+> 项目架构上 Dispatcher/MCP 已经是无状态 + NATS 队列组,天然支持水平扩展,这是很大的优势。
+
+### 3.4 Kubernetes 部署 — 🔴 必须
+
+**现状**:仅 docker-compose(开发环境)。
+
+**需引入**:
+
+```
+deploy/k8s/
+├── charts/
+│   └── sundynix/
+│       ├── Chart.yaml
+│       ├── values.yaml           # 统一配置(副本数/资源限制/密钥引用)
+│       ├── values.prod.yaml      # 生产覆盖
+│       └── templates/
+│           ├── gateway-deploy.yaml
+│           ├── gateway-svc.yaml
+│           ├── dispatcher-deploy.yaml
+│           ├── mcp-go-deploy.yaml
+│           ├── mcp-py-deploy.yaml
+│           ├── ingress.yaml
+│           ├── hpa.yaml          # 水平自动扩缩
+│           ├── pdb.yaml          # Pod 中断预算
+│           └── networkpolicy.yaml
+├── kustomize/                    # 或 Kustomize base + overlays
+└── Dockerfile.*                  # 各服务容器镜像
+```
+
+**必须包含**:
+
+| K8s 资源 | 说明 |
+|---|---|
+| **Deployment** | 每个服务 ≥2 副本,滚动更新 |
+| **HPA** | Gateway 按 CPU/请求量自动扩缩;Dispatcher 按 NATS 消费者延迟 |
+| **PDB** | `minAvailable: 1`,升级时不中断 |
+| **Resource Limits** | 每个 Pod 明确 requests/limits(CPU/内存)|
+| **Liveness / Readiness** | Gateway: HTTP /health; Dispatcher: NATS ping; MCP: NATS health |
+| **NetworkPolicy** | 前端 → Gateway (8080);Gateway → NATS (4222) / PG / Redis; NATS → Dispatcher / MCP |
+| **Ingress** | Nginx Ingress / Traefik + TLS 证书(cert-manager + Let's Encrypt)|
+| **ConfigMap / Secret** | 配置与密钥分离,Secret 经 Vault CSI 注入 |
+
+### 3.5 备份与恢复
+
+| 数据 | 策略 | RPO / RTO |
+|---|---|---|
+| **PostgreSQL** | pg_dump 每日全量 + WAL 归档增量 → S3/MinIO | RPO < 1h, RTO < 30min |
+| **Milvus** | Milvus backup 工具 → S3 | RPO < 24h |
+| **Neo4j** | neo4j-admin dump → S3 | RPO < 24h |
+| **NATS JetStream** | 3 副本 Raft 自带持久化 | 天然容灾 |
+| **配置** | Git 版本管理 + Vault 快照 | 即时恢复 |
+
+---
+
+## 四、CI/CD 与发布工程 (Release Engineering)
+
+### 4.1 当前 CI 评估
+
+**已有**:
+- [ci.yml](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/.github/workflows/ci.yml):Go build+vet+test / 前端 tsc / Python sandbox test ✅
+- [release.yml](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/.github/workflows/release.yml):Wails 跨平台构建 + GitHub Release ✅
+
+**缺失**:
+
+### 4.2 需补充的 CI 流水线
+
+```yaml
+# 完整 CI 流水线:
+jobs:
+  lint:           # ← 新增
+    - golangci-lint run    # 代码质量门
+    - eslint + prettier    # 前端 lint
+    - ruff check           # Python lint
+
+  security:       # ← 新增
+    - govulncheck ./...    # Go 依赖漏洞扫描
+    - npm audit            # 前端依赖漏洞
+    - trivy image scan     # 容器镜像漏洞扫描
+    - gitleaks detect      # 代码中泄露的密钥检测
+
+  test:           # 增强
+    - go test -race -coverprofile  # 竞态检测 + 覆盖率
+    - vitest run           # 前端单元测试(需先引入)
+    - pytest --cov         # Python 覆盖率
+
+  build:          # ← 新增:后端容器镜像
+    - docker build -t sundynix-gateway:$SHA
+    - docker build -t sundynix-dispatcher:$SHA
+    - docker build -t sundynix-mcp-go:$SHA
+    - docker build -t sundynix-mcp-py:$SHA
+    - push to container registry
+
+  e2e:            # ← 新增:端到端
+    - docker-compose up → make e2e → 全链路验证
+
+  deploy-staging: # ← 新增
+    - helm upgrade --install -n staging
+    - 冒烟测试
+
+  deploy-prod:    # ← 新增
+    - 人工审批 gate
+    - helm upgrade --install -n production
+    - 金丝雀验证
+```
+
+### 4.3 容器化 — 🔴 必须
+
+需为每个服务创建 Dockerfile:
+
+```dockerfile
+# 示例:sundynix-gateway
+FROM golang:1.25-alpine AS builder
+WORKDIR /src
+COPY go.work go.work.sum ./
+COPY sundynix-shared/ sundynix-shared/
+COPY sundynix-gateway/ sundynix-gateway/
+RUN cd sundynix-gateway && CGO_ENABLED=0 go build -o /app ./cmd/server
+
+FROM gcr.io/distroless/static-debian12
+COPY --from=builder /app /app
+EXPOSE 8080
+ENTRYPOINT ["/app"]
+```
+
+- 多阶段构建,最终镜像用 distroless(最小攻击面)
+- 镜像推到 GitHub Container Registry (ghcr.io) 或私有 Harbor
+
+### 4.4 发布策略
+
+| 策略 | 说明 |
+|---|---|
+| **语义化版本** | `vMAJOR.MINOR.PATCH`,MAJOR 含 breaking change |
+| **金丝雀发布** | 新版先切 10% 流量,观测 30min 无异常再全量 |
+| **蓝绿部署** | 数据库 schema 变更时用蓝绿(需 schema 向后兼容)|
+| **Feature Flags** | 引入 unleash / flagsmith,功能灰度发布 |
+| **回滚机制** | `helm rollback` + NATS JetStream 消费者 replay |
+
+---
+
+## 五、数据治理与合规 (Data Governance)
+
+### 5.1 多租户 — ⚠️ 待实现
+
+**现状**:知识库按 `user_id/kb_name` 做 owner 作用域,但无正式租户模型。
+
+**需引入**:
+
+| 层次 | 方案 |
+|---|---|
+| **数据隔离** | 租户 ID → PG Schema / 行级策略 (RLS);Milvus 按租户 partition;Neo4j 按租户 label |
+| **资源配额** | 每租户 LLM Token 配额 / 存储配额 / 并发任务上限 |
+| **计费** | 按 Token 用量 / 存储用量 / API 调用量 阶梯计费 |
+
+### 5.2 数据生命周期
+
+| 能力 | 说明 |
+|---|---|
+| **数据保留策略** | 会话历史 90 天过期;审计日志 1 年;JetStream 7 天滚动 |
+| **个人数据删除** | GDPR / 个保法 合规:用户注销 → 级联清除 PG + Milvus + Neo4j + Redis 所有个人数据 |
+| **数据脱敏** | 生产日志中 PII 字段(用户输入/模型输出)自动脱敏 |
+| **数据分类分级** | API Key = L4 机密;用户输入 = L3 敏感;模型输出 = L2 内部 |
+
+### 5.3 合规
+
+| 要求 | 措施 |
+|---|---|
+| **等保 2.0 三级** | 审计日志 + 访问控制 + 加密存储 + 入侵检测 |
+| **GDPR** | 个人数据删除权 + 数据可携权 + 处理记录 |
+| **AI 生成标识** | 输出标注"AI 生成"水印或元信息 |
+| **内容审查** | 模型输出过滤(敏感内容/政治/暴力/色情)— 需接第三方审查 API 或部署自有模型 |
+
+---
+
+## 六、AI 特有的生产治理
+
+### 6.1 Token 计量与成本控制 — 🔴 必须
+
+**现状**:LLM Pool 无 Token 计数。
+
+**需引入**:
+
+```go
+// Token 计量中间件(包装 Pool.ChatStream / Chat):
+type TokenMeter struct {
+    pool    *Pool
+    counter prometheus.Counter    // sundynix_llm_tokens_total
+    store   TokenUsageStore       // 写 PG sundynix_token_usage
+}
+
+// 每次 LLM 调用记录:
+type TokenUsage struct {
+    TaskID       string
+    UserID       string
+    Model        string
+    InputTokens  int
+    OutputTokens int
+    LatencyMS    int64
+    Cost         float64   // 按 model pricing 计算
+    CreatedAt    time.Time
+}
+```
+
+- 大部分 OpenAI 兼容 API 在响应中返回 `usage.prompt_tokens` / `usage.completion_tokens`
+- Eino `ChatModel.Generate` 的响应 `ChatModelOutput` 包含 `TokenUsage`
+
+### 6.2 模型路由与 Fallback — ⚠️ 缺失
+
+**现状**:单一模型配置,无 fallback。
+
+**需引入**:
+
+```
+模型路由策略:
+├── 主模型: deepseek-chat (成本低/速度快)
+├── Fallback 1: gpt-4o-mini (主模型超时/500 时切)
+├── Fallback 2: 本地 Ollama (外网不可用时切)
+└── 规则路由:
+    ├── 报告生成 → 强模型 (需要长输出+结构化)
+    ├── 普通对话 → 快模型 (低延迟优先)
+    └── 知识抽取 → 便宜模型 (批量场景控成本)
+```
+
+### 6.3 Prompt 版本管理 — ⚠️ 缺失
+
+**现状**:Prompt 硬编码在 Go 代码中。
+
+**需引入**:
+
+| 方案 | 说明 |
+|---|---|
+| **Prompt 模板表** | PG 存 prompt 模板,支持版本控制 + A/B 测试 |
+| **变量插值引擎** | `{{.profile}}` / `{{.history}}` / `{{.refs}}` 模板变量 |
+| **A/B 测试** | 同一任务按百分比路由到不同 prompt 版本,对比评分 |
+| **Prompt 审计** | 记录每次推理的完整 prompt(脱敏后),便于复盘优化 |
+
+### 6.4 模型输出缓存 — ⚠️ 缺失
+
+| 层次 | 方案 |
+|---|---|
+| **Semantic Cache** | 相似 query 的 RAG 检索结果缓存(Redis / Milvus 相似度匹配)|
+| **Exact Cache** | 完全相同的 prompt 直接返回缓存结果(省 LLM 调用) |
+| **KB 搜索缓存** | 高频 query 的检索结果缓存(TTL 5min)|
+
+### 6.5 安全 AI 特有治理
+
+| 能力 | 说明 |
+|---|---|
+| **Prompt 注入防御增强** | 当前正则检测 ✅;建议补 LLM-based 注入检测(二段判决:先用小模型判定是否为注入)|
+| **幻觉检测** | RAG 场景:比对模型输出与检索到的参考资料,标注无出处的断言 |
+| **输出内容审查** | 接第三方内容审核 API 或部署开源模型(如 Llama Guard)做输出过滤 |
+| **PII 检测** | 输入/输出流经 PII 检测器(身份证号/手机号/银行卡号),日志自动脱敏 |
+
+---
+
+## 七、前端工程化
+
+### 7.1 状态管理 — ⚠️ 改进
+
+**现状**:纯 `useState` + props drilling,无全局状态。
+
+```typescript
+// 推荐引入 Zustand(轻量 + React 生态友好):
+import { create } from 'zustand';
+
+interface AppStore {
+  user: AuthUser | null;
+  identity: Identity;
+  currentRun: RunState;
+  // actions
+  setUser: (u: AuthUser | null) => void;
+  submitTask: (dsl: TaskDsl) => Promise;
+}
+```
+
+### 7.2 路由 — ⚠️ 缺失
+
+**现状**:`view` state + 条件渲染,无 URL 路由。
+
+```typescript
+// 引入 React Router v7(Admin 端已用):
+// → 支持 URL 直达、浏览器前进后退、深链接分享
+} />
+} />
+} />
+} />
+} />
+```
+
+### 7.3 测试 — 🔴 缺失
+
+| 层次 | 工具 | 覆盖 |
+|---|---|---|
+| **单元测试** | Vitest + React Testing Library | 核心组件(StudioView / KbView / 状态逻辑)|
+| **E2E 测试** | Playwright | 关键用户流(登录 → 编排 → 运行 → 查看结果)|
+| **Visual Regression** | Chromatic / Percy | UI 回归检测 |
+
+### 7.4 性能优化
+
+| 方案 | 说明 |
+|---|---|
+| **代码分割** | React.lazy + Suspense,按视图/路由懒加载 |
+| **虚拟列表** | 知识库文档列表 / 运行历史 → 用 `react-virtual` |
+| **SSE 重连** | EventSource 断连自动重连 + 指数退避 |
+| **离线缓存** | Service Worker 缓存静态资源 |
+
+### 7.5 大文件拆分
+
+| 文件 | 大小 | 建议 |
+|---|---|---|
+| [KbView.tsx](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-desktop/frontend/src/views/KbView.tsx) | 31,944 bytes | 拆为 `KbSearchPanel` / `KbDocList` / `KbGraphPanel` / `KbIngestPanel` |
+| [StudioView.tsx](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-desktop/frontend/src/studio/StudioView.tsx) | 11,966 bytes | 拆为 `Canvas` / `Toolbar` / `PropertiesPanel` |
+| [App.tsx](file:///Users/zhangjianmin/sourceCode/GolandProjects/src/sundynix-agentix/sundynix-desktop/frontend/src/App.tsx) | 8,942 bytes | 路由抽离后天然减小 |
+
+---
+
+## 八、运维自动化 (Platform Engineering)
+
+### 8.1 集中配置管理 — ⚠️ 缺失
+
+**现状**:各服务独立 `config.yaml`,NATS 热更新仅覆盖模型配置。
+
+**需引入**:
+
+| 方案 | 说明 |
+|---|---|
+| **NATS KV** | NATS 2.8+ 内建 KV Store,已接 NATS 生态,零额外运维 |
+| **Consul** | 功能更全(服务发现 + 配置 + 健康检查),但多一个组件 |
+| 推荐 NATS KV(现有生态内扩展,无新增依赖):配置变更 → Watch → 各服务热加载 |
+
+### 8.2 优雅停机 — ⚠️ 部分
+
+**需确保**:
+- 收到 SIGTERM → 停止接收新请求 → 等待在途请求完成 → 关闭 NATS / PG / Redis → 退出
+- NATS ConsumeTasks 的 `stop` 函数已有 ✅,需在信号处理中调用
+- K8s `preStop` hook + `terminationGracePeriodSeconds: 30`
+
+### 8.3 数据库 Schema 迁移 — ⚠️ 改进
+
+**现状**:GORM `AutoMigrate`(开发方便但生产风险高:字段删除不生效、大表加列会锁表)。
+
+**需引入**:
+
+| 工具 | 说明 |
+|---|---|
+| **golang-migrate** 或 **Atlas** | 版本化 SQL 迁移文件(up/down),CI 自动校验;生产用 `gh-ost` 做无锁 schema 变更 |
+| 保留 AutoMigrate 仅用于开发模式 | `if env == "dev" { db.AutoMigrate(...) }` |
+
+### 8.4 健康检查统一 — ⚠️ 不一致
+
+**现状**:Gateway 有 HTTP 端点,Dispatcher 走 NATS ping,MCP-Go 走 NATS 队列组。
+
+**需引入**:
+
+```go
+// 每个服务暴露统一的 /healthz (liveness) 和 /readyz (readiness):
+// - liveness: 进程存活(总是 200)
+// - readiness: 所有依赖就绪
+//   Gateway:  PG connected + Redis connected + NATS connected
+//   Dispatcher: NATS connected + LLM pool ready
+//   MCP-Go:  NATS connected + Milvus ready + Bleve ready
+//   MCP-Py:  NATS connected + Docker available
+```
+
+### 8.5 告警与 On-Call
+
+| 能力 | 方案 |
+|---|---|
+| **告警规则** | Prometheus Alertmanager + PagerDuty / 飞书 / 钉钉 webhook |
+| **关键告警** | 错误率>5% / P99>5s / 熔断打开 / 磁盘>80% / 队列积压>1000 / LLM 成功率<90% |
+| **On-Call 轮值** | PagerDuty / OpsGenie 自动轮值 + 升级 |
+| **Runbook** | 每个告警对应标准处置流程文档 |
+
+---
+
+## 优先级排序(路线图)
+
+> 按风险/收益排序,分三阶段实施:
+
+### 🔴 Phase 1:安全底线 + 部署能力 (4-6 周)
+
+| # | 事项 | 理由 |
+|---|---|---|
+| 1 | **密钥加密存储** (Vault/SOPS) | API Key 明文是最大安全风险 |
+| 2 | **Dockerfile 容器化** (4 个 Go 服务 + 1 Python) | 生产部署的前提 |
+| 3 | **Kubernetes Helm Chart** | 取代 docker-compose 部署 |
+| 4 | **NATS 3 节点集群** | 消除全局单点故障 |
+| 5 | **PG SSL + NATS TLS** | 传输层加密 |
+| 6 | **统一健康检查** (/healthz + /readyz) | K8s 探针就绪 |
+| 7 | **优雅停机信号处理** | 滚动更新不丢请求 |
+| 8 | **Token 计量基础** | 成本可控的前提 |
+
+### 🟡 Phase 2:可观测性 + 治理能力 (4-6 周)
+
+| # | 事项 | 理由 |
+|---|---|---|
+| 9 | **OpenTelemetry Tracing** | 分布式链路追踪 |
+| 10 | **指标体系补全** | LLM/RAG/Task 指标 |
+| 11 | **Grafana 仪表盘** | 统一观测面板 |
+| 12 | **日志标准化** (全局 slog + Loki) | 结构化日志 + 集中采集 |
+| 13 | **审计日志** | 合规要求 |
+| 14 | **模型路由 + Fallback** | 提升可用性 |
+| 15 | **PG 主从 + 备份策略** | 数据安全 |
+| 16 | **CI 增强** (lint + security scan + coverage) | 代码质量门 |
+| 17 | **Schema 迁移工具** (golang-migrate) | 安全 DDL |
+
+### 🟢 Phase 3:规模化 + 精细化 (6-8 周)
+
+| # | 事项 | 理由 |
+|---|---|---|
+| 18 | **多租户** | 商业化必需 |
+| 19 | **RBAC 角色体系** | 企业客户需求 |
+| 20 | **前端 React Router + Zustand** | 用户体验 + 可维护性 |
+| 21 | **前端测试** (Vitest + Playwright) | 质量保障 |
+| 22 | **Prompt 版本管理 + A/B 测试** | AI 质量持续优化 |
+| 23 | **Semantic Cache** | 降低 LLM 调用成本 |
+| 24 | **内容审查 (Llama Guard)** | 合规 |
+| 25 | **金丝雀发布 + Feature Flags** | 安全发布 |
+| 26 | **Milvus Cluster + Neo4j Cluster** | 数据层高可用 |
+| 27 | **告警体系 + Runbook** | 运维成熟度 |
+| 28 | **GDPR / 个保法合规** | 数据合规 |
+
+---
+
+## 技术选型速查表
+
+| 维度 | 推荐工具/组件 | 替代方案 |
+|---|---|---|
+| 链路追踪 | OpenTelemetry + Grafana Tempo | Jaeger / SkyWalking |
+| 指标 | Prometheus + Grafana | VictoriaMetrics |
+| 日志 | slog + Grafana Loki | ELK (Elasticsearch + Logstash + Kibana) |
+| 密钥管理 | HashiCorp Vault | AWS Secrets Manager / SOPS |
+| 容器编排 | Kubernetes + Helm | Docker Swarm (小规模) |
+| CI/CD | GitHub Actions + ArgoCD | GitLab CI / Jenkins |
+| 镜像仓库 | ghcr.io / Harbor | Docker Hub Private |
+| API 网关 | Traefik / Nginx Ingress | Kong / APISIX |
+| 配置中心 | NATS KV | Consul / etcd / Apollo |
+| Schema 迁移 | golang-migrate / Atlas | goose / Flyway |
+| Feature Flags | Unleash | LaunchDarkly / Flagsmith |
+| 内容审查 | Llama Guard (自部署) | 阿里绿网 / 腾讯天御 |
+| 前端状态 | Zustand | Jotai / Redux Toolkit |
+| 前端测试 | Vitest + Playwright | Jest + Cypress |
+| 告警 | Alertmanager + 飞书/钉钉 | PagerDuty / OpsGenie |
diff --git a/project_analysis.md b/project_analysis.md
new file mode 100644
index 0000000..d3a23d7
--- /dev/null
+++ b/project_analysis.md
@@ -0,0 +1,248 @@
+# sundynix-agentix · 项目分析报告
+
+> 撰写:2026-06-24 · 由 Opus 4.8 在**全程参与搭建**后基于实测与源码盘点撰写(覆盖旧版自动分析)。
+>
+> **立场**:本报告刻意避免营销腔。功能层面据实盘点;价值层面对"功能存在"与"成熟/被验证"严格区分。
+> 本项目绝大多数能力为**单人新作、未经外部审计、未经真实流量与规模长稳验证**——请据此理解所有评分。
+
+---
+
+## 〇、一句话定位
+
+**一个功能密度极高、架构品味很好的单人「准生产原型」。** 编排深度、安全覆盖面、可观测性在同类
+开源项目里都属上乘;但离"生产可用"的差距不只在生态/商业化,更在**高可用、规模验证、灾备、审计**
+这些运维硬门槛——这些目前基本是空白。
+
+---
+
+## 一、真实数据(本次实测)
+
+| 维度 | 数据 | 备注 |
+|------|------|------|
+| 源码总量 | **~19,200 行**(不含依赖/生成) | 实测 |
+| Go 后端 | 12,776 行(含测试 ~2,397) | gateway 3121 / dispatcher 4222 / mcp-go 3872 / shared 1561 |
+| 前端 TS/TSX | 5,809 行 | 桌面端 4,628(47 文件)+ 运维台 1,181(13 文件) |
+| Python 工具 | 456 行 | mcp-py |
+| 测试文件 | **32**(Go 26 + 前端 5 + Py 1) | |
+| 代码模块 | Go 4 工作区模块 + desktop(Wails) + 2 前端 + mcp-py | |
+| 提交数 | dev 119 | dev 比 origin/dev 多 4 未推;origin/main 基本同步(差 1 文档提交) |
+| 关键依赖 | Eino v0.9.9 · OTel v1.44 · nats.go v1.37 · gorm v1.31 · milvus-sdk v2.4.1 | |
+| 基础设施 | docker-compose 8 服务 | nats/pg/redis/jaeger/neo4j/milvus(+etcd+minio) |
+
+> 订正旧报告两处不实:① 旧版称"基于 main 117 提交",实为 dev;② 之前我多次口头说的"14 个未推送
+> commit"是**错的**——实测 dev 仅比 origin/dev 多 4,origin/main 已基本同步。
+
+---
+
+## 二、架构与请求生命周期
+
+5 层 + NATS 总线:
+
+```
+桌面端(Wails)/运维台(Web) ──HTTP──▶ Gateway(Gin)
+                                      │  鉴权·护栏·限流·DSL 解析·SSE 回流
+                                      ▼  JetStream 发布 task
+                                  NATS 总线 ──(W3C traceparent 注入消息头)
+                                      ▼  限并发消费
+                              Dispatcher(Eino 图编排)
+                                      │  记忆召回→工具→注入→流式·HITL 审批·熔断·评测
+                                      ▼  core NATS request-reply(带 trace)
+                            mcp-go / mcp-py(MCP 工具层,队列组水平扩)
+```
+
+- **任务流**:JetStream 持久化 + 持久消费者 + 限并发 worker 池(`DISPATCHER_CONCURRENCY`,背压由信号量 + `MaxAckPending` 双约束)。
+- **工具调用**:core NATS request-reply,队列组负载均衡;单实例工具已协程化并发。
+- **回流**:Token 流(Redis Stream 可回放 + 断点续传)与执行轨迹流(实时 NATS)分离。
+- **控制面**:模型配置经 NATS 热更新;启动竞态用后台重试根治。
+
+这套解耦是真的、跑得通的(本会话端到端验证过多条链路)。**机制层面**支持独立替换/水平扩;
+**但水平扩仅本地小规模验证,未上真实集群。**
+
+---
+
+## 三、做得扎实的部分(本会话实测确认)
+
+### 1. 编排引擎(Eino A→D 深度集成)
+- 自研 `graph.go` + `compose.Graph` 双轨(灰度开关,compose 编译失败自动降级,安全网真实存在)。
+- **ReAct 自主智能体**:模型自主选工具;`list_tools` 自描述 + 动态发现——**加工具零改调度代码**(本会话加 6 个工具,dispatcher 一行没动,自主 agent 直接会用,已实测)。
+- **HITL 人工审批**:审批节点暂停→`waiting`→批准续跑/拒绝中止;超时安全默认拒绝;前端轮询持久状态兜底(不依赖易丢的实时事件)。实测批准/拒绝两条路径正常。
+- branch 条件选路 + map 有界并发 + aggregate 汇聚 + 多 agent 图接力。
+
+### 2. 安全治理(覆盖面广;自实现、未审计)
+- 输入护栏(注入正则 + 体积限制)、**输出护栏(流式逐片脱敏 sk-/AKIA/JWT)**、三态熔断(`-race` 过)、LLM 评测(异步离热路径)。
+- 代码沙箱(Docker 隔离 + AST 守卫 + 禁网/非 root/丢能力/只读根/限资源/超时 kill)。
+- **API Key 端到端 AES-256-GCM**:磁盘 + NATS 线缆均密文,仅用时内存解密;历史明文平滑迁移(实测密文落库 + 解密打通 DeepSeek)。
+- SSRF 防护(external_api/web_fetch)、SQL 只读三重防护、JWT(生产 fail-fast)。
+- ⚠️ 价值定性:**覆盖广 ≠ 已验证安全**。无外部审计/渗透;"最强"无依据。
+
+### 3. 知识库 / RAG
+- Milvus 向量 + Bleve 全文 + Neo4j 图谱 → RRF 融合 → 可选 rerank。
+- **Markdown 标题感知切块**(块带章节面包屑、不跨章节、跳过代码围栏,rune 安全)——实测入库面包屑正确、检索带章节上下文返回。
+- Obsidian 式双链文库 + 入库可视化进度。
+
+### 4. Agent 工具集(22 个 Go 注册,10 个 agent 可见 + mcp-py 5 个)
+- 知识库/记忆/历史/报告/external_api +(本会话新增并实测)**web_search**(Tavily/DDG 兜底)、**web_fetch**、**calculator**(调度场算法)、**current_datetime**、**sql_query**(三重防护)、**chart**(产 JSON spec,前端 SVG 渲染,职责分离)。
+
+### 5. 可观测性
+- **OTel 全链路**:跨 NATS 传 traceparent,Jaeger 一棵 span 树(实测 14 span/3 服务)。
+- **slog 带 trace_id**(日志↔链路互跳,实测同一 trace_id 命中);Prometheus;健康探针;SSE 断点续传。
+
+### 6. 性能/并发(本会话补齐 + 实测)
+- 任务并发消费 + 工具协程化并发(垂直)× 队列组多副本(水平)。实测:一个 HITL 待审任务不阻塞其他任务;平台自身开销可忽略,瓶颈在外部 LLM。
+
+### 7. 工程化 / CI/CD
+- `make demo` 无 Docker 内嵌 NATS 跑全链路;docker-compose 零配置;模型热更新;DB 连接池上限;启动竞态修复。
+- GitHub Actions CI(4 Go 模块 + 前端 tsc + mcp-py)+ Release(tag→macOS/Windows 桌面端构建)+ 桌面端更新检查。
+
+---
+
+## 三·五、Harness 治理层专项(逐项优化清单)
+
+Harness = 围绕 LLM 的可靠性 / 安全 / 质量治理层。4 个组件均为真实现 + 单测,成熟度参差。
+
+| 组件 | 文件/规模 | 成熟度 | 实情 |
+|------|----------|:------:|------|
+| 熔断器 CircuitBreaker | 126 行 + 112 测试 | ⭐⭐⭐⭐½ | 三态 + 阈值 + 冷却 + 半开探测 + 可注入时钟 + `-race` 安全,**真·生产级** |
+| 评测 Evaluator | 135 行 + 91 测试 | ⭐⭐⭐ | 规则分(空/过短/拒答/复读) + LLM-judge(1–5);异步离热路径 |
+| 输出脱敏 RedactSecrets | 26 行 + 39 测试 | ⭐⭐⭐ | 4 正则(sk-/AKIA/JWT/Bearer),流式逐片 |
+| 输入护栏 Guardrail | guardrail.go + 测试 | ⭐⭐½ | 注入正则(中英) + 体积限制 + 敏感词黑名单(默认空) |
+
+**定性(更新)**:起初是**「测温计」(检测+记录)**——多数信号只打日志。经评测闭环、低分自动纠偏、
+忠实度评测、输出脱敏增强、输入护栏升级、成本预算护栏六项,现已成**「恒温器」(检测→决策→门控/纠偏闭环)**:
+熔断挡后端雪崩、输入护栏两层挡注入/越狱、评测分级+低分重生成、输出脱敏挡密钥/PII、预算护栏挡失控成本。
+下一步重心转 P0/P1 生产硬骨头(优雅停机、HA、容量实测、本地模型)。
+
+**已知短板 / 优化清单**(按性价比,逐项推进):
+
+- [x] **P1 评测闭环** ✅:eval 结果经 NATS 落 PG(sundynix_eval)+ 分级(ok/warn/poor) + poor 告警 +
+  `GET /tasks/:id/eval` 可查。dispatcher 评→广播→网关落库。剩:桌面端质量面板、低分自动重试(P3)。
+- [x] **P1 RAG 忠实度评测** ✅:检索原文喂给 judge,一次评质量+忠实度,未被来源支持的说法进 Flags。
+  综合分(有来源)=0.3规则+0.35质量+0.35忠实。runGraph 透传 refs → evaluate。live 实测忠实 1.00/来源 1,单测覆盖。
+- [x] **P2 输出脱敏增强** ✅:有状态 `StreamRedactor` 跨分片缓冲,切点在原文上定且**绝不切断完整匹配**,
+  杜绝密钥被切成两片漏检 / 提前脱敏半截致碎片泄漏(逐字符 JWT 流亦完整捕获);rune 边界安全(中文不乱码);
+  新增 PII(手机号/邮箱/身份证);opener+尾窗双兜底、暂留封顶防 DoS。3 流式点接入,7 单测,live 实测密钥/邮箱整条脱敏。
+- [x] **P2 输入护栏升级** ✅:两层。Tier1(网关同步、无 LLM)先**归一化**(小写/去零宽/同形字折叠/拆字间隔还原/base64 解码)再跑高精度正则——干掉编码/空格/同形字绕过;`bannedTerms` 经 env 落地。Tier2(dispatcher harness LLM 分类器)只对 Tier1 判「灰区」的软信号输入裁决(escalation,明确干净/恶意不付 LLM 成本),命中→rejected,fail-open 降级。live 实测:拆字/base64/西里尔同形字均拦;恶意灰区被 LLM 拒(severity 1)、良性灰区(海盗 roleplay)放行完成。
+- [x] **P3 坏输出自动纠偏** ✅:poor(<0.5) 触发评语驱动的重生成,重评后**仅采纳更优者(不退步)**,
+  采纳的修订版落会话历史 + 评测终值带 `corrected` 标记落库。`maxRefineRounds=1`、`canRefine`(模型就绪且熔断未开)门控;
+  单 goroutine 串 评测→纠偏→落历史避竞态。单测覆盖 采纳/不退步/非低分不触发,live 验证好答案不误触发。**至此 harness 由「测温计」迈入「恒温器」。**
+- [x] **P3 成本/Token 预算护栏** ✅:用量估算计量(CJK/ASCII 启发式,无需分词器)。**单任务硬上限**:Budget 挂 ctx 沿图透传,各 LLM 节点(对话/ReAct/compose/报告)入口计输入、出口计输出,触顶即中止(报告优雅降级跳过剩余章节);防失控成本。**单用户日预算**:dispatcher 收尾经 NATS 回写 UsageEvent → 网关按用户按天累计 Redis → 提交前门控(USER_DAILY_TOKEN_BUDGET,超额 402),`/billing` 出当日用量/预算/余额。live:单任务 budget=30 → failed(已用约689);用户日 budget=200 → 402。
+
+---
+
+## 四、原型级 / 未验证(别当生产能力看)
+
+- **规模未真实验证**:并发是近期才补的,仅本地小规模测过;无长稳、无容量上限实测曲线。
+- **compose 引擎仍灰度**(默认走自研 graph.go);compose 路径下 HITL 审批未实现。
+- **OCR 多模态是骨架**:`parse_document` 真支持 txt/md/csv/docx/xlsx/文本 PDF,但 MinerU/PaddleOCR 扫描件路径是 TODO 空实现(`mineru.py` 极小)。
+- **推理模型已部分适配**:reasoning 模型的 `reasoning_content` 已捕获并 surface 到 exec 轨迹「推理过程」事件(不污染答案);尚未在最终答案区把思维链单独流式呈现给前端。
+- **测试纵深改善中**:除小函数单测,已补 `Handle()` 入口级集成(熔断/护栏/预算/状态机/工具→agent/异步评测 6 例)+ **RAG 管线集成**(检索→注入 prompt→生成→refs 回流→grounded 忠实度评测 + owner 作用域 + 优雅降级 4 例)+ bus 真实 NATS e2e,均 -race 干净;剩前端 E2E。
+
+---
+
+## 五、缺失 / 生产硬门槛(最该正视)
+
+| 缺口 | 现状 | 影响 |
+|------|------|------|
+| **高可用 / 单点** | 🟡 代码层多副本安全(调度队列组 + 网关队列组订阅去重,均实测);NATS 集群/网关 LB/PG·Redis HA 待部署期 | 进程级已可水平复制;基础设施 HA 仍缺 |
+| **备份 / 灾备** | PG/Milvus/Neo4j 无备份恢复方案 | 数据丢失风险 |
+| **安全审计** | 全自述,未渗透/未审计 | "纵深防御"未被验证 |
+| **配额 / 多租户** | 仅按 IP 全局限流;owner 基础隔离 | 无团队/组织/按用户配额 |
+| **可靠性细节** | ~~exec 轨迹流丢事件~~(已 Redis 回放/断点续传);~~停机不 drain~~(已优雅停机 drain) | ✅ 两项已补 |
+| **计量计费** | 计价配置有,计量×单价+配额未落地 | 商业化未闭环 |
+| **本地模型** | ✅ vLLM/Ollama 已接(provider 感知,自动补 /v1 + 占位 key);模型路由/多模型 fallback 仍未做 | |
+
+---
+
+## 六、对标主流开源项目(口径:功能存在性,非成熟度)
+
+> **重要**:下表 ✅/❌ 仅表示"功能是否内置存在",**不含成熟度、规模、生态、可靠性权重**。
+> 此类矩阵天然利好新项目(可挑竞品没内置的新特性,而竞品的护城河——生态/被验证程度/可靠性——
+> 无法用勾选体现)。Dify/Langflow 等的真实优势在这些"勾不出来"的地方。
+
+| 维度 | sundynix | 说明(去掉滤镜) |
+|------|:--------:|------|
+| 可视化编排 / DAG | ✅ 双轨 | 与主流持平;缺循环/迭代节点 |
+| ReAct + 工具动态发现 | ✅ | `list_tools` 自描述是亮点;Dify 用插件市场(机制不同,非"做不到") |
+| 人工审批 HITL | ✅ | 有;Dify 也有,FastGPT/Flowise 无 |
+| 多智能体 | ✅ 图接力 | **AutoGen 等专门框架更强**,别自比第一 |
+| 三路混合检索 + 图谱 | ✅ Neo4j | 较少见的内置组合,是真亮点 |
+| 标题感知切块 | ✅ | 主流也有多策略切块,非独有 |
+| 输出流式脱敏 / AES 加密 / OTel 跨总线 | ✅ | 内置且齐全,属较稀有组合(成熟度另说) |
+| 原生桌面端 (Wails) + 自动更新 | ✅ 独有 | 竞品多纯 Web |
+| 生态 / 社区 / 被生产验证 | ❌ | **竞品真正的护城河,本项目空白** |
+| 商业化(计费/多租户/插件市场) | 🟡/❌ | 未闭环 |
+
+**结论**:在"内置功能的齐全度"上,本项目确实能和数万 star 项目掰手腕,部分组合更超前;
+但**"超越"它们是不成立的**——它们赢在被千万次真实使用打磨出的可靠性、生态与运维成熟度,
+这正是单人项目无法靠堆功能追平的。
+
+---
+
+## 七、评级(功能完成度 vs 成熟度分列)
+
+| 维度 | 评分 | 说明(已含成熟度折扣) |
+|------|:----:|------|
+| 架构设计 | ⭐⭐⭐⭐ | 解耦清晰、品味好;单点无 HA、规模未验证 |
+| 编排引擎 | ⭐⭐⭐⭐½ | 双轨+ReAct+HITL+多 Agent,完成度高;缺循环、compose 仍灰度 |
+| 安全治理 | ⭐⭐⭐⭐ | 覆盖面广;未审计/未渗透 |
+| 知识库 RAG | ⭐⭐⭐⭐ | 三路+图谱+标题切块;OCR 骨架 |
+| Agent 工具集 | ⭐⭐⭐⭐½ | 丰富 + 动态发现 + SQL/图表 |
+| 可观测性 | ⭐⭐⭐⭐ | OTel+Prometheus+slog;轨迹流会丢事件 |
+| 性能 / 并发 | ⭐⭐⭐½ | 双扩机制具备;规模未实测 |
+| 代码质量 | ⭐⭐⭐⭐ | 注释充足、降级完善;KbView 大文件需拆 |
+| 测试覆盖 | ⭐⭐⭐½ | 单测面广 + Handle 入口集成 + bus 真实 NATS e2e;剩 RAG 管线集成、前端 E2E |
+| CI/CD | ⭐⭐⭐⭐ | CI+Release+更新检查;无签名公证 |
+| 运维成熟度 | ⭐⭐ | 单点、无备份灾备、无配额、规模未验证 |
+| 生态 / 商业化 | ⭐⭐ | 个人项目;计费未闭环 |
+
+### 综合:**3.7 / 5.0** —— 单人项目里属于「相当能打」,离生产仍有实打实的距离。
+
+---
+
+## 八、改进优先级
+
+| 优先级 | 项目 | 说明 |
+|:------:|------|------|
+| ~~P0~~ 🟡 | ~~容量压测曲线~~(首版):`cmd/loadtest` 闭环加压器 + 平台天花板实测(见下「容量实测」)。剩长稳/真实 LLM 大压 | "生产级并发"需数据背书 |
+| ~~P0~~ ✅ | ~~本地模型(vLLM/Ollama) + reasoning_content 适配~~:Pool provider 感知(Ollama/vLLM 自动补 /v1 + 占位 key),统一走 OpenAI 兼容路径;ChatStream 加 onReasoning,思考过程 surface 到 exec 轨迹(不污染答案)。控制台加 ollama 选项。live:Ollama qwen2.5:0.5b 端到端出答案;deepseek-v4-pro「推理过程」入轨迹 | 对齐生产 Qwen |
+| ~~P1~~ 🟡 | 高可用(代码层就绪):调度/工具本就队列组可多副本(实测 2 副本 8 任务 4/4 分摊);**网关改为队列组订阅**(eval/usage/status/config),多副本不再重复落库/重复计费(单测+实测去重)。剩 NATS 集群 + 网关 LB + PG/Redis HA 属部署期 | 解单点 |
+| P1 | 备份/灾备演练(PG/Milvus/Neo4j) | 数据安全 |
+| ~~P1~~ ✅ | ~~优雅停机 drain~~:三 Go 服务全覆盖(gateway HTTP Shutdown / dispatcher 在途任务跑完 / mcp-go 在途工具回完),SIGTERM 后等在途至 SHUTDOWN_DRAIN_TIMEOUT(默认30s)再退;在途任务 ctx 脱离信号 ctx 不被掐断。**+ exec 轨迹 Redis 回放**(与 token 流同构,连晚/刷新重连不丢轨迹,实测事后连仍补齐全程)| 可靠性 |
+| ~~P1~~ 🟡 | ~~核心链路集成测试~~:Handle 入口级 6 例 + RAG 管线 4 例(检索→注入→生成→refs→grounded 评测)已补。剩前端 E2E | 测试纵深 |
+| P2 | 计量计费闭环 + 按用户配额;OCR 完善;KbView 拆分;循环节点 | |
+| P2 | 安全审计 / 渗透(让"纵深防御"从自述变已验证) | 外部 |
+| P3 | 多租户/团队;插件体系;K8s Helm;代码签名 | |
+
+---
+
+## 容量实测(首版,2026-06-26)
+
+**方法**:`cmd/loadtest` 闭环加压(阶梯并发,SSE 流检测完成、非轮询,避免轮询放大)。
+dispatcher 开 `LLM_FORCE_STUB=1 LLM_STUB_*_MS=0` 绕开真实 LLM 推理,量**平台自身全链路天花板**
+(网关→NATS/JetStream→调度→图执行→工具RTT→Token回流→PG状态写),单 dispatcher、DISPATCHER_CONCURRENCY=64。
+
+| 并发 | 吞吐/s | p50 | p95 | 备注 |
+|---:|---:|---:|---:|---|
+| 1 | 23 | 42ms | 46ms | **单任务纯平台开销 ~42ms** |
+| 8 | 76 | 103ms | 115ms | |
+| 16 | 96 | 161ms | 176ms | |
+| 32 | 112 | 269ms | 345ms | **吞吐峰值** |
+| 64 | 112 | 557ms | 676ms | 饱和(延迟翻倍、吞吐不增)|
+| 128 | 102 | 1173ms | 1590ms | 过载、优雅降速 |
+| 256 | 0 | — | — | 硬崩(连接/DNS 抖动)|
+
+**结论**:
+1. **单节点平台天花板 ≈ 110 全链路任务/秒**,单任务固有开销 ~42ms,饱和点 ~并发 32–64。
+2. **吞吐瓶颈不是 DB 连接数**(池 25→80 吞吐不变),而是每任务多跳管线(JetStream fsync + 多次
+   NATS 回写 + Redis 录制 + 3 次 PG 状态写)的综合成本。
+3. **C=256 停摆**是单节点饱和(任务排队 + PG 状态写 + 网关 handler 顶不住),非 bug;C≤128 优雅可用。
+   DSN 已由 `localhost` 改 `127.0.0.1`(消除每连接 DNS 解析,卫生改进,但不解决饱和)。正解是横向扩。
+4. **关键判断**:平台开销 42ms ≪ 真实 LLM 出一轮答案的秒级延迟,**平台不是瓶颈,GPU 才是**。
+   单 GPU 跑 Qwen 32B 约出几十轮/秒,远低于平台的 ~110/s → 横向拆服务(队列组多副本喂满 GPU 集群)
+   的意义成立;要提总吞吐应加 dispatcher 副本与 GPU,而非优化平台代码。
+5. 待补:长稳(小时级)压测看内存/句柄泄漏;真实 LLM 端到端容量(受 provider 限流,需自部署后测)。
+
+---
+
+*本报告由 Opus 4.8 基于全程搭建经验 + 本次实测撰写;刻意压低自评水分。功能盘点可信,
+评分按成熟度折扣阅读。如与旧版(自动分析)冲突,以本版为准。*
diff --git a/scripts/backup.sh b/scripts/backup.sh
new file mode 100755
index 0000000..8b9f3da
--- /dev/null
+++ b/scripts/backup.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# 备份 sundynix-agentix 全部有状态数据(PG / Neo4j / Milvus / MinIO / NATS-JetStream)。
+# 用法:scripts/backup.sh [输出目录]   默认 ./backups/<时间戳>
+# 建议 cron 每日跑,产物拷到异机。
+set -euo pipefail
+
+COMPOSE="docker compose -f docker-compose.prod.yml"
+TS="$(date +%Y%m%d-%H%M%S)"
+OUT="${1:-./backups/$TS}"
+mkdir -p "$OUT"
+echo "[backup] → $OUT"
+
+# PostgreSQL:逻辑备份(可跨版本恢复)。
+echo "[backup] postgres…"
+$COMPOSE exec -T postgres pg_dump -U sundynix sundynix | gzip > "$OUT/postgres.sql.gz"
+
+# Neo4j:在线 dump(社区版用 cypher-shell 导出关系;这里用 APOC/dump 兼容写法的简化版——
+# 社区版无 neo4j-admin 在线 dump,改为停服卷拷或用 cypher 导出。此处导出全图为 cypher。)
+echo "[backup] neo4j…"
+$COMPOSE exec -T neo4j sh -lc 'command -v neo4j-admin >/dev/null 2>&1 && neo4j-admin database dump neo4j --to-stdout 2>/dev/null' \
+  > "$OUT/neo4j.dump" 2>/dev/null || echo "[backup] neo4j 在线 dump 不可用(社区版),改用卷快照见下"
+
+# 数据卷快照(Milvus / MinIO / Neo4j 兜底 / JetStream):用临时容器打 tar。
+backup_volume () { # $1=卷名 $2=输出文件
+  local vol; vol="$($COMPOSE config --volumes | grep -E "^${1}\$" || true)"
+  local full="$(docker volume ls --format '{{.Name}}' | grep -E "_${1}\$" | head -1)"
+  [ -z "$full" ] && { echo "[backup] 卷 $1 未找到,跳过"; return; }
+  echo "[backup] volume $full…"
+  docker run --rm -v "$full":/data -v "$(cd "$OUT" && pwd)":/out alpine \
+    sh -c "cd /data && tar czf /out/$2 ."
+}
+backup_volume milvus_data   milvus_data.tar.gz
+backup_volume minio_data    minio_data.tar.gz
+backup_volume neo4j_data    neo4j_data.tar.gz
+backup_volume nats_data     nats_data.tar.gz
+
+echo "[backup] 完成。务必另存 .env(含 SUNDYNIX_SECRET_KEY,丢了模型密钥全废):cp .env $OUT/env.bak"
+echo "$OUT"
diff --git a/scripts/rageval.py b/scripts/rageval.py
new file mode 100644
index 0000000..d9883ae
--- /dev/null
+++ b/scripts/rageval.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+# RAG 离线检索质量评测:四模式(向量/全文/图谱/混合) recall@k + MRR,量化"混合 vs 单路"。
+import json, sys, time, urllib.request
+
+BASE = "http://127.0.0.1:8080"
+KB = "evalset"
+TOKEN = open(sys.argv[1]).read().strip()
+
+# 语料:每条一句话事实,唯一标记=载具名(用于判定召回)。
+CORPUS = [
+    ("启明星一号", "启明星一号是一颗对地观测卫星,由总设计师周文渊研制,搭载高光谱相机。"),
+    ("玄鸟二号", "玄鸟二号是一型运载火箭,由总工程师林书豪设计,使用液氧甲烷发动机。"),
+    ("蛟龙三号", "蛟龙三号是一台载人潜水器,由首席科学家陈海平主持,最大下潜深度7000米。"),
+    ("烛龙四号", "烛龙四号是一颗深空探测器,由项目主管吴宇航负责,用于探测木星磁场。"),
+    ("麒麟五号", "麒麟五号是一座空间站,由总指挥赵长青建造,配备机械臂。"),
+    ("白泽六号", "白泽六号是一颗通信卫星,由总设计师钱明轩研制,实现量子通信。"),
+    ("饕餮七号", "饕餮七号是一艘破冰船,由总工程师孙铁柱主持,采用核动力驱动。"),
+    ("穷奇八号", "穷奇八号是一艘行星际飞船,由首席科学家周伯通设计,依靠太阳帆推进。"),
+    ("梼杌九号", "梼杌九号是一架预警机,由总设计师黄药师研制,配红外制导系统。"),
+    ("应龙十号", "应龙十号是一款高超声速飞行器,由总工程师欧阳锋设计,采用折叠翼。"),
+    ("重明十一号", "重明十一号是一架电动垂直起降飞行器,由总设计师洪七公研制,用全固态电池。"),
+    ("毕方十二号", "毕方十二号是一座防御平台,由总指挥段誉建造,装备激光武器。"),
+    # —— 干扰项:与各目标同族近义,逼向量路在相似项中区分 ——
+    ("天罡卫星", "天罡卫星是一颗对地观测卫星,由总设计师周文博研制,搭载多光谱相机。"),
+    ("地煞火箭", "地煞火箭是一型运载火箭,由总工程师林书文设计,使用液氧煤油发动机。"),
+    ("潜龙号", "潜龙号是一台载人潜水器,由首席科学家陈海峰主持,最大下潜深度5000米。"),
+    ("探火号", "探火号是一颗深空探测器,由项目主管吴宇辉负责,用于探测火星大气。"),
+    ("天宫号", "天宫号是一座空间站,由总指挥赵长河建造,配备太阳能帆板。"),
+    ("鹊桥号", "鹊桥号是一颗通信卫星,由总设计师钱明远研制,做激光通信。"),
+    ("破冰者号", "破冰者号是一艘破冰船,由总工程师孙铁山主持,采用柴油动力。"),
+    ("远航号", "远航号是一艘行星际飞船,由首席科学家周伯文设计,依靠核电推进。"),
+    ("天眼号", "天眼号是一架预警机,由总设计师黄药剂研制,配相控阵雷达。"),
+    ("风刃号", "风刃号是一款高超声速飞行器,由总工程师欧阳峰设计,采用乘波体。"),
+    ("飞羽号", "飞羽号是一架电动垂直起降飞行器,由总设计师洪七叔研制,用液流电池。"),
+    ("盾卫号", "盾卫号是一座防御平台,由总指挥段正淳建造,装备动能拦截弹。"),
+]
+# 查询:q=问句, m=期望命中的标记, t=类型(便于看各路强弱)
+QUERIES = [
+    ("哪台设备可以下潜到七千米的深海", "蛟龙三号", "语义"),
+    ("用来在冰封海域开路的核动力船", "饕餮七号", "语义"),
+    ("能垂直起降的电动飞行器用了什么电池", "重明十一号", "语义"),
+    ("靠激光拦截目标的防御系统是哪个", "毕方十二号", "语义"),
+    ("液氧甲烷发动机用在哪", "玄鸟二号", "关键词"),
+    ("木星磁场探测", "烛龙四号", "关键词"),
+    ("量子通信卫星", "白泽六号", "关键词"),
+    ("太阳帆推进", "穷奇八号", "关键词"),
+    ("周文渊负责研制的是什么", "启明星一号", "实体关系"),
+    ("孙铁柱主持的项目", "饕餮七号", "实体关系"),
+    ("黄药师设计了哪款预警机", "梼杌九号", "实体关系"),
+    ("赵长青建造的空间站叫什么", "麒麟五号", "实体关系"),
+    # —— 纯语义改写:尽量避开与目标文档的字面重叠,逼全文路失手、看混合是否兜住 ——
+    ("可搭乘人员探入数千米幽暗海沟的勘探艇", "蛟龙三号", "纯语义"),
+    ("环绕巨行星测量其磁场强度的太空仪器", "烛龙四号", "纯语义"),
+    ("能从陆地竖直升空、不烧油的载客飞行器", "重明十一号", "纯语义"),
+    ("以高能光束摧毁来袭目标的拦截阵地", "毕方十二号", "纯语义"),
+]
+MODES = ["vector", "fulltext", "graph", "hybrid"]
+
+def post(path, body):
+    req = urllib.request.Request(BASE + path, data=json.dumps(body).encode(),
+        headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}, method="POST")
+    return json.load(urllib.request.urlopen(req, timeout=20))
+
+def ingest():
+    for name, text in CORPUS:
+        post("/api/v1/kb/note", {"kb": KB, "name": name, "content": text})
+    print(f"已提交 {len(CORPUS)} 篇,等待入库(切块/向量/图谱)…")
+
+def search(q, mode, topk=5):
+    r = post("/api/v1/kb/search", {"kb": KB, "q": q, "topK": topk, "mode": mode})
+    return [h.get("Text") or h.get("text") or "" for h in (r.get("hits") or [])]
+
+def rank_of(hits, marker):
+    for i, t in enumerate(hits):
+        if marker in t:
+            return i + 1
+    return 0
+
+def main():
+    if "--noingest" not in sys.argv:
+        ingest()
+        time.sleep(40)  # 等队列把 12 篇全部 embed+图谱抽取完
+    print(f"\n{'模式':<10}{'recall@3':>10}{'recall@5':>10}{'MRR':>8}")
+    print("-" * 40)
+    per_type = {m: {} for m in MODES}
+    for mode in MODES:
+        r3 = r5 = mrr = 0.0
+        bytype = {}
+        for q, marker, typ in QUERIES:
+            hits = search(q, mode)
+            rk = rank_of(hits, marker)
+            hit3, hit5 = (1 if 0 < rk <= 3 else 0), (1 if 0 < rk <= 5 else 0)
+            r3 += hit3; r5 += hit5; mrr += (1.0 / rk if rk else 0.0)
+            bytype.setdefault(typ, [0, 0]); bytype[typ][0] += hit5; bytype[typ][1] += 1
+        n = len(QUERIES)
+        print(f"{mode:<10}{r3/n:>10.2f}{r5/n:>10.2f}{mrr/n:>8.2f}")
+        per_type[mode] = {t: f"{v[0]}/{v[1]}" for t, v in bytype.items()}
+    types = list(dict.fromkeys(t for _, _, t in QUERIES))
+    print("\n各模式 recall@5 分查询类型(看各路强弱):")
+    print("模式".ljust(10) + "".join(t.rjust(10) for t in types))
+    for mode in MODES:
+        d = per_type[mode]
+        print(mode.ljust(10) + "".join(d.get(t, "").rjust(10) for t in types))
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/restore.sh b/scripts/restore.sh
new file mode 100755
index 0000000..b38160b
--- /dev/null
+++ b/scripts/restore.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# 从 backup.sh 的产物恢复。用法:scripts/restore.sh <备份目录>
+# 会停服 → 恢复 PG(逻辑)+ 各数据卷(覆盖)→ 重启。务必先确认目录正确,操作不可逆。
+set -euo pipefail
+
+COMPOSE="docker compose -f docker-compose.prod.yml"
+DIR="${1:?用法: scripts/restore.sh <备份目录>}"
+[ -d "$DIR" ] || { echo "目录不存在: $DIR"; exit 1; }
+echo "[restore] 从 $DIR 恢复,将覆盖现有数据。Ctrl-C 取消,5s 后继续…"; sleep 5
+
+echo "[restore] 停应用(保留基建跑着以便恢复 PG)…"
+$COMPOSE stop gateway dispatcher mcp-go mcp-py admin || true
+
+# PostgreSQL:逻辑恢复(先清库再灌)。
+if [ -f "$DIR/postgres.sql.gz" ]; then
+  echo "[restore] postgres…"
+  $COMPOSE up -d postgres; sleep 5
+  $COMPOSE exec -T postgres psql -U sundynix -d sundynix -c \
+    "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
+  gunzip -c "$DIR/postgres.sql.gz" | $COMPOSE exec -T postgres psql -U sundynix -d sundynix
+fi
+
+# 数据卷恢复(停服 → 清空卷 → 解 tar)。
+restore_volume () { # $1=卷后缀 $2=tar 文件
+  [ -f "$DIR/$2" ] || { echo "[restore] 无 $2,跳过"; return; }
+  local full; full="$(docker volume ls --format '{{.Name}}' | grep -E "_${1}\$" | head -1)"
+  [ -z "$full" ] && { echo "[restore] 卷 $1 未找到,跳过"; return; }
+  echo "[restore] volume $full ← $2"
+  docker run --rm -v "$full":/data -v "$(cd "$DIR" && pwd)":/in alpine \
+    sh -c "rm -rf /data/* && cd /data && tar xzf /in/$2"
+}
+echo "[restore] 停基建以恢复卷…"; $COMPOSE down
+restore_volume milvus_data milvus_data.tar.gz
+restore_volume minio_data  minio_data.tar.gz
+restore_volume neo4j_data  neo4j_data.tar.gz
+restore_volume nats_data   nats_data.tar.gz
+
+echo "[restore] 重启全部…"; $COMPOSE -f docker-compose.prod.yml up -d
+echo "[restore] 完成。若恢复到新机,记得把备份里的 env.bak 还原为 .env。"
diff --git a/sundynix-admin/.dockerignore b/sundynix-admin/.dockerignore
new file mode 100644
index 0000000..9c97bbd
--- /dev/null
+++ b/sundynix-admin/.dockerignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.env
diff --git a/sundynix-admin/Dockerfile b/sundynix-admin/Dockerfile
new file mode 100644
index 0000000..fd95b6d
--- /dev/null
+++ b/sundynix-admin/Dockerfile
@@ -0,0 +1,15 @@
+# 运维控制台:node 构建 SPA → nginx 托管 + 反代网关 API(一个 URL 访问整套)。
+#   docker build -t sundynix/admin sundynix-admin
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+# VITE_GATEWAY 留空 → 前端走相对路径 /api/...,由 nginx 反代到 gateway(同源、免跨域)。
+ENV VITE_GATEWAY=""
+RUN npm run build
+
+FROM nginx:1.27-alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/sundynix-admin/nginx.conf b/sundynix-admin/nginx.conf
new file mode 100644
index 0000000..a09f04a
--- /dev/null
+++ b/sundynix-admin/nginx.conf
@@ -0,0 +1,30 @@
+# 运维控制台静态托管 + 反代网关 API(一个 URL 同时给前端与 /api)。
+server {
+    listen 80;
+    server_name _;
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # SPA 路由:找不到文件回退 index.html(前端路由接管)。
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # 网关 API:反代到 compose 网络内的 gateway 服务。
+    location /api/ {
+        proxy_pass http://gateway:8080;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        # SSE(token 流 / 执行轨迹):关闭缓冲 + 长超时,否则流式会被攒住/断开。
+        proxy_buffering off;
+        proxy_cache off;
+        proxy_read_timeout 1h;
+        proxy_send_timeout 1h;
+    }
+
+    # 健康/指标直通(运维探活)。
+    location = /healthz { proxy_pass http://gateway:8080; }
+    location = /readyz  { proxy_pass http://gateway:8080; }
+}
diff --git a/sundynix-admin/package-lock.json b/sundynix-admin/package-lock.json
index 71df158..4236ccb 100644
--- a/sundynix-admin/package-lock.json
+++ b/sundynix-admin/package-lock.json
@@ -13,16 +13,28 @@
         "react-router-dom": "^7.1.0"
       },
       "devDependencies": {
+        "@testing-library/jest-dom": "^6.9.1",
+        "@testing-library/react": "^16.3.2",
+        "@testing-library/user-event": "^14.6.1",
         "@types/react": "^19.0.0",
         "@types/react-dom": "^19.0.0",
         "@vitejs/plugin-react": "^4.3.0",
         "autoprefixer": "^10.4.0",
+        "jsdom": "^29.1.1",
         "postcss": "^8.4.0",
         "tailwindcss": "^3.4.0",
         "typescript": "^5.6.0",
-        "vite": "^5.4.0"
+        "vite": "^5.4.0",
+        "vitest": "^4.1.9"
       }
     },
+    "node_modules/@adobe/css-tools": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+      "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@alloc/quick-lru": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -36,6 +48,57 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "5.1.11",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+      "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/generational-cache": "^1.0.1",
+        "@csstools/css-calc": "^3.2.0",
+        "@csstools/css-color-parser": "^4.1.0",
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/dom-selector": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+      "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/generational-cache": "^1.0.1",
+        "@asamuzakjp/nwsapi": "^2.3.9",
+        "bidi-js": "^1.0.3",
+        "css-tree": "^3.2.1",
+        "is-potential-custom-element-name": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/generational-cache": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+      "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/nwsapi": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+      "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@babel/code-frame": {
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -270,6 +333,16 @@
         "@babel/core": "^7.0.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==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/template": {
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -318,6 +391,193 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@bramus/specificity": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+      "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "css-tree": "^3.0.0"
+      },
+      "bin": {
+        "specificity": "bin/cli.js"
+      }
+    },
+    "node_modules/@csstools/color-helpers": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+      "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+      "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
+      "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^6.1.0",
+        "@csstools/css-calc": "^3.2.1"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+      "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-syntax-patches-for-csstree": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
+      "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "peerDependencies": {
+        "css-tree": "^3.2.1"
+      },
+      "peerDependenciesMeta": {
+        "css-tree": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+      "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.2",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -607,6 +867,24 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/netbsd-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -624,6 +902,24 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/openbsd-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -641,6 +937,24 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/sunos-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -709,6 +1023,24 @@
         "node": ">=12"
       }
     },
+    "node_modules/@exodus/bytes": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+      "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "@noble/hashes": "^1.8.0 || ^2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@noble/hashes": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@jridgewell/gen-mapping": {
       "version": "0.3.13",
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -759,6 +1091,25 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+      "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.3"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -797,6 +1148,273 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@oxc-project/types": {
+      "version": "0.137.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
+      "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
+      "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
+      "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
+      "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
+      "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
+      "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
+      "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
+      "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
+      "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
+      "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
+      "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
+      "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
+      "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
+      "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.11.1",
+        "@emnapi/runtime": "1.11.1",
+        "@napi-rs/wasm-runtime": "^1.1.6"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
+      "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
+      "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
     "node_modules/@rolldown/pluginutils": {
       "version": "1.0.0-beta.27",
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1154,6 +1772,122 @@
         "win32"
       ]
     },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@testing-library/dom": {
+      "version": "10.4.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+      "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^5.0.1",
+        "aria-query": "5.3.0",
+        "dom-accessibility-api": "^0.5.9",
+        "lz-string": "^1.5.0",
+        "picocolors": "1.1.1",
+        "pretty-format": "^27.0.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@testing-library/jest-dom": {
+      "version": "6.9.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+      "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@adobe/css-tools": "^4.4.0",
+        "aria-query": "^5.0.0",
+        "css.escape": "^1.5.1",
+        "dom-accessibility-api": "^0.6.3",
+        "picocolors": "^1.1.1",
+        "redent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=14",
+        "npm": ">=6",
+        "yarn": ">=1"
+      }
+    },
+    "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+      "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@testing-library/react": {
+      "version": "16.3.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+      "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": "^10.0.0",
+        "@types/react": "^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^18.0.0 || ^19.0.0",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@testing-library/user-event": {
+      "version": "14.6.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+      "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": ">=7.21.4"
+      }
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/aria-query": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+      "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1199,6 +1933,24 @@
         "@babel/types": "^7.28.2"
       }
     },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1247,6 +1999,117 @@
         "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
       }
     },
+    "node_modules/@vitest/expect": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+      "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.1.0",
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "4.1.9",
+        "@vitest/utils": "4.1.9",
+        "chai": "^6.2.2",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+      "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+      "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "4.1.9",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+      "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.9",
+        "@vitest/utils": "4.1.9",
+        "magic-string": "^0.30.21",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+      "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+      "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.9",
+        "convert-source-map": "^2.0.0",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
     "node_modules/any-promise": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1275,6 +2138,26 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/aria-query": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+      "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "dequal": "^2.0.3"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/autoprefixer": {
       "version": "10.5.0",
       "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -1325,6 +2208,16 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/bidi-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+      "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "require-from-string": "^2.0.2"
+      }
+    },
     "node_modules/binary-extensions": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1416,6 +2309,16 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/chai": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/chokidar": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1484,6 +2387,27 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/css-tree": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+      "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mdn-data": "2.27.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+      }
+    },
+    "node_modules/css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1504,6 +2428,20 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/data-urls": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+      "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^16.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
     "node_modules/debug": {
       "version": "4.4.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1522,6 +2460,33 @@
         }
       }
     },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/didyoumean": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1536,6 +2501,14 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/dom-accessibility-api": {
+      "version": "0.5.16",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+      "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.5.371",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
@@ -1543,6 +2516,19 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/entities": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+      "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/es-errors": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -1553,6 +2539,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es-module-lexer": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
+      "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/esbuild": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1602,6 +2595,26 @@
         "node": ">=6"
       }
     },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/fast-glob": {
       "version": "3.3.3",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1730,6 +2743,29 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/html-encoding-sniffer": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+      "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.6.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/is-binary-path": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1792,6 +2828,13 @@
         "node": ">=0.12.0"
       }
     },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/jiti": {
       "version": "1.21.7",
       "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -1809,6 +2852,57 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/jsdom": {
+      "version": "29.1.1",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+      "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^5.1.11",
+        "@asamuzakjp/dom-selector": "^7.1.1",
+        "@bramus/specificity": "^2.4.2",
+        "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+        "@exodus/bytes": "^1.15.0",
+        "css-tree": "^3.2.1",
+        "data-urls": "^7.0.0",
+        "decimal.js": "^10.6.0",
+        "html-encoding-sniffer": "^6.0.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "lru-cache": "^11.3.5",
+        "parse5": "^8.0.1",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^6.0.1",
+        "undici": "^7.25.0",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^8.0.1",
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^16.0.1",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsdom/node_modules/lru-cache": {
+      "version": "11.5.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+      "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
     "node_modules/jsesc": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -1835,6 +2929,267 @@
         "node": ">=6"
       }
     },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
     "node_modules/lilconfig": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1865,6 +3220,34 @@
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/lz-string": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+      "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "bin": {
+        "lz-string": "bin/bin.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==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/mdn-data": {
+      "version": "2.27.1",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+      "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
     "node_modules/merge2": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -1889,6 +3272,16 @@
         "node": ">=8.6"
       }
     },
+    "node_modules/min-indent": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1967,6 +3360,33 @@
         "node": ">= 6"
       }
     },
+    "node_modules/obug": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+      "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+      "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^8.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
     "node_modules/path-parse": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -1974,6 +3394,13 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2177,6 +3604,32 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/pretty-format": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1",
+        "ansi-styles": "^5.0.0",
+        "react-is": "^17.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/queue-microtask": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2219,6 +3672,14 @@
         "react": "^19.2.7"
       }
     },
+    "node_modules/react-is": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
     "node_modules/react-refresh": {
       "version": "0.17.0",
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2290,6 +3751,30 @@
         "node": ">=8.10.0"
       }
     },
+    "node_modules/redent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "indent-string": "^4.0.0",
+        "strip-indent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/resolve": {
       "version": "1.22.12",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -2323,6 +3808,47 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/rolldown": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
+      "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.137.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.1.3",
+        "@rolldown/binding-darwin-arm64": "1.1.3",
+        "@rolldown/binding-darwin-x64": "1.1.3",
+        "@rolldown/binding-freebsd-x64": "1.1.3",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.3",
+        "@rolldown/binding-linux-arm64-musl": "1.1.3",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.3",
+        "@rolldown/binding-linux-x64-gnu": "1.1.3",
+        "@rolldown/binding-linux-x64-musl": "1.1.3",
+        "@rolldown/binding-openharmony-arm64": "1.1.3",
+        "@rolldown/binding-wasm32-wasi": "1.1.3",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.3",
+        "@rolldown/binding-win32-x64-msvc": "1.1.3"
+      }
+    },
+    "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/rollup": {
       "version": "4.61.1",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
@@ -2392,6 +3918,19 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
     "node_modules/scheduler": {
       "version": "0.27.0",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2414,6 +3953,13 @@
       "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
       "license": "MIT"
     },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2424,6 +3970,33 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+      "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/strip-indent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "min-indent": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/sucrase": {
       "version": "3.35.1",
       "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -2460,6 +4033,13 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/tailwindcss": {
       "version": "3.4.19",
       "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -2521,6 +4101,23 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+      "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.17",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2569,6 +4166,36 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/tinyrainbow": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+      "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tldts": {
+      "version": "7.4.5",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz",
+      "integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^7.4.5"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "7.4.5",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz",
+      "integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/to-regex-range": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2582,6 +4209,32 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/tough-cookie": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+      "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^7.0.5"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+      "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
     "node_modules/ts-interface-checker": {
       "version": "0.1.13",
       "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -2589,6 +4242,14 @@
       "dev": true,
       "license": "Apache-2.0"
     },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
     "node_modules/typescript": {
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2603,6 +4264,16 @@
         "node": ">=14.17"
       }
     },
+    "node_modules/undici": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+      "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
     "node_modules/update-browserslist-db": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2701,6 +4372,754 @@
         }
       }
     },
+    "node_modules/vitest": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+      "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "4.1.9",
+        "@vitest/mocker": "4.1.9",
+        "@vitest/pretty-format": "4.1.9",
+        "@vitest/runner": "4.1.9",
+        "@vitest/snapshot": "4.1.9",
+        "@vitest/spy": "4.1.9",
+        "@vitest/utils": "4.1.9",
+        "es-module-lexer": "^2.0.0",
+        "expect-type": "^1.3.0",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "std-env": "^4.0.0-rc.1",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^1.0.2",
+        "tinyglobby": "^0.2.15",
+        "tinyrainbow": "^3.1.0",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "4.1.9",
+        "@vitest/browser-preview": "4.1.9",
+        "@vitest/browser-webdriverio": "4.1.9",
+        "@vitest/coverage-istanbul": "4.1.9",
+        "@vitest/coverage-v8": "4.1.9",
+        "@vitest/ui": "4.1.9",
+        "happy-dom": "*",
+        "jsdom": "*",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/coverage-istanbul": {
+          "optional": true
+        },
+        "@vitest/coverage-v8": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        },
+        "vite": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vitest/node_modules/@vitest/mocker": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+      "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "4.1.9",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.21"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vitest/node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "peer": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/vitest/node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/vitest/node_modules/vite": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
+      "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.4",
+        "postcss": "^8.5.15",
+        "rolldown": "~1.1.2",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.3.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+      "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+      "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+      "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.11.0",
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/yallist": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/sundynix-admin/package.json b/sundynix-admin/package.json
index 8dc3fe9..9b3e1cf 100644
--- a/sundynix-admin/package.json
+++ b/sundynix-admin/package.json
@@ -6,7 +6,9 @@
   "scripts": {
     "dev": "vite",
     "build": "tsc && vite build",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest"
   },
   "dependencies": {
     "react": "^19.0.0",
@@ -14,13 +16,18 @@
     "react-router-dom": "^7.1.0"
   },
   "devDependencies": {
+    "@testing-library/jest-dom": "^6.9.1",
+    "@testing-library/react": "^16.3.2",
+    "@testing-library/user-event": "^14.6.1",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "@vitejs/plugin-react": "^4.3.0",
+    "autoprefixer": "^10.4.0",
+    "jsdom": "^29.1.1",
+    "postcss": "^8.4.0",
+    "tailwindcss": "^3.4.0",
     "typescript": "^5.6.0",
     "vite": "^5.4.0",
-    "tailwindcss": "^3.4.0",
-    "autoprefixer": "^10.4.0",
-    "postcss": "^8.4.0"
+    "vitest": "^4.1.9"
   }
 }
diff --git a/sundynix-admin/src/api.test.ts b/sundynix-admin/src/api.test.ts
new file mode 100644
index 0000000..ffcf191
--- /dev/null
+++ b/sundynix-admin/src/api.test.ts
@@ -0,0 +1,269 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
+import {
+  setToken,
+  getToken,
+  clearToken,
+  login,
+  me,
+  listModels,
+  saveModel,
+  gatewayOnline,
+  groupPrompts,
+  listPrompts,
+  createPromptVersion,
+  activatePrompt,
+  deactivatePrompt,
+  type PromptVersion,
+} from "./api";
+
+// 构造一个 fetch 响应桩。
+function res(body: unknown, init?: { ok?: boolean; status?: number }): Response {
+  return {
+    ok: init?.ok ?? true,
+    status: init?.status ?? 200,
+    json: async () => body,
+  } as unknown as Response;
+}
+
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+  clearToken();
+  localStorage.clear();
+  fetchMock.mockReset();
+  vi.stubGlobal("fetch", fetchMock);
+});
+
+afterEach(() => {
+  vi.unstubAllGlobals();
+});
+
+describe("令牌持久化", () => {
+  it("setToken 写入内存并持久化到 localStorage", () => {
+    setToken("abc");
+    expect(getToken()).toBe("abc");
+    expect(localStorage.getItem("sdx_admin_token")).toBe("abc");
+  });
+
+  it("clearToken 清空内存与 localStorage", () => {
+    setToken("abc");
+    clearToken();
+    expect(getToken()).toBe("");
+    expect(localStorage.getItem("sdx_admin_token")).toBeNull();
+  });
+});
+
+describe("authHeaders(经由带鉴权的请求观测)", () => {
+  it("有令牌时带上 Bearer 头", async () => {
+    setToken("tok123");
+    fetchMock.mockResolvedValue(res({ models: [] }));
+    await listModels("chat");
+    const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record;
+    expect(headers.Authorization).toBe("Bearer tok123");
+  });
+
+  it("无令牌时不带 Authorization 头", async () => {
+    fetchMock.mockResolvedValue(res({ models: [] }));
+    await listModels("chat");
+    const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record;
+    expect(headers.Authorization).toBeUndefined();
+  });
+
+  it("写请求带 JSON Content-Type", async () => {
+    setToken("tok");
+    fetchMock.mockResolvedValue(res({ id: "1" }));
+    await saveModel({ kind: "chat", provider: "openai", base_url: "u", api_key: "k", model: "m" });
+    const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record;
+    expect(headers["Content-Type"]).toBe("application/json");
+  });
+});
+
+describe("guard:401 清令牌并广播登出", () => {
+  it("401 时清令牌并派发 sdx:logout 事件", async () => {
+    setToken("stale");
+    const onLogout = vi.fn();
+    window.addEventListener("sdx:logout", onLogout);
+    fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 }));
+
+    await expect(listModels("chat")).rejects.toThrow();
+    expect(getToken()).toBe("");
+    expect(onLogout).toHaveBeenCalledOnce();
+    window.removeEventListener("sdx:logout", onLogout);
+  });
+
+  it("403(已登录非管理员)照常抛错,但不清令牌/不登出", async () => {
+    setToken("validuser");
+    const onLogout = vi.fn();
+    window.addEventListener("sdx:logout", onLogout);
+    fetchMock.mockResolvedValue(res({}, { ok: false, status: 403 }));
+
+    await expect(listModels("chat")).rejects.toThrow();
+    expect(getToken()).toBe("validuser"); // 令牌保留
+    expect(onLogout).not.toHaveBeenCalled();
+    window.removeEventListener("sdx:logout", onLogout);
+  });
+});
+
+describe("login", () => {
+  it("成功时存令牌并返回用户", async () => {
+    fetchMock.mockResolvedValue(
+      res({ token: "t1", user: { id: "u1", email: "a@b.c" } }),
+    );
+    const user = await login("a@b.c", "pw");
+    expect(user.id).toBe("u1");
+    expect(getToken()).toBe("t1");
+  });
+
+  it("失败时抛出后端 error 文案且不存令牌", async () => {
+    fetchMock.mockResolvedValue(res({ error: "密码错误" }, { ok: false, status: 401 }));
+    await expect(login("a@b.c", "bad")).rejects.toThrow("密码错误");
+    expect(getToken()).toBe("");
+  });
+
+  it("无 error 字段时回退到状态码文案", async () => {
+    fetchMock.mockResolvedValue(res({}, { ok: false, status: 500 }));
+    await expect(login("a@b.c", "pw")).rejects.toThrow("500");
+  });
+});
+
+describe("me", () => {
+  it("无令牌时直接返回 null(不发请求)", async () => {
+    const u = await me();
+    expect(u).toBeNull();
+    expect(fetchMock).not.toHaveBeenCalled();
+  });
+
+  it("响应非 ok 时清令牌并返回 null", async () => {
+    setToken("expired");
+    fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 }));
+    expect(await me()).toBeNull();
+    expect(getToken()).toBe("");
+  });
+
+  it("成功时返回用户", async () => {
+    setToken("ok");
+    fetchMock.mockResolvedValue(res({ user: { id: "u1", email: "a@b.c" } }));
+    expect(await me()).toEqual({ id: "u1", email: "a@b.c" });
+  });
+});
+
+describe("gatewayOnline", () => {
+  it("healthz ok 时为 true", async () => {
+    fetchMock.mockResolvedValue(res(null, { ok: true }));
+    expect(await gatewayOnline()).toBe(true);
+  });
+
+  it("healthz 非 ok 时为 false", async () => {
+    fetchMock.mockResolvedValue(res(null, { ok: false, status: 503 }));
+    expect(await gatewayOnline()).toBe(false);
+  });
+
+  it("网络异常时吞掉错误并返回 false", async () => {
+    fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
+    expect(await gatewayOnline()).toBe(false);
+  });
+});
+
+// —— Prompt 控制面 ——
+function pv(key: string, version: number, active = false): PromptVersion {
+  return { key, version, active, note: "", content: `c${version}` };
+}
+
+describe("groupPrompts(控制面分组:纯函数)", () => {
+  it("保持 keys 注册顺序,未建版本的 key 也成组(active=null)", () => {
+    const gs = groupPrompts({ keys: ["a", "b", "c"], versions: [pv("b", 1)] });
+    expect(gs.map((g) => g.key)).toEqual(["a", "b", "c"]);
+    expect(gs[0]).toMatchObject({ key: "a", versions: [], active: null });
+    expect(gs[1].versions).toHaveLength(1);
+  });
+
+  it("每组版本号倒序,挑出激活版", () => {
+    const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 3, true), pv("k", 2)] });
+    expect(gs[0].versions.map((v) => v.version)).toEqual([3, 2, 1]);
+    expect(gs[0].active?.version).toBe(3);
+  });
+
+  it("无激活版时 active 为 null(回退代码默认)", () => {
+    const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 2)] });
+    expect(gs[0].active).toBeNull();
+  });
+
+  it("DB 出现 Known 未列的 key 也容错展示(追加在已知之后)", () => {
+    const gs = groupPrompts({ keys: ["known"], versions: [pv("orphan", 1, true)] });
+    expect(gs.map((g) => g.key)).toEqual(["known", "orphan"]);
+    expect(gs[1].active?.version).toBe(1);
+  });
+});
+
+describe("listPrompts", () => {
+  it("缺字段时回退为空数组(不崩)", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({}));
+    expect(await listPrompts()).toEqual({ keys: [], versions: [] });
+  });
+
+  it("非 ok 抛错", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({}, { ok: false, status: 502 }));
+    await expect(listPrompts()).rejects.toThrow("502");
+  });
+
+  it("命中 /api/v1/prompts(不在 /admin 前缀下)", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ keys: [], versions: [] }));
+    await listPrompts();
+    expect(fetchMock.mock.calls[0][0]).toMatch(/\/api\/v1\/prompts$/);
+  });
+});
+
+describe("createPromptVersion", () => {
+  it("成功返回新版本号", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ key: "k", version: 4 }));
+    expect(await createPromptVersion("k", "body", "note")).toBe(4);
+    const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
+    expect(body).toEqual({ key: "k", content: "body", note: "note" });
+  });
+
+  it("失败抛后端 error 文案", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ error: "key/content required" }, { ok: false, status: 400 }));
+    await expect(createPromptVersion("k", "", "")).rejects.toThrow("key/content required");
+  });
+});
+
+describe("activatePrompt", () => {
+  it("成功无异常", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ activated: true }));
+    await expect(activatePrompt("k", 2)).resolves.toBeUndefined();
+  });
+
+  it("已激活但广播失败(warn) → 当作错误抛出提醒运维", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ warn: "已激活但广播失败: nats down" }));
+    await expect(activatePrompt("k", 2)).rejects.toThrow("广播失败");
+  });
+
+  it("HTTP 失败抛 error", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ error: "key/version required" }, { ok: false, status: 400 }));
+    await expect(activatePrompt("k", 0)).rejects.toThrow("key/version required");
+  });
+});
+
+describe("deactivatePrompt", () => {
+  it("成功无异常并带上 key", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ deactivated: true }));
+    await deactivatePrompt("k");
+    const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
+    expect(body).toEqual({ key: "k" });
+  });
+
+  it("失败抛错", async () => {
+    setToken("t");
+    fetchMock.mockResolvedValue(res({ error: "boom" }, { ok: false, status: 502 }));
+    await expect(deactivatePrompt("k")).rejects.toThrow("boom");
+  });
+});
diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts
index e3e86d9..cdad392 100644
--- a/sundynix-admin/src/api.ts
+++ b/sundynix-admin/src/api.ts
@@ -125,6 +125,7 @@ export interface Pricing {
   model_id: string;
   input_per_1k: number;
   output_per_1k: number;
+  credit_weight: number; // 每模型积分权重(0/缺省=1.0,不加权)
   currency: string;
 }
 
@@ -142,6 +143,38 @@ export async function savePricing(p: Pricing): Promise {
   }
 }
 
+// —— 全局计费规则(token→积分汇率 + 硬拦截开关)——
+export async function getBillingConfig(): Promise<{ tokens_per_credit: number; credit_enforce: boolean }> {
+  const res = guard(await fetch(`${ADMIN}/billing-config`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`billing config failed: ${res.status}`);
+  const d = (await res.json()) as { tokens_per_credit?: string; credit_enforce?: boolean };
+  return { tokens_per_credit: Number(d.tokens_per_credit) || 1000, credit_enforce: !!d.credit_enforce }; // 空/未设 → 回退默认
+}
+
+export async function saveBillingConfig(tokensPerCredit: number, creditEnforce: boolean): Promise {
+  const res = guard(
+    await fetch(`${ADMIN}/billing-config`, {
+      method: "PUT",
+      headers: authHeaders(true),
+      body: JSON.stringify({ tokens_per_credit: tokensPerCredit, credit_enforce: creditEnforce }),
+    }),
+  );
+  if (!res.ok) {
+    const d = (await res.json().catch(() => ({}))) as { error?: string };
+    throw new Error(d.error ?? `save billing config failed: ${res.status}`);
+  }
+}
+
+// —— 充值/发放积分(正=充值,负=校正)——
+export async function grantCredits(tenantId: string, credits: number, memo: string): Promise {
+  const res = guard(
+    await fetch(`${ADMIN}/credits/grant`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ tenant_id: tenantId, credits, memo }) }),
+  );
+  const d = (await res.json().catch(() => ({}))) as { balance_micro?: number; error?: string };
+  if (!res.ok) throw new Error(d.error ?? `grant failed: ${res.status}`);
+  return d.balance_micro ?? 0;
+}
+
 // gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
 export async function gatewayOnline(): Promise {
   try {
@@ -151,3 +184,328 @@ export async function gatewayOnline(): Promise {
     return false;
   }
 }
+
+// —— 服务状态:基建 / 应用服务探活 + MCP 工具注册 ——
+export interface StatusItem {
+  name: string;
+  up: boolean;
+  detail?: string;
+  latency_ms?: number;
+}
+export interface ToolInfo {
+  name: string;
+  cn: string;
+  desc: string;
+}
+export interface ToolGroup {
+  server: string;
+  up: boolean;
+  tools: ToolInfo[] | null;
+}
+export interface SystemStatus {
+  checked_at: string;
+  infra: StatusItem[];
+  services: StatusItem[];
+  tools: ToolGroup[];
+}
+
+export async function getStatus(): Promise {
+  const res = guard(await fetch(`${ADMIN}/status`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`status failed: ${res.status}`);
+  return (await res.json()) as SystemStatus;
+}
+
+// —— 工作台概览(仪表盘聚合)——
+// /api/v1/stats/overview 在 RequireAuth 组下;Task/Eval 表无 owner,故任务/评测口径为全局。
+export interface DayCount {
+  key: string;
+  count: number;
+}
+export interface RecentRun {
+  task_id: string;
+  status: string;
+  detail: string;
+  at: string;
+}
+export interface Overview {
+  tasks_today: number;
+  tasks_total: number;
+  status_count: DayCount[]; // 近 7 天终态分布
+  task_trend: DayCount[]; // 近 7 天每日任务数(仅含有任务的天)
+  eval_avg: number;
+  faithful_avg: number; // 仅有来源的评测;无来源时为 0
+  eval_count: number;
+  kb_docs: number;
+  kb_count: number;
+  tokens_today: number;
+  daily_budget: number; // 0 = 不限额
+  token_trend: DayCount[];
+  recent_runs: RecentRun[];
+  services: Record;
+}
+
+export async function statsOverview(): Promise {
+  const res = guard(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`overview failed: ${res.status}`);
+  return (await res.json()) as Overview;
+}
+
+// —— 管理端系统级聚合(控制塔口径,RequireAdmin)——
+// 区别于 statsOverview(桌面端个人工作台):这里一律全平台口径——全部用户/任务/评测/模型态/提示词态/健康。
+// 单模型运行时健康态(failover 链上主/备各一条)。
+export interface ModelHealthItem {
+  provider: string;
+  model: string;
+  role: string; // primary / fallback
+  state: string; // closed(在线) / open(熔断中) / half-open(半开探测) / single(无备用链)
+  fails: number;
+}
+
+export interface AdminOverview {
+  users: number;
+  kb_count: number; // 全平台知识库数
+  kb_docs: number; // 全平台文档数
+  tasks_today: number;
+  tasks_total: number;
+  status_count: DayCount[];
+  task_trend: DayCount[];
+  eval_avg: number;
+  faithful_avg: number;
+  eval_count: number;
+  models: {
+    chat_count: number;
+    embedding_count: number;
+    active_chat: string;
+    active_embedding: string;
+    fallbacks: number;
+    health: ModelHealthItem[]; // 运行时每模型 failover/熔断态(来自 dispatcher)
+  };
+  prompts: { managed: number; overrides: number };
+  services: Record;
+  checked_at: string;
+}
+
+export async function adminOverview(): Promise {
+  const res = guard(await fetch(`${ADMIN}/overview`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`admin overview failed: ${res.status}`);
+  return (await res.json()) as AdminOverview;
+}
+
+// —— 审计 / 安全事件(敏感操作留痕 + 护栏命中)——
+export interface AuditEntry {
+  id: string;
+  actor: string; // 操作者 uid
+  action: string; // POST / PUT / DELETE / PATCH
+  route: string;
+  path: string;
+  status: number;
+  ip: string;
+  detail: string;
+  at: string;
+}
+export interface GuardrailEventItem {
+  id: string;
+  actor: string;
+  kind: string; // blocked / suspect
+  reason: string;
+  signals: string; // JSON 数组字符串
+  method: string;
+  path: string;
+  ip: string;
+  at: string;
+}
+
+export async function listAudit(limit = 50, offset = 0): Promise {
+  const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`audit failed: ${res.status}`);
+  return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
+}
+
+export async function listGuardrailEvents(limit = 50, offset = 0): Promise {
+  const res = guard(await fetch(`${ADMIN}/guardrail-events?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`guardrail events failed: ${res.status}`);
+  return ((await res.json()) as { events?: GuardrailEventItem[] }).events ?? [];
+}
+
+// —— Prompt 控制面(建版本 → 激活 → 控制面热下发各服务,不重启即生效)——
+// 注意:prompt 路由在 RequireAuth 组下(/api/v1/prompts),不在 /admin 前缀内。
+const PROMPTS = `${GATEWAY}/api/v1/prompts`;
+
+export interface PromptVersion {
+  key: string;
+  version: number;
+  active: boolean;
+  note: string;
+  content: string;
+}
+
+export interface PromptListResp {
+  keys: string[]; // 平台受管的全部可配 key(即便还没建过版本)
+  versions: PromptVersion[];
+}
+
+// 某 key 的聚合视图:版本按号倒序、当前激活版(无则 null = 回退代码内置默认)。
+export interface PromptGroup {
+  key: string;
+  versions: PromptVersion[];
+  active: PromptVersion | null;
+}
+
+// groupPrompts 把扁平的 {keys, versions} 归并成按 key 的分组(控制面页面的事实源)。
+// 纯函数:保持 keys 的注册顺序,每组版本号倒序,挑出激活版。
+export function groupPrompts(resp: PromptListResp): PromptGroup[] {
+  const byKey = new Map();
+  for (const k of resp.keys) byKey.set(k, []);
+  for (const v of resp.versions) {
+    if (!byKey.has(v.key)) byKey.set(v.key, []); // 容错:DB 有但 Known 未列的 key 也展示
+    byKey.get(v.key)!.push(v);
+  }
+  return Array.from(byKey.entries()).map(([key, vs]) => {
+    const versions = [...vs].sort((a, b) => b.version - a.version);
+    return { key, versions, active: versions.find((v) => v.active) ?? null };
+  });
+}
+
+export async function listPrompts(): Promise {
+  const res = guard(await fetch(PROMPTS, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`list prompts failed: ${res.status}`);
+  const data = (await res.json()) as Partial;
+  return { keys: data.keys ?? [], versions: data.versions ?? [] };
+}
+
+export async function createPromptVersion(key: string, content: string, note: string): Promise {
+  const res = guard(
+    await fetch(`${PROMPTS}/version`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key, content, note }) }),
+  );
+  const data = (await res.json()) as { version?: number; error?: string };
+  if (!res.ok) throw new Error(data.error ?? `create version failed: ${res.status}`);
+  return data.version ?? 0;
+}
+
+export async function activatePrompt(key: string, version: number): Promise {
+  const res = guard(
+    await fetch(`${PROMPTS}/activate`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key, version }) }),
+  );
+  const data = (await res.json()) as { error?: string; warn?: string };
+  if (!res.ok) throw new Error(data.error ?? `activate failed: ${res.status}`);
+  if (data.warn) throw new Error(data.warn); // 已激活但广播失败 → 当作错误提示运维
+}
+
+export async function deactivatePrompt(key: string): Promise {
+  const res = guard(await fetch(`${PROMPTS}/deactivate`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key }) }));
+  if (!res.ok) {
+    const d = (await res.json().catch(() => ({}))) as { error?: string };
+    throw new Error(d.error ?? `deactivate failed: ${res.status}`);
+  }
+}
+
+// —— 用量 / 计费(SaaS P2 计量:token / 积分 / 成本,按租户)——
+// 金额与积分均为「微单位」(×10⁻⁶):展示时 ÷1e6。走 /admin/usage(系统级,跨租户)。
+export interface UsageDay {
+  day: string; // YYYYMMDD
+  total_tok: number;
+  credits_micro: number;
+  cost_micros: number;
+  task_count: number;
+}
+export interface UsageTenantSum {
+  tenant_id: string;
+  name: string;
+  total_tok: number;
+  credits_micro: number;
+  cost_micros: number;
+  task_count: number;
+  balance_micro: number;
+}
+export interface UsageReport {
+  from: string;
+  to: string;
+  tenant: string; // 空 = 全平台口径
+  trend: UsageDay[];
+  totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
+  balance_micro?: number; // 仅单租户口径
+  tenants?: UsageTenantSum[]; // 仅全平台口径:各租户排行
+}
+
+export async function adminUsage(params?: { tenant?: string; from?: string; to?: string }): Promise {
+  const q = new URLSearchParams();
+  if (params?.tenant) q.set("tenant", params.tenant);
+  if (params?.from) q.set("from", params.from);
+  if (params?.to) q.set("to", params.to);
+  const qs = q.toString();
+  const res = guard(await fetch(`${ADMIN}/usage${qs ? "?" + qs : ""}`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`usage failed: ${res.status}`);
+  const d = (await res.json()) as UsageReport;
+  return { ...d, trend: d.trend ?? [], tenants: d.tenants ?? [] }; // Go 空切片可能序列化为 null
+}
+
+// —— 多成员租户:租户目录 + 成员管理 ——
+export interface TenantRow {
+  id: string;
+  name: string;
+  slug: string;
+  plan: string;
+  status: string;
+  credit_balance_micro: number;
+  shared_billing: boolean;
+  members: number;
+}
+export interface Member {
+  user_id: string;
+  email: string;
+  name: string;
+  role: string; // owner / admin / member / viewer / billing_admin
+  status: string;
+  joined_at: string;
+}
+
+export async function listTenants(): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`list tenants failed: ${res.status}`);
+  return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
+}
+
+export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
+  const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
+  if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
+  if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
+}
+
+export async function listMembers(tenantId: string): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { headers: authHeaders() }));
+  if (!res.ok) throw new Error(`list members failed: ${res.status}`);
+  return ((await res.json()) as { members?: Member[] }).members ?? [];
+}
+
+export async function addMember(tenantId: string, email: string, role: string): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ email, role }) }));
+  if (!res.ok) {
+    const d = (await res.json().catch(() => ({}))) as { error?: string };
+    throw new Error(d.error ?? `add member failed: ${res.status}`);
+  }
+}
+
+export async function setMemberRole(tenantId: string, uid: string, role: string): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ role }) }));
+  if (!res.ok) {
+    const d = (await res.json().catch(() => ({}))) as { error?: string };
+    throw new Error(d.error ?? `set role failed: ${res.status}`);
+  }
+}
+
+export async function removeMember(tenantId: string, uid: string): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "DELETE", headers: authHeaders() }));
+  if (!res.ok) {
+    const d = (await res.json().catch(() => ({}))) as { error?: string };
+    throw new Error(d.error ?? `remove member failed: ${res.status}`);
+  }
+}
+
+export async function setSharedBilling(tenantId: string, on: boolean): Promise {
+  const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/shared-billing`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ shared_billing: on }) }));
+  if (!res.ok) {
+    const d = (await res.json().catch(() => ({}))) as { error?: string };
+    throw new Error(d.error ?? `set shared billing failed: ${res.status}`);
+  }
+}
diff --git a/sundynix-admin/src/components/BillingRules.tsx b/sundynix-admin/src/components/BillingRules.tsx
new file mode 100644
index 0000000..6729d17
--- /dev/null
+++ b/sundynix-admin/src/components/BillingRules.tsx
@@ -0,0 +1,213 @@
+import { useEffect, useState } from "react";
+import { listModels, listPricing, savePricing, getBillingConfig, saveBillingConfig, type Model, type Pricing } from "../api";
+
+// 计费规则(配置端):全局 token→积分汇率 + 每模型单价/积分权重。
+// 这是「扣费按规则扣」的规则源——改完立即对后续任务生效;下方用量观测即其结果。
+
+interface Row {
+  model: Model;
+  inPer1k: string;
+  outPer1k: string;
+  weight: string; // 积分权重(空/0 → 后端按 1.0)
+  currency: string;
+  dirty: boolean;
+  saving: boolean;
+  msg: string;
+}
+
+export function BillingRules({ onSaved }: { onSaved?: () => void }) {
+  const [rows, setRows] = useState([]);
+  const [rate, setRate] = useState(""); // tokens_per_credit
+  const [enforce, setEnforce] = useState(false); // 硬拦截开关
+  const [cfgDirty, setCfgDirty] = useState(false);
+  const [cfgSaving, setCfgSaving] = useState(false);
+  const [cfgMsg, setCfgMsg] = useState("");
+  const [loading, setLoading] = useState(true);
+  const [err, setErr] = useState("");
+
+  const load = async () => {
+    setLoading(true);
+    setErr("");
+    try {
+      const [chat, emb, pricing, cfg] = await Promise.all([listModels("chat"), listModels("embedding"), listPricing(), getBillingConfig()]);
+      const byID = new Map(pricing.map((p) => [p.model_id, p]));
+      const mk = (m: Model): Row => {
+        const p = byID.get(m.id);
+        return {
+          model: m,
+          inPer1k: p ? String(p.input_per_1k) : "",
+          outPer1k: p ? String(p.output_per_1k) : "",
+          weight: p && p.credit_weight ? String(p.credit_weight) : "",
+          currency: p?.currency || "CNY",
+          dirty: false,
+          saving: false,
+          msg: "",
+        };
+      };
+      setRows([...chat.map(mk), ...emb.map(mk)]);
+      setRate(String(cfg.tokens_per_credit));
+      setEnforce(cfg.credit_enforce);
+      setCfgDirty(false);
+    } catch (e) {
+      setErr((e as Error).message);
+    } finally {
+      setLoading(false);
+    }
+  };
+  useEffect(() => {
+    void load();
+  }, []);
+
+  const patch = (id: string, p: Partial) => setRows((rs) => rs.map((r) => (r.model.id === id ? { ...r, ...p, dirty: true, msg: "" } : r)));
+
+  const saveRow = async (r: Row) => {
+    setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: true, msg: "" } : x)));
+    try {
+      await savePricing({
+        model_id: r.model.id,
+        input_per_1k: Number(r.inPer1k) || 0,
+        output_per_1k: Number(r.outPer1k) || 0,
+        credit_weight: Number(r.weight) || 0,
+        currency: r.currency || "CNY",
+      });
+      setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, dirty: false, msg: "✓ 已保存" } : x)));
+      onSaved?.();
+    } catch (e) {
+      setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, msg: (e as Error).message } : x)));
+    }
+  };
+
+  const saveConfig = async () => {
+    const n = Number(rate);
+    if (!(n > 0)) {
+      setCfgMsg("汇率必须 > 0");
+      return;
+    }
+    setCfgSaving(true);
+    setCfgMsg("");
+    try {
+      await saveBillingConfig(n, enforce);
+      setCfgDirty(false);
+      setCfgMsg("✓ 已保存");
+      onSaved?.();
+    } catch (e) {
+      setCfgMsg((e as Error).message);
+    } finally {
+      setCfgSaving(false);
+    }
+  };
+
+  if (loading) return 
加载计费规则中…
; + if (err) return
计费规则加载失败:{err}
; + + return ( +
+
+
+

计费规则

+

扣费按此规则实时折算 · 改完对后续任务立即生效

+
+ 配置端 +
+ + {/* 全局:token→积分 汇率 + 硬拦截开关 */} +
+
+ +
+ { + setRate(e.target.value); + setCfgDirty(true); + setCfgMsg(""); + }} + className="w-28 rounded border px-2 py-1 text-right font-mono text-xs focus:border-violet-500 focus:outline-none" + /> + token = 1 积分(设 1 即按 token 直计) +
+
+
+ + +
+ + {cfgMsg && {cfgMsg}} +
+ + {/* 每模型单价 + 积分权重 */} + {rows.length === 0 ? ( +
还没有登记模型,先到「模型」页添加。
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
模型类型输入 / 1K输出 / 1K币种积分权重
+
{r.model.model}
+
{r.model.provider}
+
{r.model.kind} + patch(r.model.id, { inPer1k: e.target.value })} placeholder="0" /> + + patch(r.model.id, { outPer1k: e.target.value })} placeholder="0" /> + + + + patch(r.model.id, { weight: e.target.value })} placeholder="1.0" title="每 token 烧积分的倍率(空/0=1.0)" /> + + + {r.msg && {r.msg}} +
+
+ )} +
+ ); +} diff --git a/sundynix-admin/src/components/ModelManager.tsx b/sundynix-admin/src/components/ModelManager.tsx index 268019e..dfd9b6c 100644 --- a/sundynix-admin/src/components/ModelManager.tsx +++ b/sundynix-admin/src/components/ModelManager.tsx @@ -132,8 +132,9 @@ export function ModelManager({ value={form.provider} onChange={(e) => set("provider", e.target.value)} > - + +