108 Commits

Author SHA1 Message Date
Blizzard 5c50c87a88 docs: 重写架构文档——工程级完整版(5 张 Mermaid 图 + 功能状态总表)
原文停在 2026-07-02 已严重失真:能力矩阵还写「多租户/RBAC 未动、真实计费
未做、NATS 集群未做」,而这三项均已完成并 live 验证。它又被 5 份设计文档
引用为配套文档,不能放着不管。

重写为 15 章 913 行,以代码为单一事实源:
- 分层架构图 + 3 张关键链路时序图(任务提交/语音全双工/本地执行三道闸)
  + 三机部署拓扑图,全部 Mermaid(已用 mermaid-cli 逐张渲染验证)
- 5 个服务逐个拆:internal 包职责 + 技术栈依赖表含版本
- NATS 契约总表:1 任务流 + 3 回流 + 4 组工具 RPC + 5 条持久回写流 + KV
- 编排引擎:12 种节点 kind、ReAct、11 个平台工具、harness 五件套
- 29 张表全清单 + 9 个中间件 + 迁移机制
- 13 域功能实现状态总表(/🟡/)
- 安全治理/可观测/客户端三产品面/CI-CD/技术栈速查/已知缺口

顺带记账:§9.14 点名 4 处文档与代码不符(PROGRESS.md 停更、DEPTH_ROADMAP
汇总表自相矛盾、Tier3 说 NATS 集群没做但已落地、PAYMENT_DESIGN 说不做订阅
但已实现),明确以代码为准,避免后来人被误导。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:46:02 +08:00
Blizzard 7e0393bf62 fix(ui): 设置弹窗狂闪+输入被清空(toast context 每次渲染造新对象) + 配置生效可见
【弹窗闪/填着填着没了】根因在 Toast Provider:`value={{ push }}` 每次渲染都造
新对象,而全项目有 7 处把 useToast() 结果放进 useEffect 依赖。于是**任意一条
toast 弹出**都会让这些 effect 重跑——JarvisSettings 因此重新拉取配置、把用户
正在填的内容覆盖掉;若拉取本身失败又会 push 错误 toast → 无限循环狂闪。

修:Provider 的 context value 用 useMemo 稳定(一处修好,7 处受益);
JarvisSettings 的加载 effect 只依赖 open,toast 走 ref 取用(纵深防御)。

【不确定配置是否生效】改完名字语音坞还挂着旧名(只在挂载时拉一次),
用户完全看不出改动生效——这本身就是 bug。
修:设置保存后回调 onSaved → 语音坞立刻重拉名字;弹窗顶部加「当前生效」摘要
(助手名/人设是否自定义/语音走自己的还是系统配置)+ 一句验证方法。

live 验证:①填入「星期五」后触发 toast,输入内容不再被清空 ②保存后落库正确
③按语音提示词问「你是谁」→ 答「我是星期五,你的私人语音助手」

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:54:01 +08:00
Blizzard a16229573b feat(jarvis): 本地沙箱改多目录白名单 + 让它知道自己能操作这台电脑
单目录沙箱做不了"操作我电脑"——只能在一个文件夹里打转,跨目录整理直接没戏。
且模型压根不知道 shell 除了跑脚本还能开 App、控 App、触发快捷指令。

沙箱:单根 → 多根白名单
- 用户授权多个目录(设置里每行一个,可一键填入桌面/下载/文档),其余一律拒
- 路径改绝对路径(多根之下相对路径没有唯一含义),支持 ~ 展开,相对路径兜底按首个根解释
- local_list_dir 留空 path = 返回授权目录清单 → 模型据此自己发现"我能访问哪儿"
- local_exec 可指定 cwd(须在授权目录内)
- 防逃逸不变:软链解析后必须落在某个根内,越界即拒(单测覆盖 ../ 与软链逃逸)

告诉模型它能干什么
- local_exec 描述展开:文件整理(mv/cp/find)、mdfind 全盘搜、open 开应用/文件/网址、
  osascript 控制 Mac App、shortcuts run/list 触发快捷指令、系统信息
- 语音系统提示词把 JARVIS 定位成"这台电脑的操作者",并要求先想清用哪个工具再动手

live 验证(两个授权目录):
① "你能访问哪些目录,里面有什么" → 自主先查授权清单、再逐个列举,答全对
② "把下载里的图片挪到桌面" → 自主 mv,文件真的跨目录移动了

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:42:58 +08:00
Blizzard 149c4dc89a fix(voice): 未配 TTS 时也回 tts_end(别让客户端永远卡在"思考中")
TTSEnabled 要 api_key + tts_resource_id + tts_voice_type 三项齐全,而
ASREnabled 只要两项。线上只配齐 ASR 时:转写成功→进思考中→agent 出结果→
speak() 静默 return,连 tts_end 都不发 → 客户端永远停在"思考中"。

用户看到的是"提问后彻底没反应",完全看不出是配置缺失——两个症状
(一直思考不回答 / 结果不语音回复)其实是这同一个静默 return。

改:未配 TTS 时明确回 error 文案 + tts_end,让客户端回到待命并告知原因;
服务端日志也点明缺哪三项。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:27:02 +08:00
Blizzard c66d178efe fix(voice): 思考看门狗覆盖 PTT 模式(别永远卡在"思考中")
此前 armThinkTimer 只在连续对话模式挂,PTT 下后端不回(dispatcher 未就绪/
模型没配/任务失败)就永远卡在"思考中",用户干等且毫无线索。

改:任何模式收到 final 转写都挂看门狗;超时后连续对话回聆听、PTT 回待命,
并明确提示可能原因 + 引导去运行页看任务状态。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:57:18 +08:00
Blizzard e60679ed7b fix(voice): PTT 改为主交互——按住说话松开发送(修 PTT 形同虚设的 bug)
VAD 自动断句在真实环境不够稳(环境音/停顿都会误触发),改回按住说话。

修掉 PTT 之前根本不生效的三处:
- keydown 里 `if (!c) return`——用户没先点过麦克风按钮时 clientRef 为 null,
  按空格什么都不发生。改为懒建客户端(keydown 本身就是用户手势,可启 AudioContext)
- 被 `!inConversation()` 挡着:一旦点过麦进了连续对话,PTT 永久失效。改为 PTT 优先,
  必要时先退出对话模式
- 缺 e.repeat 守卫:按住不放会连发 keydown

交互统一为一套 PTT 语义:
- 按住空格 / 按住麦克风按钮(pointer 事件,覆盖鼠标触控) → 说话,松开发送
- 朗读中按按钮 = 打断
- 指针滑出/取消/窗口失焦都算松开,不会卡在录音态
- 录音中按钮变红缩小 + 电平条 + 呼吸环,一眼可见"正在听"

顺带:松开后标签改为"已停止收音"(准确说法——麦克风硬件仍开着以便快速重按,
只是不再上传音频,门控在 shouldSendMic);删掉已无人使用的 micTapAction 及其单测。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:52:26 +08:00
Blizzard 08c4d94187 fix(deploy): nginx 反代补 WebSocket 升级头(语音/本地执行器)
nginx 默认吃掉 Upgrade/Connection 头,被代理的 WS 永远握不上手——现象是
HTTP 接口全正常、只有 WebSocket 报 ws error(直连网关端口则一切正常)。
/api/v1/voice/stream(语音会话)与 /api/v1/local/runner(本地执行器)都走 WS。

用 map 而非写死 "upgrade":普通请求带 Connection: upgrade 会干扰 keepalive。

注:生产 compose 无 admin 服务(admin 已 go:embed 进 gateway),线上域名前面
那层 nginx 是服务器手工配的,需同样补这两行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:40:21 +08:00
Blizzard 39ab7d9700 feat(desktop): 服务器地址运行时可配(登录页可填,不必重编客户端)
此前 GATEWAY 只在构建期由 VITE_GATEWAY 写死,客户端连线上后端就得重新打包,
换一次环境编一次不现实。

改:优先级 localStorage > 构建期 VITE_GATEWAY > localhost:8080;
登录页加折叠的「服务器地址」输入(必须在登录前可改——地址不对连都连不上,
更谈不上登录);保存后自动重载窗口生效(模块级常量在加载时读定)。

顺带:地址统一去尾斜杠,否则拼出 //api/v1 这种路径。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:25:08 +08:00
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00
Blizzard 52988af196 feat(voice): 按住空格键语音输入(PTT),松开发送
按住空格键开始聆听,松开发送 end 指令提交任务。不销毁麦克风上下文,
下次重按直接复用,避免 getUserMedia/AudioContext 重建延迟。
支持输入框/文本区内不抢空格、窗口失焦自动取消。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-24 21:03:27 +08:00
Blizzard aba88193d8 feat(voice): 提升桌面端语音交互与本地任务执行支持 2026-07-24 16:40:25 +08:00
Blizzard e9a8b9b4c6 docs: 本地 Agent 执行设计——双平面(执行下沉/控制中心) + 与 Claude Code 对比
在保留中心化治理前提下让 Agent 操作本机文件/执行命令的设计。
含:双平面架构、与 Claude Code 逐项对比、现有架构支持度分析、
Go+Wails 客户端可行性、档 A/档 B 演进、安全门禁、P0-P4 落地阶段。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:13:03 +08:00
Blizzard b8095688ba fix(voice): TTS 只播前几字就断——tts_end 别打断,等排队音频放完
火山合成远快于真实语速:一句话的 PCM 秒级全推到客户端,被 nextStart
预约到未来时刻顺序播。此前 tts_end 直接 resetPlayback() stop 掉所有还
没播的 source,于是只听到前三四个字就断。

改:tts_end 走 drainThenReady()——不打断,按 nextStart 算剩余时长等排队
音频自然放完,末段结束再回 ready;speaking 态维持到真播完(HUD 频谱靠它)。
resetPlayback 只留给 barge-in/close,并清 drainTimer 防迟到定时器改状态。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:01:02 +08:00
Blizzard 4153046e0e feat(desktop): 全屏 JARVIS HUD 补齐概念版完整度——开机自检 + 遥测 + 解码文字
对齐概念版 artifact 的完整效果:
- 开机自检序列(INITIALIZING→AUDIO/UPLINK/VOICE MODEL OK→ONLINE),进场淡出,reduced-motion 跳过
- 右上遥测读数(SIGNAL/LATENCY/GAIN/SESSION),SIGNAL/LATENCY/GAIN 由真实电平实时驱动+抖动
- 回答走解码式打字机(逐字定稿、前沿字符跳乱码 glyph scramble),接真实 reply 流
- 修 ESC 按钮与遥测重叠:ESC 常显于右上角,遥测下移到其下,不再压住 SIGNAL

真机(浏览器预览)验证:遥测四行齐全无遮挡,STANDBY 呼吸,品牌位星期五。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:03:42 +08:00
Blizzard a4b9897eaf fix(voice): 麦克风打不开时给准确提示——多约束尝试 + 无设备预检
- 无麦克风设备(如 Mac Studio/Mini 无内置麦)时,getUserMedia 会以 OverconstrainedError
  "Invalid constraint" 误导报错;先 enumerateDevices 预检 audioinput,没有就直说"没检测到麦克风"
- getUserMedia 依次试 {audio:true}/{audio:{}}/带回声消除,全败则暴露真实错误名;
  NotAllowedError 单独提示去系统设置授权

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:56:08 +08:00
Blizzard fe5c4215f7 fix(voice): 麦克风约束兜底——WKWebView 不认高级约束("Invalid constraint")退 {audio:true}
原生壳(Wails WKWebView)的 getUserMedia 不认 channelCount/echoCancellation 等高级约束,
抛 "Invalid constraint"。先试高级约束(浏览器更好),失败退回最简 {audio:true}(最兼容)。
配合上一提交的 .app 打包+NSMicrophoneUsageDescription,原生壳麦克风打通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:47:19 +08:00
Blizzard 92341be32c fix(desktop): 麦克风优雅降级 + Info.plist 加 NSMicrophoneUsageDescription
- voice.ts: startMic 前探测 navigator.mediaDevices?.getUserMedia——WKWebView(原生壳)非安全
  上下文里它可能未暴露,直接调会抛 "undefined is not an object" 崩掉;改为给可读提示
- build/darwin/Info.plist + Info.dev.plist: 加 NSMicrophoneUsageDescription,否则 macOS
  不会授权麦克风(package 后的 .app 才能弹权限)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:44:27 +08:00
Blizzard ef2b815bd7 fix(desktop): 补 build:dev 脚本,修复 wails3 dev 启动
wails3 dev 的 DEV 构建走 `npm run build:dev`,而 package.json 只有 build(此前只用过
task build/package,dev 从没跑通)。补 build:dev=vite build --mode development。

注:原生 dev/build 还有个 go.work 坑——desktop 不在工作区,直接 go build 需 GOWORK=off;
且 wails3 generate bindings -clean 会误删已提交 bindings(因模块不在 go.work 解析不到 services)。
可靠的本地跑原生窗口:cd sundynix-desktop && (cd frontend && npm run build) &&
  GOWORK=off CGO_ENABLED=1 go build -tags production -buildvcs=false -o /tmp/jarvis_desktop . && /tmp/jarvis_desktop

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:39:44 +08:00
Blizzard 2a743a33f6 feat(desktop): 全屏「JARVIS 模式」HUD——钢铁侠风,接真实语音音频
弧反应堆核心 + 同心旋转环 + 随真实声音反应的环形频谱 + 状态编排 + HUD 边框/扫描线/网格。
可视化数据全来自真实语音会话(不是模拟)。

- voice.ts: micCtx/playCtx 各挂 AnalyserNode + level()——说话读下行TTS、其余读麦克风,
  实时电平 0..1(HUD 每帧读,复用缓冲)
- JarvisHud.tsx: Canvas 2D HUD,按 VoiceState 编排(待命呼吸/聆听炸开/思考散粒子+雷达/说话频谱),
  单一青色强调+思考态琥珀;显示助手名(星期五)+ 我说的 + 打字机回答;尊重 prefers-reduced-motion
- VoiceDock: 加"全屏 JARVIS 模式"按钮(建客户端+拉名字);Esc 退出

真机验证:全屏 HUD 正常渲染,STANDBY 呼吸,品牌位显示自定义名"星期五"。晚上点中心说话即随真声反应。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:28:39 +08:00
Blizzard 24351014e0 feat(desktop): JARVIS 设置面板——名字/人设/自带豆包配置(用户级)
- lib/api.ts: getMyJarvis/saveMyJarvis + JarvisConfig 类型
- shell/JarvisSettings.tsx: 弹窗——名字 + 语气人设 + 折叠「高级:我的豆包配置」
  (api_key/asr/tts resource-id/音色;掩码不回传=沿用已存;填齐才覆盖系统)
- VoiceDock: 麦克风上方加设置齿轮,打开面板

真机验证:面板正确加载后端配置(名字=星期五、人设),高级区四项占位清晰,存档下次说话生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:12:03 +08:00
Blizzard 99ce07c1b0 feat(voice): 每用户 JARVIS——自定义名字/人设 + 自带豆包配置回落 + 语音不串主记忆
把 JARVIS 做成每用户独立:名字(你叫JARVIS别人叫星期五)、人设(与主偏好记忆分开)、
可自带豆包配置(齐全则用用户的,否则回落系统)。

- store/user_jarvis.go: sundynix_user_jarvis(user_id唯一;name/persona/火山creds,APIKey密文)
  + Get/Save(upsert) + AutoMigrate 注册
- voice_config.go resolveJarvis(uid): 火山配置用户优先系统兜底 + 取名字/人设
- voice_task.go: voiceSystemPrompt(name,persona)——简短是硬基线,名字/语气由用户定;
  buildVoiceGraph 带 name+persona;voice.go 会话升级时按用户解析
- dispatcher compose_compiler: 语音任务(useVoice)不拉主偏好记忆,改用用户 persona(保留短期历史)
- jarvis.go + 路由: GET/PUT /api/v1/me/jarvis(用户级,api_key 脱敏/留空沿用)

真机验证:设 name=星期五+干净人设→语音答"我是星期五…"(自称新名、无糙话、4.4s),
has_own_voice=false 用系统豆包。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:06:32 +08:00
Blizzard fb3685532c polish(admin): 补齐侧栏菜单缺失图标,全项统一对齐
之前 登录设置/语音设置/微信用户/订阅 四项无 ICON_MAP 条目→图标位空、与兄弟项不齐。
补齐 heroicons 描边风一致图标:登录设置=钥匙、语音设置=麦克风、微信用户=聊天气泡、
订阅=循环;删掉无对应路由的死条目 usage。现 17 条路由全有图标。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:34:28 +08:00
Blizzard 95f3781076 feat(admin): 模型配置加「编辑」按钮 + 语音默认提示改 deepseek-v4-flash
- ModelManager: 每行加「编辑」→ 载入表单就地改(api_key 留空=沿用现有密钥),
  后端按 id 走更新而非新增;表单头/按钮随编辑态切换,带「取消」;省去删了重登记
- ModelsPage: 语音默认模型提示 deepseek-chat→deepseek-v4-flash(chat 2026/07/24 弃用)

注:语音模型实例已通过 admin API 从 deepseek-chat 热切到 deepseek-v4-flash(dispatcher
现读 v4-flash,无需重启)——验证了编辑/激活的 NATS 热更新链路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:07:13 +08:00
Blizzard a4669a0d82 fix(usage): 用量按实际所用模型计量(语音任务归语音模型,别错记工作模型)
分开工作/语音模型后,语音任务实际跑语音模型(deepseek-chat),但 emitUsage 没带 Model,
网关 SaveUsageEvent 拿激活 chat 模型(deepseek-v4-pro)兜底 → 语音用量被错价成工作模型。
修:emitUsage 按任务 model_profile 选池、type-assert ModelName() 填真实模型名。

真机验证:语音任务首字出声 ~2s(deepseek-chat,较 v4-pro 的 5-7s 骤降),usage_event.model
正确记为 deepseek-chat。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:43:30 +08:00
Blizzard aec7ad949c feat(model): 工作模型与 JARVIS 语音模型分开配置 + 语音任务路由到快模型
模型配置加"用途"维度:工作主力(chat,要强)与 JARVIS 语音(voice,要快/低时延)各配各激活,
语音任务走语音模型池,未配置则透明回落工作模型——不影响现有功能。

- contract: ConfigKindVoice="voice" + Meta[model_profile]=voice(与 intent==report 同类路由)
- gateway: ServeConfig/broadcastActive 循环纳入 voice;submitVoiceTask 打 model_profile=voice 标记
- dispatcher: 第二个 llm.Pool(voicePool)吃 voice 配置热更新;board.useVoice 从 Meta 派生(含快照);
  Orchestrator.agentPool(b) 按黑板选池——语音且语音池就绪→语音池,否则工作池;
  agent 生成路径(graph/react/coordinator/compose)全改走 agentPool(b),报告/护栏/记忆固定工作池
- admin: 模型页三 Tab(工作主力/JARVIS语音/向量化),复用 ModelManager;api Kind 加 voice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:39:17 +08:00
Blizzard b6927247da feat(voice): 下行打字机文本流 + 首块快出,即时反馈
- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)

管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:25:20 +08:00
Blizzard 6b31856551 perf(voice): 砍兜底等待 1.2s→0.5s + 语音提示词强制简短 + 时延测量
- voice.go: ClientEnd 兜底等待 1200ms→500ms(白吃的时延),首字出声 6.6s→5.3s
- voice_task.go: JARVIS 系统提示词改"必须简短/最多三句/先给结论"(语音场景长答案既拖慢
  首字又难听;注:deepseek-v4-pro 仍可能不理会长度指令,可靠收短需 max_tokens 硬顶)
- voicesim: 时延拆解(提交/首字出声/朗读完毕,以"说完"为0点)

现状:首字出声 ~5.3s,大头是 deepseek 生成第一句(大模型 TTFT);管线开销(兜底+TTS握手)已压到~1s。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:17:28 +08:00
Blizzard 9ae06229b8 feat(voice): 显式 end 兜底提交 + 端到端模拟工具,全链路真机跑通
火山流式 ASR 只在 VAD 静音时发 Final;客户端点"停"(ClientEnd)不能干等——否则整段说完
却因没触发 VAD Final 而永不提交任务(实测复现)。改为:
- voice.go 累计 latestText(每条转写更新);ClientEnd 后 1.2s 用最新转写兜底提交;
  turnMu+submitted 保证 Final 与 ClientEnd 两路只提交一次(替换旧 lastFinal 去重)
- cmd/voicesim: 端到端模拟(免麦)——TTS 合成问话→灌网关语音WS→ASR转写→提交任务→
  大模型回答→TTS朗读回推,问答音频各存 wav,签发测试用户 JWT(auth.Issue)

真机验证:问"你是谁?你能做什么?"→ task_bab1c6ee → JARVIS 语音回答 18.6s(sim_answer.wav)。
麦克风音频→ASR→任务→大模型→TTS 全程走网关跑通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:08:17 +08:00
Blizzard 08e6425fec feat(voice): 双向流式 TTS 真机跑通——TTS合成→ASR往返全绿
对齐官方 python demo 修 TTS payload:
- TaskRequest 必须带完整 req_params(speaker+audio_params)再加 text,只发 {text} 火山收不到
  (会回空 text 的 TTSSentenceStart 后直接结束、无音频)
- StartSession 去掉自造的 namespace/user,就是 {req_params:{speaker,audio_params}}
- ttsReqBase 基底 StartSession/TaskRequest 复用;TTSSession 存 reqBase
- 加 VOICE_DEBUG 帧级调试日志(联调用,默认关无开销)

真机验证(voicecheck):TTS 合成 143KB PCM24k → 降采样喂 ASR → 转写"北京今天的天气怎么样?
需要带伞吗?"≈原句。ASR(volc.bigasr.sauc.duration)+TTS(seed-tts-2.0)两协议全通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:49:43 +08:00
Blizzard d6c1a787f4 fix(voice): 改用火山「新版控制台」API Key 鉴权(ASR 握手真机验通)
一直误用旧版 5 头方案(X-Api-App-Key+Access-Key+App-ID),火山始终 401 grant not found。
用户提供官方文档「新版本控制台」证实:新版只需**单个 X-Api-Key** 头。改对后真机联调:

- frame.go setVolcAuthHeaders: X-Api-Key + X-Api-Resource-Id + X-Api-Request-Id + X-Api-Sequence:-1
  (删旧 App-Key/Access-Key/App-ID/Connect-Id;去 appID 参数)
- asr/tts 调用点同步;config.go 删 Config.AppID;voicecheck/voiceconfig 删 VOLC_APP_ID
- 删临时诊断工具 voiceprobe

真机结果:ASR 握手(volc.bigasr.sauc.duration 有效);TTS 用 seed-tts-2.0 会话正常建立
(不再 resource mismatch),但暂无音频返回→双向 TTS 事件/payload 待调(下一步)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:42:36 +08:00
Blizzard c5397438d4 docs(voice): 记录联调结论——协议验通,剩服务开通(账号侧)
联调确认:鉴权/帧协议已被真火山完全接受。剩 grant not found=账号未开通对应服务。
- TTS 正确 Resource-Id 是 volc.service_type.10029(语音合成大模型),非 seed-tts-2.0(那是模型名)
- ASR 端点 sauc/bigmodel 认 volc.bigasr.sauc.duration(格式被识别)
- 两者均 grant not found → App 9604175735 需在语音控制台开通这两个大模型服务

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:07:48 +08:00
Blizzard 8283bc542f fix(voice): 火山鉴权头精确大小写(X-Api-App-ID 免被 Go 规范化)
Go 的 Header.Set 会把 X-Api-App-ID 规范化成 X-Api-App-Id;改直接赋 map 保留精确
大小写,对齐官方 demo。鉴权方案已与参考实现(realtime_dialog demo)完全一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:59:58 +08:00
Blizzard bb9c73a9b7 fix(voice): 修正火山 V3 流式端点鉴权头(真火山联调验通协议格式)
联调发现 Authorization: Bearer 不被 openspeech V3 二进制流端点接受。改用官方规范头:
- X-Api-App-Key: 固定常量 PlgvMymc7f3tQnJ6(所有客户端同值,缺它 400 "app key not found")
- X-Api-Access-Key: 新版 API Key(账号鉴权)
- X-Api-App-ID: 账号 App ID(解析资源授权,缺它 401 "grant not found")
- X-Api-Resource-Id / X-Api-Connect-Id

改动:Config 加 AppID 字段;frame.go setVolcAuthHeaders 统一挂头 + handshakeDetail
榨取握手失败的 HTTP 状态/logid/body(联调可诊断);asr/tts 共用;voicecheck 加握手探针
分别验 ASR/TTS 端点;voiceconfig/voicecheck 读 VOLC_APP_ID。

现状:协议格式已被真火山接受(过了 400 格式错,进到账号/资源授权查询)。剩 App ID +
资源开通确认(账号侧,联调补)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:53:26 +08:00
Blizzard b718bdbb21 feat(voice): voiceconfig 工具——语音配置一键入库(联调/无头环境免开 admin)
等价于 admin「语音设置」保存一次:读 VOLC_* 环境变量→AES 加密(复用 gateway
EncryptedForStore)→写 sundynix_setting。网关每请求现读,无需重启。凭证只走 env 不进 git。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:42:47 +08:00
Blizzard 5b871bc363 feat(voice): 火山协议自检工具 voicecheck(本地端到端验协议,免麦克风免全栈)
不用部署、不用麦克风:本机直连火山公网端点,端到端验证手搓的 ASR/TTS 帧协议。
TTS 合成一句→收音频存 out_tts.wav→降采样 24k→16k 喂 ASR→打印转写。
转写≈原句即证明两套协议都被真火山接受。凭证只走环境变量(VOLC_API_KEY 等),不进 git。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:33:55 +08:00
Blizzard 9458bf21f3 feat(voice): 桌面端 JARVIS 语音坞——麦克风上行 + TTS 播放队列(Phase 1 收口)
右下角悬浮麦克风:点按说话→转写→提交任务→跳运行页→朗读回答,接入既有运行·观测流。

- lib/voice.ts: VoiceClient——一条 WS 承载上行 PCM16k/下行转写/下行 TTS PCM24k;
  ScriptProcessorNode 采麦(48k→16k 降采样+Float32→Int16);AudioBufferSourceNode
  排队调度做无缝连续朗读;start/end/barge_in/bye 控制;ready/transcript/task/speaking/tts_end
- shell/VoiceDock.tsx: 悬浮麦克风 UI(聆听/思考/朗读态 + 转写气泡 + 打断),懒建客户端(用户手势启 AudioContext)
- App.tsx: onVoiceTask 把语音 task 挂回 attachRun(后端已提交,前端只 attach);挂载 VoiceDock

真识别/合成需部署联调(真连火山+admin录入语音配置);本地过 tsc/build/68 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:29:47 +08:00
Blizzard 6fe0a58f1b feat(voice): 火山双向流 TTS 客户端 + 下行接线(Phase 1 嘴巴)
回答 token 流 → 攒句器 → 火山双向 TTS → 音频帧回推客户端,端到端连续朗读打通。

- tts_frame.go: V3 事件族帧编解码(事件号+会话ID+gzip),与 ASR 简帧不同族;
  从官方参考实现核实 generate_header/parse_response 字节布局;3 解析单测
- tts.go: seed-tts-2.0 双向流客户端 StartTTS(握手ConnectionStarted/SessionStarted)
  /Speak(逐句TaskRequest)/Finish/Audio()/Close,PCM 24k;新版 API Key 鉴权
- voice_tts.go: speak() 先订阅token流再建TTS(core NATS无持久,握手期攒句入pending
  就绪补吐,不丢开头);音频泵首帧ServerSpeaking、收尾ServerTTSEnd;打断stopTTS
- voice.go: barge_in→stopTTS;会话结束连带停TTS;final转写→go speak(taskID)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:25:49 +08:00
Blizzard 302e1ebaff feat(voice): 上行接线——最终转写→组DSL→提交任务(Phase 1 打通)
ASR 最终转写触发一次任务提交,复用 HTTP SubmitTask 那条共用关卡
(preflightCore/launchCore),语音只是"嘴替键盘",编排/工具/计费一行不新造。

- task_handler.go: preflight/launch 抽出无 gin 内核 preflightCore/launchCore
  (preflightBlock 承载拦截态),gin 版做薄封装;语音会话无 gin.Context 也走同一关卡
- voice_task.go: buildVoiceGraph(转写→input→agent 单图) + submitVoiceTask(校验/关卡/落库发射)
- voice.go: 会话升级时抓租户/会话;结果 goroutine 见 Final→onFinalTranscript 提交、
  回 ServerTask{task_id};去重连发的重复 final;画布图一次性消费
- voice_task_test.go: 组图合法性 + 带转写 + input→agent 连边

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:16:29 +08:00
Blizzard 4ee4a91a51 feat(voice): 火山 ASR 流式识别客户端 + 会话接线(Phase 1 耳朵)
voice/asr.go: 连 wss://openspeech.bytedance.com/api/v3/sauc/bigmodel,新版 API Key 鉴权
(Authorization: Bearer + X-Api-Resource-Id + Connect-Id),发初始配置帧(bigmodel/zh/ITN/
标点/VAD),PushAudio 流式喂 PCM、Finish 收尾;读 goroutine 解析响应(result 支持数组/对象/
字符串,type=final 为最终)推入 Results 通道。

handler/voice.go 接线:onAudio→PushAudio、start→重开识别、end→Finish;起 goroutine 把
转写 send(transcript) 实时回推客户端。加 writeMu 串行化写(读循环与 ASR 结果 goroutine
都写同一 WS,gorilla 禁并发写)。连接结束 stopASR 收尾。

带单测(配置JSON字段/result三形态解析)。真识别需部署联调(要真连火山);API Key 是否
还需 X-Api-App-Key 联调若 401 再补。上行接线(final→SubmitTask)与 TTS 是下一步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:27:32 +08:00
Blizzard b526b21dee feat(voice): 火山语音 WS 二进制帧编解码(ASR/TTS 共用核心)
从官方参考实现核实的 V3 协议帧格式,港到 Go:byte0=0x11、byte1=(msgType<<4)|flags、
byte2=序列化<<4|压缩、byte3=0x00、大端 uint32 payload 长度、payload;服务端响应含 4B
序列号故 payload 从第 12 字节起。encodeFrame/jsonFrame/audioFrame/parseServerFrame +
msgType(0x01 config/0x02 音频/0x09 结果/0x0F 错误)与 flags(0x02 最终)常量。
3 个单测钉死头布局/音频最终帧/响应解析(确定性,不依赖网络)。

ASR 初始配置 JSON、WS 连接与流式识别(接 onAudio)是下一步网络层实现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:22:09 +08:00
Blizzard 15f5a85612 feat(voice): WebSocket 端点 + 客户端↔网关协议(Phase 1 地基)
加 gorilla/websocket(无 genproto 冲突)。voice/protocol.go 定死单条 WS 的消息协议:
二进制帧=音频(上行麦克风/下行TTS),文本帧=JSON 控制/事件(ClientMsg:start/end/barge_in/
bye;ServerMsg:ready/transcript/task/speaking/tts_end/error);音频 PCM 16k 单声道。
handler/voice.go: GET /api/v1/voice/stream 升级 WS,鉴权走 AuthFromHeaderOrQuery(?token=,
WS 带不了 Bearer),会话外壳 + 协议读循环(音频帧/控制消息分派)已通,火山 ASR/TTS 客户端
在下一步挂 onAudio/onControl 的 TODO 点接入。build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:17:58 +08:00
Blizzard a28ae49b6a refactor(voice): 语音配置改用新版 API Key 鉴权(弃旧版 appid+token)
用户提醒:火山新版走 API Key 鉴权,不用旧版 appid+access_token。新版 WS 握手只带两个
header——Authorization(Bearer <APIKey>) + X-Api-Resource-Id。

配置从 {appid, access_token, 2×resource-id, voice} 收敛为 {api_key, 2×resource-id,
voice}:APIKey 走 secrets AES 加密入库;ASREnabled/TTSEnabled 改为只看 api_key+对应
resource-id。admin 配置页两个字段并一个 API Key 字段,清单同步改为新版口径。build+tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:06:22 +08:00
Blizzard 618184689e feat(voice): 语音配置控制面——火山豆包 appid/token/resource-id/音色(参数落地处)
JARVIS 不依赖火山账号能先做的第二块:配置层,做成'你参数一填就落库'的形态。
- voice.Config: AppID/AccessToken/ASRResourceID/TTSResourceID/TTSVoiceType,AccessToken
  走 secrets AES 加密入库(镜像微信配置);ASREnabled/TTSEnabled 可各自独立判断。
- admin GET/PUT /admin/voice(RequireAdmin),token 空串=沿用已存、明文回显。
- admin「语音设置」页(运维组):五个字段 + ASR/TTS 就绪徽标 + '去哪拿参数'清单。

端点/音频格式(PCM 16k 单声道)由代码固定不入配置。WS 会话 + 火山 ASR/TTS 客户端等
用户回传 resource-id 后按官方 demo 协议做。build+tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:00:35 +08:00
Blizzard 644b635d73 feat(voice): 攒句器——语音下行 TTS 的 token 攒句(JARVIS 地基第一块)
语音交互(VOICE_DESIGN.md)不依赖火山账号的第一块地基:SentenceBuffer 把 LLM 逐 token
输出攒成'适合喂 TTS 的句子片段'。逐字喂 TTS 太碎(单字合成不自然、首包延迟高);句末标点
(。!?.!?;;换行)即成一句,从句标点(,,::)且攒够 12 rune 也吐(让长回答尽早出声)。
跨 Push 续半句,Flush 收尾吐无标点结尾。纯逻辑无外部依赖、4 个单测(跨 Push/短从句不断/
长从句先吐/Flush 收尾)。

WS 会话 + 火山 ASR/TTS 客户端 + 控制面配置等到真 API 参数到手再按真形状做,不写猜测桩。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:30:50 +08:00
Blizzard f16f63284f feat(cluster): gateway 后台定时器加 leader 锁——多副本安全(B5)
此前两个 gateway 定时器(订阅推进/掉单补偿)每实例各扫一遍:多副本下重复查库,
且对微信查单调用量随副本线性放大(微信有频控,先被限流的是自己)。

store.TryRunExclusive:用 PG advisory try-lock 做集群级单实例执行(leader 选举)——每轮
tick 非阻塞抢锁,抢到才跑、跑完释放;抢不到说明别的实例是 leader、本轮跳过。自愈:锁随
持有连接释放,leader 挂了下一轮别的实例自然抢到接管,无需显式故障转移。订阅/补偿用不同
锁键(可由不同实例分别 lead)。非 PG(sqlite 测试)/无 DB → 退回本地直接跑(单实例安全)。

两个定时器的 tick 各包一层 TryRunExclusive。至此 gateway 可安全多副本(SSE 走共享 Redis
流无需粘性、状态全外置、JWT 无状态,剩数据层 HA 属 C 层 ops)。带 fallback 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:21:31 +08:00
Blizzard 9b153871eb feat(monitor): NATS 集群 Raft 副本健康 + 基建 ping 延迟(监测组完善)
此前 /status 把 NATS 当一盏二元灯(连不上就 fatal 故恒真),看不出 3 节点集群里
哪个节点掉了、JetStream 持久流的 Raft 副本是否还齐(计费/状态/评测流不丢的关键)。
DB/Redis/MinIO 也只二元 ping、无延迟。

- bus.ClusterStatus:连的节点名 + 集群发现节点数(nc.Servers) + RTT(nc.RTT) + 6 条关键
  持久流(tasks/status/usage/eval/ingest/approvals)的 Raft 副本健康(leader + healthy/total,
  单节点部署记 1/1;某节点掉队 → healthy<total 记降级)。
- /status 新增 nats 集群对象 + NATS 灯改为'连接且无副本降级才绿'、detail 显示'N 节点·连 X·
  流副本齐全/降级'、latency=RTT;PG/Redis/MinIO 加 ping 往返耗时。
- admin StatusPage:新增 NATS 集群面板(节点/RTT/连接 + 各流 leader/副本健康/消息数表),
  基建行显示延迟。

sundynix-shared/gateway build+vet+test 绿;admin tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:15:22 +08:00
Blizzard a42f30239f fix(payment): 微信 API 调用加超时兜底(B3 出网韧性)
微信是外部第三方、最可能慢/挂,而 wechatpay-go SDK 默认 http.Client 无 Timeout:
一次卡住的 Prepay/QueryOrder 会拖住请求 goroutine;尤其掉单补偿定时器用的是
context.Background()(无超时)→ 微信一挂那轮 tick 无限期卡死。

双保险:客户端级 HTTP 超时(WithHTTPClient Timeout=15s,belt) + CreatePay/QueryOrder
每次调用 ctx 超时(suspenders,兜住 SDK 忽略或背景 ctx 的情况)。

范围克制:只硬化微信这个真外部依赖。PG/Redis(连接池+降级)、NATS(无限重连)、mcp-go RAG
客户端(embed 30s/chat 60s/rerank 20s 已有超时)本就有韧性,不额外套熔断(那对本规模是过度设计)。

build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:01:09 +08:00
Blizzard ebbeed90e9 feat(auth): 登录账户级失败锁定 + 密码强度 8-72 位(B4)
补上 A5 只做了 IP 限流、没做账户锁定的缺口(审计 B4)。

- 账户级失败锁定:连续登录失败达 5 次即临时锁定 15min(Redis 计数+TTL),锁定期直接拒绝
  不再校验密码,成功登录即清零。与 A5 的 IP 限流互补:IP 限流挡'一个 IP 猛打',账户锁定
  挡'分布式慢速猜一个号'。权衡:per-account 可被人为锁死受害者(lockout DoS),故窗口设短
  自动解锁+叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
- 密码强度:注册从'至少 6 位'提到 8-72 位。上限 72 字节——bcrypt 超 72 字节静默截断,
  不拦会让超长密码实际只用前 72 字节。桌面端注册提示同步改 8-72 位。

存量用户不受影响(只校验注册/改密时的新密码)。build+vet+test 绿;desktop tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:56:29 +08:00
Blizzard da9c76f073 feat(prod): 实时就绪探针——gateway /readyz 真 ping + dispatcher/mcp-go 加 HTTP 探针(B2)
此前 gateway /readyz 用的是 Enabled() 启动期降级标志(反映不了运行中 PG 掉线,LB 会继续
往已不可用实例导流);dispatcher/mcp-go 干脆没有 HTTP 探针(只有 NATS ServeHealth,k8s/LB
够不着、只能靠 gateway 经 NATS 代探)。

- gateway /readyz 改用 db.Ping 实时探活。只把 **DB 当硬依赖门**:Redis 掉线仍可服务(限流有
  A5 进程内 fail-safe 兜底、SSE 回落 live NATS),一 blip 就把全部实例踢出轮转反而制造整站
  故障,故 Redis 只上报不 gate。NATS 启动即连(fatal)不单列。
- 新 sundynix-shared/health 包:Serve/Handler 提供 /healthz(恒 200 liveness) + /readyz(由
  ready() 决定 readiness)。dispatcher(:8091, DISPATCHER_HEALTH_ADDR)与 mcp-go(:8092,
  MCP_GO_HEALTH_ADDR)各起一个,readiness = NATS 连接可用(bus.IsConnected)。接入各自优雅停机。
- bus 加 IsConnected()(nc.IsConnected 实时);dispatcher Subscriber 透传。

四模块 build+vet+test 绿;health 带 Handler 单测。探针端口仅内部用(对外仍只暴露 gateway)。
compose/k8s 的 healthcheck 编排属 C 层 ops,另做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:50:31 +08:00
Blizzard 4500335da7 refactor(store): 迁移路径务实硬化——advisory lock + 版本表 + 破坏性迁移移出启动路径(B1)
此前每次启动裸跑 AutoMigrate + 一串手写索引/回填,且启动路径上有两处 DROP TABLE
CASCADE 的 legacy 迁移;多实例并发启动无锁 → 并发 ALTER/建索引竞争,一方报错即掉降级。

务实硬化(不引外部工具,保留 gorm 结构体为源):
- PG advisory lock:整段迁移在 pg_advisory_lock 内串行,多实例同时启动只有一个进锁跑,
  其余阻塞等待。取锁 60s 超时兜底(取不到带告警继续,AutoMigrate/索引多幂等)。
- 破坏性 legacy 迁移移出默认路径:migrateLegacyIntIDs/migrateDocLinkToID(DROP TABLE
  CASCADE)默认不跑,仅 ALLOW_LEGACY_SCHEMA_MIGRATION=1 时执行;检测到旧 schema 但未开
  只告警不动手。现网早已是雪花 id 本就不触发,但从此不再是启动就可能 DROP。
- 版本化 runner:AutoMigrate 之外的步骤(3 个部分唯一索引 + NULL 余额回填)登记为
  schemaSteps,各跑一次并记入 sundynix_schema_migration 表,下次跳过;某步失败即停、
  不记录、下次重试。将来 AutoMigrate 做不了的破坏性/数据迁移在末尾追加新 id 即可。

pgsql.go 的迁移块收敛为一句 runMigrations(db)。带 runner 单测(跑一次/跳过/追加/失败即停)。
现网 DB 已有的索引/回填重跑无害(IF NOT EXISTS / WHERE IS NULL),跑后记录。build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:26:58 +08:00
Blizzard ac38d5e663 fix(prod): 后端生产级 A 类硬伤全清(7 项:授权/崩溃点/全表扫/限流/鉴权/限额)
部署前生产级审计(可靠性/数据层/安全三路)后,清掉 7 处代码级硬伤:

A1 后台定时器 goroutine 无 panic recover → 单个 DB panic 崩整个 gateway。加 safeGo/
   safeCall,包住订阅/掉单补偿/微信推送/探针 goroutine,单轮 tick 再兜一层。
A2 提示词控制面(建/激活/停用,热广播全服务)只 RequireAuth → 任意登录用户改全局提示词。
   三写端点+列表挂 RequireAdmin。
A3 HITL 审批端点无角色门 → viewer 可放行烧钱执行。加 RequireTenantRole(member)。
A4 审计/护栏列表 limit 无校验,limit=-1 让 gorm 取消 LIMIT 全表扫。加 clampLimit/
   clampOffset,AdminTasks/AdminSpaces 补上界。
A5 限流 Redis 一挂就完全放行(fail-open)。加进程内固定窗口兜底(fail-safe) + 登录/注册
   按 IP 专用严限流(10/min)。
A6 公开 by-id 端点(stream/exec/report导出/kb导入流)无鉴权无租户过滤。加
   AuthFromHeaderOrQuery(从 ?token= 取 JWT) + task/report 按 owner 归属校验;桌面端
   5 处 EventSource/下载 URL 经 tokenQuery 附 JWT。
A7 文件上传无大小上限(整文件进内存 OOM 面) → 50MB 闸(KB_MAX_UPLOAD_BYTES)+ LimitReader;
   http.Server 加 ReadHeaderTimeout/ReadTimeout/MaxHeaderBytes(不设 WriteTimeout 保 SSE)。

带单测:clampLimit/safeCall/procLimiter/AuthFromHeaderOrQuery/TaskOwner。
build+vet+全量 test 绿;desktop tsc 绿。B(迁移工具/实时探针/出网韧性/登录锁定/leader选举)
与 C(TLS/PG HA/K8s/备份自动化/可观测)分期后做,参照 production_readiness.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:04:47 +08:00
Blizzard ce7cca657e fix(backend): 补齐三处漏洞——暂停租户拦截/金额不符落审计终态/邀请码列表滤失效
P0 暂停租户是空开关:admin 能设 suspended,但 preflight 只查预算+余额、不看
租户 status → 暂停后照样能提交烧积分。加 TenantSuspended 校验(活跃租户 + 分叉时
的计费租户都拦),403 拒绝。

P1 金额不符只刷日志:回调/查单判了不符却没落审计、订单永远卡 pending 被补偿定时器
每轮重扫刷屏。加 disputed 终态 + MarkOrderDisputed(CAS 只挂一次) + 审计(首次写一次);
disputed 不在 pending 扫描内,停止无限重扫。admin /orders?status=disputed 可查。

P1 邀请码列表混入失效码:ListInvites 只按 status=active 过滤,过期/满员的码仍显示为
有效、误导邀请人。有效列表加 expires_at>now 且 used<max 过滤(RedeemInvite 本就会拒,
这里修的是展示一致性)。

三处均带 store 单测(TenantSuspended/MarkOrderDisputed CAS/ListInvites 过滤)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:20:34 +08:00
Blizzard bd829cfecb feat(invite): 租户成员二维码邀请(可复用团队码,扫码关注即入组)
owner/admin 在桌面端生成一张微信带参二维码发给团队,成员用微信扫码关注
即自动加入租户,并收到「 已加入团队【X】」被动回复。与登录二维码同一微信机制,
scene 加 inv_ 前缀分流;扫码入组走被动回复,不需要 access_token、不碰 IP 白名单。

- store: TenantInvite(可复用码=有效期+人数上限+可撤销三道闸);RedeemInvite 幂等入组、
  同一人重复扫不重复消耗名额、复活已移除者;带单测钉死过期/撤销/满员/幂等。
- handler: 建码/列表/撤销端点(RequireTenantRole admin);WxMPEvent inv_ 分支。
- 顺手修潜在生产 bug:微信用户此前都建成空邮箱,User.Email 整列唯一索引下第二个微信
  用户就撞唯一约束建号失败(登录"全通"只因当前仅一个微信用户)。改为按 openid 合成占位
  邮箱 wx-<openid>@wx.local,绕开冲突且不动索引。邀请功能会批量建微信用户,非修不可。
- desktop: 顶栏「邀请成员」入口(租户 admin/owner 可见) + 二维码弹窗(生成/大图展示/撤销)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:47:21 +08:00
Blizzard 68608c1592 feat(wechat): 支付成功后微信客服消息推回执(积分包/订阅购买)
用户扫码付款后(在 48h 互动窗口内),主动推一条客服消息回执:
到账积分/开通套餐 + 当前余额。只在 MarkOrderPaid changed=true 首次到账时推,
异步+超时隔离,失败只记日志、绝不影响入账。非微信用户自动跳过。

不做「周期刷新提醒」:客服消息受 48h 窗口限制,定时刷新那刻用户多半已超窗、
必然失败,那类隔天提醒须用模板消息(暂缓)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:23:41 +08:00
Blizzard 83269e067a feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)
用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里
回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单
限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:17:41 +08:00
Blizzard 9e2ff6007f docs(deploy): 微信 token 中控文档改回实际采用的 B 方案(宝塔 nginx + IP + 密钥)
用户腾讯云未配域名、用宝塔。文档从 frp stcp 改成实际走的:宝塔计划任务换 token
+ nginx 站点(IP)+ 密钥头。附强密钥提醒与 frp 隧道备选(token 走公网明文的固有代价)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:39:39 +08:00
Blizzard 0f2afdaac1 feat(admin): 微信用户观测页 + 唯一昵称 + 密钥明文回显(单管理员后台)
按需求四项:
1. 微信新用户昵称改「微信用户_XXXXX」:后缀从 openid 的 sha1 派生(去混淆字母表),
   openid 唯一 → 后缀实际不重复,且同 openid 每次一致(重登不换名)。用户可自行改名。
2. 后台加「平台 → 微信用户」列表:昵称 / openid(点击复制)/ 积分余额 / 加入时间。
   每人仍独立租户与积分(各买各的,确认过不共享积分池),此页只做统一观测。
3. 登录设置的 AppSecret 明文回显(不再只显示"已保存")。
4. 支付配置的 APIv3 密钥明文回显。
   —— 用户明确后台单人使用、RequireAdmin 已拦,接受这一安全降级;密文仍加密入库。

修一个 gorm Scan 坑:WechatUserRow 的 WechatOpenID/BalanceMicro 没加 column tag,
gorm 把 WechatOpenID 断成列名 wechat_open_id,与 SQL alias wechat_openid 对不上 →
openid 静默返回空。教训:Scan 到自定义结构 + SQL 用 alias 时,字段一律显式加 column tag。

本地验证:真库造两个微信用户,列表接口正确返回 openid(修 tag 前是空);
登录设置页 AppSecret 已是可见文本框;nickname 单测覆盖唯一/稳定/去混淆。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:36:27 +08:00
Blizzard cbd0a96ce7 docs(deploy): 微信 token 中控改用 frp stcp 隧道(不上公网,无需域名)
用户腾讯云未配域名、frp 是 toml。改成:腾讯云 token 服务只绑 127.0.0.1,
经 frp stcp(点对点加密隧道)让 132 拉取,token 全程不上公网、不用证书。

- 说明书给出 toml 版 frp 配置(腾讯云 [[proxies]] stcp + 132 [[visitors]])、
  cron 换 token 脚本、切换验证步骤。
- compose:gateway 加 extra_hosts host.docker.internal:host-gateway —— 容器里的
  127.0.0.1 是容器自己,token 落在宿主机 127.0.0.1:9099,须经 host.docker.internal 访问。

代码侧(PullToken + accessToken 中控分支)无改动,沿用上一提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:02:09 +08:00
Blizzard a1c68ec6b2 feat(auth): 微信 access_token 走中控服务器(腾讯云静态 IP 换 token)
隐患:gateway 换 access_token 时微信看到的是本地宽带出网 IP(106.58.232.43,
动态会变),一变白名单就失效、二维码建不出来(40164)。

按微信官方推荐的「中控服务器」架构解决:腾讯云静态 IP 统一换 token,gateway 拉取
使用。微信 IP 白名单只限制换 token 这一步,拿 token 建二维码不查 IP —— 所以
换 token 挪到中控、建二维码仍在 gateway 本地,白名单只填腾讯云 IP,永不失效。

- wechat.PullToken:从中控 HTTPS 端点拉 token(Bearer 密钥鉴权),坏响应/403/空
  token 一律报错不当成功;中控没给 expires_in 时按 7200 兜底。
- handler.accessToken:配了 WECHAT_TOKEN_URL 就只从中控拉、绝不自己 FetchAccessToken
  (微信要求单点刷新,多点各自换会互相顶掉 token);不配维持直连,零副作用可回退。
- compose:gateway 加 WECHAT_TOKEN_URL / WECHAT_TOKEN_SECRET(从宿主机 .env 注入)。
- deploy/wechat-token-zhongkong.md:腾讯云侧 cron 脚本 + nginx 配置 + 切换验证步骤。

本地验证(假中控 httptest + 真 gateway):建票时 Redis 缓存的是中控给的 token
(FAKE_TOKEN_FROM_ZHONGKONG),gateway 未直连微信换 token;随后拿该 token 调
qrcode/create(假 token 报 40001 属预期)——证明「中控换 token → 本地建二维码」链路成立。
4 组 PullToken 单测覆盖正常/403/坏JSON/兜底。

真机验证(部署后):把本地 IP 从白名单删掉、只留腾讯云 IP,扫码仍能登录即坐实
qrcode 不受 IP 限制;若建二维码报 40164 则退回 tinyproxy 正向代理备选。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:53:27 +08:00
Blizzard 04b5677605 feat(site): 官网直接登录 + 购买(不再单独部署 Web 面)
用户要求「直接在官网登录,别多搞一个 web 工程」。官网本就是 admin 前端的一部分
(embed 进 gateway),在它上面加登录 + 购买,仍是「一个前端」,零新增部署。
sundynix-web(薄 Web 面)不部署;账单/组织/团队等留桌面端。

定价页「购买」流程:
  未登录 → 弹微信扫码登录(带参二维码 + 关注/扫码事件)→ 登录后自动继续那笔购买
  已登录 → 直接弹微信支付二维码 → 轮询单态 → 到账
支付走后端现有链路(下单/回调/查单/掉单补偿全复用),与桌面端同一套。

站点用户 token 存独立 key(sdx_site_token),与运维后台的 sdx_admin_token
隔开——普通用户登录拿到的用户 JWT 不该与管理员令牌混用。

本地验证:未登录点购买 → 弹登录弹窗;未配微信时如实显示「微信登录未配置」
(部署配好即真二维码)。登录门 + 支付弹窗 UI 骨架与错误态均正确;真二维码/
真支付留待部署后。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:23:23 +08:00
Blizzard 883540bd7e feat(auth): 微信扫码登录改为「带参二维码 + 关注/扫码事件」(登录即涨粉)
上一版做成了网页授权(OAuth 允许页),方向错了。改成用户要的流程:
扫码 → 弹公众号关注页 → 关注即登录,服务号顺带涨粉。

流程:PC 建票 → 后端用 access_token 调「带参数二维码」接口(scene=ticket) →
展示微信二维码图 → 用户扫码关注 → 微信推 subscribe/SCAN 事件到 /wx/mp/callback →
按 openid 找/建用户 → ticket 置 authorized → PC 轮询拿 JWT。

明文模式(消息加解密):回调只验签名 sha1(sort(token,ts,nonce)),不做 AES。

关键实现点:
- access_token 缓存进 Redis(跨实例共享,避免重复拉取互相失效)+ 进程内锁双检;
- 事件同时处理 subscribe(未关注,EventKey 带 qrscene_ 前缀)与 SCAN(已关注,不带);
- 事件回调必须验签——否则任何人 POST 一个 openid 就能登录别人;
- 回调无论如何回 "success",否则微信重试并给用户弹"公众号故障";
- User.wechat_openid 用部分唯一索引(WHERE <> ''),避开存量空串互撞。

配置(appid/secret/token)后台可改、secret AES 加密入库。管理端「运维 → 登录设置」
列出还需在公众平台做的事(服务器 URL / Token 一致 / 明文模式 / IP 白名单)。

本地验证(真流程,非 mock):验签回 echostr 与微信算法一致;模拟 subscribe 事件
→ 建号 + 置票 → PC 轮询拿到 token+user → 库里确有该 openid 用户。真微信推真事件
留待部署后扫码。前端 web 登录页加「微信扫码/邮箱」双 tab,默认微信。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:58:31 +08:00
Blizzard 07955ddf07 feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感),
不接管消息推送,副作用最小。

流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 →
回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized →
PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。

- 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets;
- ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis);
- User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一:
  存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败
  —— 与之前 NULL 余额同类的坑,这次提前避开。

单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、
建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了,
留待部署后真机扫码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:44:00 +08:00
Blizzard a8a497b4ce feat(gateway): 支持微信域名校验文件(为网页授权登录铺路)
微信公众平台配置「JS接口安全域名 / 网页授权域名」时会下发 MP_verify_xxx.txt,
要求能从域名根目录直接访问。文件放宿主机 /home/workspace/wechat-verify,
只读挂进容器(不进镜像、不进 git —— 它随时可能重发,且属站点凭证类文件)。
未设 WECHAT_VERIFY_DIR 时这段完全不生效。

**第一版写成了独立路由 `/:mpfile`,直接把官网打挂**:它匹配所有单段路径,
于是 /pricing、/download 全变 404(实测确认)。改为并进 NoRoute 的 SPA 兜底里,
在"已排除 /api/ 与内嵌静态文件"之后、回退 index.html 之前处理。

文件名白名单:必须 MP_verify_ 前缀 + .txt 后缀、不含路径分隔符与 ..,
否则落 SPA 兜底 —— 避免把挂载目录变成任意文件下载口子。

回归验证:/ /pricing /download /admin 均 200,校验文件取到正确内容,
/passwd.txt 与路径穿越都只拿到 index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:20:02 +08:00
Blizzard b1fea23a0c feat(site): 官网定价页 —— 套餐由后台配置驱动,公开可见
官网加「定价」菜单与页面。数据来自 GET /api/v1/pricing(**不挂鉴权**):
运营改价、上下架、调发放节奏都在管理端完成,官网跟着变,不用发版;
未登录就能看到价格,这是转化的前提,也是官网存在的意义。

接口只吐在售项与展示字段,成本、权重、租户信息一律不出去。

结账仍在 Web 面完成(那里有登录态、租户上下文与支付轮询),官网只负责
「看价 → 去买」。同一套支付流程不在两处各实现一遍——这也是本次订阅开发
一直遵循的那条线:积分包与订阅共用一个支付弹窗,桌面端不重复购买入口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:59:58 +08:00
Blizzard a34d7045f1 feat(desktop): 用量页显示订阅状态与到期倒计时(购买仍归 Web 面)
桌面端只读展示:套餐、发放节奏、已发放次数、到期倒计时(≤7 天转橙、≤3 天转红),
「去续订」跳系统浏览器到 Web 面账单页。

刻意**不在桌面端做购买流程**。购买入口已在 Web 面,再复制一套扫码弹窗就违背了
「一个功能一个入口、一份数据一个查法」——也正是我在积分包/订阅之间特意合并弹窗
所避免的那种重复:真要两套,之前修过的轮询问题就得修两遍。

但"还剩几天到期"必须放在桌面端:这是用户实际干活的地方,而订阅到期即失效、
没有任何扣款或续费通知,看不见就等于没有。

真环境验证:桌面端用量页显示真实订阅(已发放 3 次)、余额 2,981.34 与账本一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:47:05 +08:00
Blizzard caa2602131 feat(web): 用户面订阅 —— 购买/续订入口 + 到期提醒
账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。
已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延
语义一致,免得用户以为会新开一条。

到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**,
用户只能从这里看见,不显眼等于没有。

支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn
处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修
两遍。

真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K
(NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:33:59 +08:00
Blizzard f8f7359723 feat(admin): 订阅管理页 + 修「余额列为 NULL 导致充值永不到账」
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。

顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招):

  credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是
  「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**:
  分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的,
  不是订阅引入的问题。

三处修:
  - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈;
  - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立);
  - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate
    尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。

回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给
模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。

真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额
2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:28:32 +08:00
Blizzard 80d4aaf4ba feat(billing): 订阅制后端 —— 手动购买 + 周期发放积分 + 到期失效
规格(按需求):用户扫码买一个订阅周期,有效期内每 N 天发一次积分,到期即失效,
不自动续费。N 与每次发放额度都在套餐里配,后台可改。

为什么不做自动续费:微信 Native 扫码支付没有代扣能力,真自动续费要走「委托代扣」
——另一套产品与资质。与其假装有,不如把"到期即失效"这个语义做扎实。

两个决定,都写进了代码注释:
- **发放语义是累加而非重置**。每次刷新写一条 grant 分录、余额累加。重置型
  (月度配额清零)会让「余额 = SUM(ledger)」这条对账不变量变复杂,且有误清
  用户自费积分的风险。
- **订阅开通放在 store.MarkOrderPaid 内**,而不是各调用方。回调与掉单补偿两条
  路都经过它,放这一处才没人能漏掉;按 orderID 幂等,重复调用无害。

复用而非另造:订阅单与积分包单走同一条支付链路(下单/回调/查单/掉单补偿),
只是 kind=sub 且 credits_micro=0——积分不在付款时给,由订阅按周期发。

定时器每 10 分钟扫一轮,语义与幂等都在 store.TickSubscription 里,与手动触发
共用,不会两处漂移。停机期间欠下的发放会一次性补齐。

8 组测试。其中一条当场抓到真 bug:开通时原本无条件发一笔,同一订单重复开通
(回调重推/查单赛跑)会因序号自增绕过幂等索引,白送积分。改为开通也走与定时器
同一套排期判断——排期天然幂等。教训:幂等键要锚在业务时间轴上,不能靠自增序号。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:11:34 +08:00
Blizzard 466959d3c3 fix: 静默吞错横扫 —— 数据落库/配置广播/审计失败不再无声
把检索链路那次的做法推到同类位置。判据是"丢了这个 error 的代价",只改代价
高的,不动刻意的 fire-and-forget。

改(丢了就是数据永久丢失或行为不可解释):
  - task_handler: 任务**输出**与**执行轨迹**的收尾落库。这是唯一的持久副本
    (Redis 流 10min TTL),失败则永远无法复盘,界面上只显示"这次运行没有
    轨迹"。轨迹的 json.Marshal 失败分支同样是静默跳过,一并补上。
  - admin.broadcastActive: 模型配置热更新广播。失败 = dispatcher/mcp-go 仍用
    旧配置,症状是"控制台改了模型却不生效",而操作者这边一切正常——正是今天
    排查半天的那类问题。
  - middleware/audit: 审计留痕写入。不阻断主流程是对的(业务已完成),但静默
    失败意味着敏感操作没有记录,且没人知道记录缺了,这是合规缺口。

不改(确认过是合理的):
  - dispatcher 的 PublishToken/PublishExec:往前端推流,丢一帧是 UI 瑕疵,
    且按 token 打日志会刷屏;
  - 计量回写 PublishUsage:本来就检查 error 并打日志,钱的路径是干净的。

另修一处误导文案:任务下钻的轨迹空态原本把原因说死为"早于该功能上线",
现在"落库失败"也是已知原因,文案改为并列并指向日志里的 [task] 告警。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:49:13 +08:00
Blizzard 518cddbd1c feat(admin): 检索试验台分路卡片直接说明"为什么空"
此前四张卡片一律写"这一路没召回",而人的视线落在卡片上、不在顶部状态行。
现在按该路诊断分别显示:未启用给出缺什么配置(琥珀)、报错给出错误原文(红)、
真没匹配才说没召回。

真环境验证(截图为证):向量="该知识库在 Milvus 中没有向量(未入库或集合被
重建过)"、图谱="未启用:Neo4j 未连接或未配置"、全文="索引里确实没有匹配内容"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:29:04 +08:00
Blizzard 7c8c723245 fix(admin): 诊断解析放错了函数 + 补 kb_search 形状测试
上一个提交里,诊断解析被塞进了用户面的 KbSearch,而真正传 diag 的
AdminKbSearch 还在用老代码 —— 于是试验台永远拿不到 routes。写法上是
字符串替换只改了文件里第一处匹配,而这两个 handler 的收尾代码一模一样。
用户面已还原(它不传 diag,拿到的本就是裸数组,多一层解析是错的)。

补 3 组 mcp 层测试钉死 kb_search 的两种返回形状:
  - 不带 diag(生产调用:dispatcher / Agent 工具链)→ 裸 JSON 数组,契约不变;
  - 带 diag → {hits, routes} 对象;
  - not ready 时不被总闸短路,仍逐路诊断。
这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若无条件生效,所有
调用方的解析都会静默失败。

真环境验证(本地 gateway + mcp-go + 真 PG/Milvus/bleve):
  vector    empty     该知识库在 Milvus 中没有向量(未入库或集合被重建过)
  fulltext  ok        15 命中
  graph     disabled  Neo4j 未连接或未配置
以前这三行都只是"0 条"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:24:49 +08:00
Blizzard b26fe21408 fix(rag): 检索三路不再静默吞错 —— 逐路诊断 + 一路挂不拖垮全部
排查"向量路为什么是空的"花了半小时,因为空就是空,没有任何线索。这次把
整条检索链上的静默降级一次清掉。

真 bug(不只是可观测性):
  - kb_search 与 Search() 都拿 rag.Ready() 当总闸,而 Ready() 只代表"向量路
    可用"(embedding + Milvus)。全文(bleve)与图谱(Neo4j)根本不依赖它们,却
    被一并毙掉 → "模型配置没下发"表现为"整个知识库什么都搜不到",还不报错。
    改为逐路判定,任一路可用就仍有召回。

不再吞错:
  - milvus.search 原先把 error 转成 nil,nil —— 检索失败与无召回彻底无法区分;
  - bleve.search / graph.search 出错直接回 nil,连日志都没有;
  - searchPaths 丢掉 embedding 的 error。
    三处改为如实返回,错误统一打日志。

逐路诊断(RouteDiag):每路上报 ok/empty/disabled/error + 耗时 + 原因,经
kb_search 的 diag 参数(仅试验台传,生产调用返回值不变)→ gateway → 检索
试验台。界面上现在能直接看出"这一路没配置/报错了/确实没匹配",不必翻日志。
内存兜底索引也会在 note 里点明"重启即清零"。

测试:3 组,覆盖"无 embedding 时全文仍可召回"、三种空的区分、内存索引提示。
把总闸加回去验证过第一条确实会红——测试能抓到这个回归,不是摆设。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:10:03 +08:00
Blizzard 00ad6c8ac0 fix(admin): 任务下钻按真实轨迹字段渲染 + 修评测页白屏
docker 起来后拿真库验证,上一版有两处只有真数据才暴露的问题:

1. 轨迹字段名猜错了。实际是 seq/ts/node/kind/phase/label/detail,我写的是
   type/msg/at。更糟的是兜底判断用了 !e.msg && !e.node —— node 恰好存在,
   兜底不触发,结果会渲染出一列几乎空白的行。现按真实字段渲染:label 作
   标题、detail 作正文、kind 上色(system/model/tool/agent/plan/section/
   render/approval 八种)、phase=error 整行标红、ts 转本地时间。

2. 评测页签点开直接白屏。库里 flags 存的是字符串 "null"(不是 "[]"),
   JSON.parse 出来是 null,flags.length 抛 TypeError 把整个控制台打崩。
   `as string[]` 的类型断言骗过了 tsc,单测也覆盖不到——只有点开真数据的
   页面才会炸。改为 Array.isArray 校验。

验证方式:本地起 gateway + 真 Postgres,浏览器里逐个页签点过(轨迹 15 条
真实事件、最终输出、评测 100/100/100、DSL 格式化),并在真库上确认跨租户
读取、jsonb ->> 取 topic、cast(jsonb as text) 取 graph 均正常。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:20:58 +08:00
Blizzard e4ec74893f feat(admin): 任务观测支持下钻 —— 轨迹/输出/评测/DSL
全平台任务页此前只能看列表,点进去什么都没有。现在点一行开抽屉,四个页签:
执行轨迹、最终输出、评测明细、提交时的 DSL。不含审批操作——审批是客户端
用户的行为(桌面端 ApprovalBar),管理端只做观测。

不复用用户面的 /tasks/:id/replay:Task/Eval 都在租户插件作用域内,用请求 ctx
查别的租户的任务不会报错,而是静默返回空输出/空轨迹,UI 上表现为"这任务没
产出",比报错难查得多。新增 admin 端点走 WithoutTenant。

数据取自 sundynix_task 收尾落库的 output/trace 列,不依赖 Redis 流(10min TTL)。
所以这是复盘视图,运行中的任务轨迹为空——UI 里明确写出来,免得被当成轨迹丢了。

修的两处与测试环境失真有关(写测试时暴露的):
  - 测试库没配 NamingStrategy,与 OpenPostgres 不一致:多数模型有显式
    TableName() 碰巧对得上,但 Task 这类没有的会退化成 "tasks",导致写裸
    SQL 的查询在测试里查无此表。现已对齐 sundynix_ 前缀 + 单数表名。
  - graph 的 ::text 换成标准 cast(... as text):前者是 Postgres 专有,
    换掉后这条查询才能被内存库覆盖。

新增 5 组测试,其中一组专门先证明"租户过滤在测试环境里确实开着"——否则
"跨租户能读到"的断言可能只是因为插件没装,属于假过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:07:02 +08:00
Blizzard 84463394d4 fix(admin): 审计筛选下沉 SQL + 工具数不再写死 + 模型删除加确认
清单里那批小毛病,逐条复核后修(「审计详情列缺失」那条已不成立,早补上了)。

1. 审计筛选只在当前页生效 —— 影响最大的一条。分页是服务端的,筛选却在
   前端对已取回的 50 条做,于是搜一个用户 ID 显示"无结果"时,后面几页
   可能还有几百条。审计的用途就是查证,"搜不到"会被读成"没发生过"。
   改为 action/path/q 三个条件全部落到 SQL,前端只管发条件(防抖 300ms)。

2. 服务状态页把 mcp-go/mcp-py 的工具数写死成 23/4 —— 增删工具后一直骗人,
   且服务离线时照样显示,看不出工具其实一个都没注册上。改取实际上报值。

3. 模型删除一点即删,无任何确认。补二次确认,并对"正在使用中"的模型
   单独说明后果(删掉会立刻打断线上对话/向量能力)。

审计筛选补了 4 组回归测试,两条是踩出来的坑:
  - q 的 OR 组必须带括号:gorm 以 AND 拼接各 Where,裸 OR 会让 action
    条件被绕过(测试里用"同 IP 不同方法"两行钉死这个语义);
  - LIKE 必须显式写 ESCAPE '\':Postgres 默认拿反斜杠当转义符,SQLite
    不写就没有转义符——原来的写法在单测里静默失效,搜 "100%" 命中 0 条。
    顺带把 ILIKE 换成 LOWER()+LIKE,这段才能被内存库覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:51:59 +08:00
Blizzard bfd0d74c34 feat(status): 全文索引降级上报到控制台 —— 让静默降级看得见
上一个提交修的是"卷没挂"这个实例,这个提交修的是"它坏了几周没人发现"这个
类别问题:全文路退内存兜底后,混合检索只表现为召回变差,不报错、无告警。

- mcp-go: bleveStore 记录 persistent;health 工具多报一个 fulltext_disk。
  仍是 map[string]bool,gateway 现有反序列化不受影响(加非 bool 字段会让
  整个 health 解析失败,连带 Milvus/Neo4j 健康灯一起黑)。
- gateway: 基建灯新增「全文索引」一项,退内存时给出后果与排查方向,
  而不只是一盏灰灯。
- admin: 基建列表此前只渲染 name/up,detail 字段一直没露出——而降级原因
  恰恰只存在于 detail 里。补上渲染(离线态用琥珀色),并给「全文索引」
  补 INFRA_META(它不是独立服务,没有端口,落在容器卷上)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:44:48 +08:00
Blizzard c1420b5665 fix(rag): 全文索引在容器里根本没持久化 —— 补 /data 卷 + BLEVE_PATH
排查「fulltext 路 0 命中」时发现的真问题,比原以为的分词器问题严重得多。

现状:mcp-go 容器**没有任何 volume**,也没设 BLEVE_PATH,于是索引落到相对
CWD 的 .data/bleve。而运行阶段没有 WORKDIR(CWD=/)、进程又是非 root(uid 10001),
两种结局都糟:
  - 在 / 下建不了目录 → openBleve 静默退回内存索引,重启即清零;
  - 就算建得了,也只是写进容器可写层,每次发版重建容器一样清零。
更糟的是它不报错:混合检索少一路只表现为召回变差,看不出故障。

改动:
  - Dockerfile:建 /data 并 chown 给 app,WORKDIR 设为 /data;
  - 两份 compose:BLEVE_PATH=/data/bleve + 挂持久卷(132 那份此前连
    顶层 volumes 段都没有);
  - .env.example:写清默认相对路径相对的是**启动目录**——本地从仓库根起
    和从 sundynix-mcp-go/ 起会写出两份互不相干的索引,排查时极易被坑。

附带澄清:中文分词本身没问题。直接开落盘索引验证过,mapping 里 text 字段
确实是 cjk,实测「星间链」命中 4 条、「海关监管」命中 104 条,BM25 打分正常。
a4852c3 那次修复是有效的,之前怀疑分词器的判断作废。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:40:31 +08:00
Blizzard 2c9ecc833e feat(admin): 积分包支持就地生成付款码,验证支付链路
管理端「支付 · 配置 → 积分包」每行加「生成付款码」:下单 → 二维码 → 盯到终态,
用来确认「渠道配置 → 下单 → 扫码 → 回调/查单 → 幂等入账 → 积分到账」在当前
环境真的通。

刻意走用户面的 /api/v1/billing/*,不做 admin 专用旁路 —— 造条测试专线的话,
验过了也不能说明线上用户那条路通。租户由服务端按登录用户解析,无需带租户头。

代价是这会真实扣款,所以:
- 弹窗顶部显著提示是真单、会真扣钱,并指向「订单与对账」的退款入口;
- 已下架的包按钮禁用(服务端本就会拒,别让人白扫)。

弹窗展示订单号/状态/轮询次数/到账积分,出问题时能直接判断卡在哪一环,
比只说一句"成功/失败"有用。轮询策略与 sundynix-web 支付弹窗保持一致:
递归 setTimeout、切后台暂停、失败指数退避、倒计时以服务端 expires_at 为准。

依赖:admin 新增 qrcode(懒加载进 PaymentConfigPage 分块,不进主包)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:52:24 +08:00
Blizzard 70b0419387 fix(payment): 支付轮询链路收口——查单节流 + 前端轮询重写
后端:
- 主动查单加最小间隔(5s)。此前 BillingOrderStatus 见 pending 就直连微信查单,
  而前端 2.5s 轮一次 —— 单笔订单在 30min TTL 内可打出约 720 次渠道调用,
  微信侧有频控,并发用户一多先被限流的是我们自己。回调才是入账主路径,
  查单只是兜底,节流最坏只让在场用户多等 5s。
- 标记随订单落终态清除,并在补偿定时器每轮 prune 掉超 TTL 的残留
  (用户扫码前就关弹窗的订单不会再被轮询,其标记无人回收)。
- 下单响应补 expires_at:二维码有效期由服务端 orderTTL 说了算,
  前端硬编码一份迟早漂移。

前端(sundynix-web):
- orderStatus 不再丢掉 warn。服务端在「已付但金额与订单不符」时不入账、
  挂起人工核对,订单一直停在 pending —— 丢掉 warn 用户就会一直等一个
  永远不会来的结果。现在弹窗显式告警并给出订单号。
- setInterval → 递归 setTimeout:请求慢于间隔时 setInterval 会把请求摞起来,
  对「每次可能触发渠道查单」的端点尤其糟。
- 标签页切走时暂停轮询(用户扫完码要切去微信 App),切回前台立刻补查一次。
- 连续失败指数退避至 15s 封顶,断网时不再定频猛打。
- 二维码倒计时 + 失效遮罩,过期不再让用户扫一个必然失败的码。
- 明说「关掉也不会丢钱,到账会自动补上」——掉单补偿定时器本就兜底,
  但此前 UI 没讲,用户只能守着弹窗。
- 关闭弹窗一律刷新余额:用户可能在关闭前一刻付款、状态刚落地。

测试:新增 handler 包首个测试,钉住节流与标记回收行为(-race 通过)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:46:40 +08:00
Blizzard 34500e66a7 fix(admin): 补全 package-lock,修 CI 里 npm ci 失败
vitest@4 依赖 esbuild 0.28.x,而 vite@5.4 用 0.21.5,lock 里只写了后者,
vitest 那份嵌套 esbuild(含各平台包)从没落进 lock —— 当初加 vitest 时 lock
只做了部分更新。本地 node_modules 早已装好,npm run build / vitest 一直正常,
所以直到 Docker 里 npm ci 严格按 lock 装才炸:
  npm error `npm ci` can only install packages when your package.json and
  package-lock.json are in sync. Missing: esbuild@0.28.1 from lock file

npm install 重新生成 lock,两套 esbuild 及其 linux/musl 平台包都已就位;
干净目录 npm ci 验证通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:25:26 +08:00
Blizzard 79b110afd0 feat(admin): 数据源页转 RAG 运维台 + 支付/模型菜单重组 + 概览升级为仪表盘
后端:
- 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路
  (vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者
  自己的 space,跨租户排障就没法做了)
- KB 清单补 space_id(检索键是 <space_id>/<name>,缺它前端拼不出 key)

前端:
- 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断,
  召回不准时能直接定位是向量/分词/图谱哪一环挂了
- 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下;
  导航支持二级菜单(NavParent 命中子路由自动展开)
- SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一
- 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection),
  去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据)
- 全局隐藏滚动条(保留滚动)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:17:54 +08:00
Blizzard ac4fd00e28 fix(deploy): gateway 挂载微信支付证书目录(容器读不到宿主机路径)
证书在 132 宿主机 /home/workspace/wechat-pay-cert,但 gateway 跑在容器里、没挂任何卷,
LoadPrivateKeyWithPath 直接读磁盘 → 路径不存在 → 商户私钥加载失败、微信渠道隐藏。

只读挂进 gateway(只有它碰支付,dispatcher/mcp-* 不用):
  /home/workspace/wechat-pay-cert → /etc/sundynix/wechat-cert (ro)

⚠️ admin「系统配置 → 支付」里填的必须是**容器内路径** /etc/sundynix/wechat-cert/xxx.pem,
填宿主机路径会失败。私钥不进镜像、不进 git。部署时 compose 变更会让 up -d 重建容器生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:19:32 +08:00
Blizzard c3151d1078 refactor(admin): 配置按域收口到「系统配置」页,Usage 只留用量观测
原本模型配置在「模型」页、计费规则与支付渠道却塞在「分析 > 计费&用量」观测页里(该页
~1290 行,首屏全是表单、真正的用量观测在第四屏)。按「域」重组:

- 新建「系统配置」页,两个 Tab:
  · 模型 = 模型登记/激活/连通性测试 + 计费规则(汇率/硬拦截/按模型定价与权重)
  · 支付 = 支付渠道配置(兑换码/积分包/微信参数) + 订单流水(订单流/对账/人工退款)
  配了什么就在同处看它的规则与数据,不用来回跳。三块直接复用原组件,零重写。
- 「计费 & 用量」瘦成纯用量观测(消耗趋势/租户排行/发放积分)。
- 导航「模型」页并入系统配置(12 页 → 11),侧栏图标换齿轮。
- 顺带修残留 bug:数据源页「前往模型管理」原为 href=#/models + onClick 改 hash,
  既指向已废弃路径、又是 hash 写法(早已换 BrowserRouter,点了不跳),改正规 <Link to=/admin/settings>。

tsc + build + vitest 41 过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:19:32 +08:00
Blizzard d189dd967a fix(admin): resolve JSX namespace and className syntax compilation errors in admin console 2026-07-18 22:21:13 +08:00
Blizzard 0d2929931f feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard 2026-07-18 22:18:12 +08:00
Blizzard 197531ea33 fix(site): 官网页头去掉深浅切换按钮(未挂 ThemeProvider 会运行时报错)
ThemeToggle 依赖 useTheme/ThemeProvider,但按'官网默认浅色'计划没搬那套 provider →
useTheme must be used within ThemeProvider 运行时崩。删掉切换按钮 + 无用的 theme-provider.tsx。
顺带 index.html 换中性标题 + favicon 指向官网 logo。排查确认无其它 Context/Provider 依赖、
logo 路径 /brand/ 正确。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:44:43 +08:00
Blizzard 6de1b3f6b2 feat(admin): 官网落地页融进 admin —— / 是官网、/admin 是运维后台
用户要 agent.sundynix.cn/ 打开是营销官网、/admin 是后台,且只用一个前端。
把 sundynix-site/web 的营销落地页融进 sundynix-admin 这一个 Vite 工程(不挂两份 embed):

- 拷官网到 src/site/:home(hero/功能/架构/快速上手/下载)+layout(header/footer)
  +terminal-demo/logo/reveal 等 + content/site.ts(文案本就是本产品)。去掉博客/posts/埋点/RSS
  (营销 only,零后端改动);use-release 改纯静态版本号。public/ 加 brand logo+favicon。
- Tailwind v4→v3 对齐:site 的 ink/ground/accent/term 等 token 加进 tailwind.config(var 引用)
  + :root/.dark 变量+keyframes 进 index.css;bg-ground/85 用 color-mix 还原。admin 自身用默认
  gray/violet 调色板,不冲突;landing 的 bg-ground 只作用在 SiteLayout 容器、不改 body。
- 路由重构:App.tsx 换 BrowserRouter —— / 官网(公开)、/admin/* 后台(AdminGate 内做 me/Login/AppShell)。
  routes.tsx path 改相对、AppShell 链接加 /admin 前缀。@ 别名指向 src/site。
- gateway/Dockerfile/nginx 零改动:单份 dist 挂根 + NoRoute SPA 兜底已支持 BrowserRouter 深链。

验证:tsc + vite build(Tailwind 编译过)+ vitest 41 过;gateway 二进制冒烟打真 serve——
/ 返回官网 index.html、/admin 与 /admin/tasks SPA 兜底 200、/brand/*.webp 200、/api/v1 走 API 401;
dist 里确认落地页文案已进 bundle。(注:MCP 浏览器当前断开,视觉渲染待部署后线上看。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:36:43 +08:00
Blizzard 373167b705 feat(admin): 补全平台任务观测 + 空间管理 + 基建加 MinIO/实时探针
对照已实现系统功能补 admin 缺失模块(feat/site):

- 全平台任务/运行观测:GET /admin/tasks(跨租户查 sundynix_task,join 租户名/提交人邮箱/评测,
  按状态/租户筛 + 状态计数;含 HITL 待审批=筛 waiting)+ TasksPage(状态卡+筛+表)。
- 空间(Space)管理:GET /admin/spaces(跨租户列 Space + 成员数子查询 + kind/归档态)+ SpacesPage。
- 基建观测:infra 加 MinIO(126 对象存储);postgres/redis/minio 从启动标志升级为实时 ping
  (blob.Ping/Postgres.Ping/Redis.Ping),能反映中途掉线。StatusPage 加 minio 标签、6 个依赖。
- 模型健康/熔断:确认 DashboardPage 已有「运行时链路态」渲染 m.health,无需重做。

验证:admin tsc + vitest 41 过、gateway build/vet/test 过;两新页浏览器实测渲染+优雅错误处理;
两新端点起临时 gateway 打真 PG 实测——tasks(counts+3路join)、spaces(成员数子查询)均返真数据。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:06:25 +08:00
Blizzard de315450a4 fix(mcp-py): NATS_URL 逗号多节点拆成列表(集群连接)
deploy-132 / deploy (push) Successful in 1m56s
Python nats.py 不像 Go 客户端会自动拆逗号串,把 nats://a:4222,nats://a:4223 整串当单
URL 解析端口 → ValueError: Port '4222,nats:' → invalid connect url,mcp-py 崩溃重启循环。
按逗号拆成 servers 列表传入即可。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:25:32 +08:00
Blizzard 55b8a697aa ci(gitea): 132 上扁平布局 —— compose 与 .env 同放 $REMOTE_DIR 根
deploy-132 / deploy (push) Successful in 1m45s
之前 scp 到 $REMOTE_DIR/deploy/132-app/ 并在那跑,需 .env 也放深层目录。改为 compose
直接落 $REMOTE_DIR/docker-compose.yml、在 $REMOTE_DIR 根下 up,与用户预放的 $REMOTE_DIR/.env
同级。--no-build 不碰构建上下文,compose 里的相对 build 路径在 132 上无影响。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:17:48 +08:00
Blizzard 09ac03b758 perf(docker): 依赖下载全部换国内源(内网构建提速)
deploy-132 / deploy (push) Failing after 5m35s
go mod download 走默认 proxy.golang.org 内网极慢。四个 Dockerfile 统一换国内镜像:
- Go: GOPROXY=https://goproxy.cn,direct(三个 Go 服务)
- alpine: apk 源换 mirrors.aliyun.com(ca-certificates/tzdata)
- pip: mcp-py 换清华 pypi 源
- npm: gateway admin 已在用 npmmirror(此前已有)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:03:45 +08:00
Blizzard 2d14245086 fix(docker): 运行镜像 gcr.io/distroless → alpine(内网 runner 连不上 gcr.io)
deploy-132 / deploy (push) Has been cancelled
128 的 runner 能拉 docker.io 但连不上 gcr.io(dial 443 i/o timeout),distroless 拉不到。
三个 Go 服务运行 stage 换 alpine:3.20 + ca-certificates(TLS 连 LLM/MinIO)+ tzdata +
非 root 用户。二进制 CGO_ENABLED=0 全静态,alpine 上直接跑;本地已验证构建+启动正常。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:54:35 +08:00
Blizzard 84a32023ee ci(gitea): 检出改为从内网 Gitea 克隆,绕开 github.com 拉 actions/checkout
deploy-132 / deploy (push) Failing after 33s
内网 runner 连不上 github.com,actions/checkout@v4 下载 action 时 unexpected EOF。
改用 git init+fetch 从 git.sundynix.cn(同内网必达)拉当前分支,用工作流自带
github.token 认证,不再依赖 github.com。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:47:33 +08:00
Blizzard 7cacfdb5a3 Merge remote-tracking branch 'fork/main'
deploy-132 / deploy (push) Failing after 1m31s
2026-07-18 15:41:18 +08:00
Blizzard e350fa9595 ci: release.yml 也加 Gitea 守卫(打 tag 时不在 Gitea 上误跑桌面发布构建)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:33:29 +08:00
Blizzard 05b66399f9 ci(gitea): 改为 runner 构建镜像 + save/scp(照 sundynix-site 模式)
之前写成 rsync 源码到 132 本地 build,需要 132 上有全套源码。改回 sundynix-site 的做法:
runner(128) 构建 4 个应用镜像 → docker save 打包 → scp 镜像包 + compose 到 132 →
132 docker load + docker compose up -d --no-build(用预构建镜像,不在 132 上 build)。
于是 132 上只有 docker-compose.yml + .env(+ 临时镜像包),没有源码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:17:50 +08:00
Blizzard da0d964054 ci(gitea): 部署目录改为 /home/workspace/sundynix-agentix
与 sundynix-site 的 /home/workspace/ 一致(用户实际部署路径)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:11:10 +08:00
Blizzard 2599936777 ci(gitea): push-to-main 自动部署到 132 + 防 GitHub CI 在 Gitea 误跑
- .gitea/workflows/deploy.yml:runner(128) 检出 → rsync 仓库到 132 → 在 132 本地
  docker compose up -d --build → 循环 curl :3000/healthz。在 132 构建、132 运行(不拿
  4 个大镜像去 128 构建);rsync 排除 .env/node_modules,不覆盖 132 上的密钥。
  Gitea secrets:DEPLOY_HOST/DEPLOY_USER/DEPLOY_PASSWORD。
- .github/workflows/ci.yml:6 个 job 加 if !contains(github.server_url,'sundynix.cn'),
  Gitea 上只跑 deploy、不误跑 GitHub CI(此前为此把 Gitea 工作流整个关了)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:45:24 +08:00
Blizzard 6b45adbbd2 chore(deploy): 三机部署 compose 拆分 + NATS 3 节点集群
126=MinIO(已部署)、128=基础设施、132=应用。

- deploy/128-infra:NATS 3 节点集群(独立卷+cluster routes,暴露4222/4223/4224)+PG/Redis/
  Milvus(自带etcd+自带内部minio,不复用126)/Neo4j/Jaeger;端口对局域网暴露、卷持久化。
- deploy/nats-cluster/nats{1,2,3}.conf:max_payload 一致 + jetstream + cluster。
- deploy/132-app:仅 gateway(3000:8080)+dispatcher+mcp-go+mcp-py,无 admin nginx;env 指向
  126/128;mcp-go 补齐 MINIO env(本会话 blob 遗漏);NATS_STREAM_REPLICAS=3;.env.example 模板。
- docker-compose.prod.yml(单机):删 admin 服务(已内嵌)、mcp-go 补 MINIO env。
- 三个 compose 均 docker compose config 校验通过;.env 已 gitignore、.env.example 可提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:45:24 +08:00
Blizzard 2e02a12260 feat(bus): JetStream 流/KV 副本数可配(NATS_STREAM_REPLICAS,默认1)
三机/单机多节点集群部署需 Replicas=3,durable 流才有 Raft quorum 容错(计费/状态/
用量/评测/审批/入库回写不因单节点挂掉而丢)。此前 6 处 CreateOrUpdateStream + 1 处
KV 桶都未设 Replicas(默认1),集群等于白搭。加 streamReplicas() 读 env(合法值 1/3/5),
7 处统一带上。默认 1 保证单机/CI 不变。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:45:24 +08:00
Blizzard 28a1543249 feat(gateway): 内嵌 admin 控制台(go:embed),去掉单独 nginx 容器
复刻 sundynix-site 的 webfs 模式:gateway 一个容器同时 serve 控制台 UI + API + 供
桌面端直连。admin 用 HashRouter + API base 走相对 /api/v1(构建传 VITE_GATEWAY=''),
源码零改动即可同源 serve。

- internal/webui:go:embed all:admin_dist + Dist();占位 index.html 让不构建前端也能编译。
  目录命名 admin_dist(避开 .gitignore dist/ 与 .dockerignore **/dist 通配)。
- router.go NoRoute:非 /api/ 路径走内嵌静态,命中 /assets/* 直吐、否则回退 index.html;
  embed 失败仅 log 降级不影响 API。/metrics /healthz /readyz /api/v1 已注册,永不进 NoRoute。
- Dockerfile 加 admin node 构建 stage,go build 前 COPY dist 覆盖 embed 占位。
- live 验证:/ 返回 admin(200)、/assets/*.js(200)、/api/v1/admin/overview(401 走 API)、/healthz(200)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:45:24 +08:00
Blizzard 3a4e1d53a5 refactor(payment): 渠道抽象成 Channel 接口 —— 接新渠道=加 adapter 不动骨架
PAYMENT_DESIGN §3 承诺的 internal/payment/channel.go 适配器接口此前不存在,微信硬编码在
manager/handler 里。补齐抽象:

- channel.go:Channel 接口(Name/CreatePay/QueryOrder/VerifyCallback)+ 统一 PayIntent/PayResult
  + 渠道名常量。入参用基本类型不吃 *store.PaymentOrder,payment 包不反依赖 store。
- Wechat 实现 Channel(编译期断言 var _ Channel);QueryResult 归一为 PayResult;CreatePay 返回 PayIntent。
- Manager 从「持一个 *Wechat」改为渠道注册表:Get(name)/Available()/Status(name)/ReloadWechat;
  按渠道名持有已装配实例,热重载不变。
- 回调路由收敛 /billing/callback/wechat → /billing/callback/:channel 按名路由(旧 notify URL 仍匹配);
  查单/掉单补偿据 order.Channel 路由,不再写死微信。下单支持可选 channel(缺省 wechat)。
- 支付宝/Stripe 现在真·只差一个 adapter+注册。唯一未泛化:回调 ack 应答格式(现微信态,注释标明)。
- payment 包首个测试:Manager 注册表 4 用例(空/注册摘除/空配置/配置不全)。build/vet/test/lint 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:56:24 +08:00
Blizzard 86e79d76ac docs(eino): 诚实修正 Phase C —— 报告流水线未 compose 化(刻意保留独立路径)
Phase C 改动清单曾把「report.go 改用 compose」标 ,实际 report intent 仍走独立
handleReport(writeSections 并发流水线),不经 compose.Graph。改为诚实记录:报告是
固定形状专用流水线、并行已由 writeSections 拿到,硬迁移只是拿稳定功能冒回归风险、
无用户可见收益,故刻意保留为 compose 之外的第二条编排路径。顺带把 Phase C 头部
灰度开关的陈旧描述更新为「已单引擎、graph.go 退役」的实况(与代码一致)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:45:51 +08:00
Blizzard 1beef44625 ci: 加 -race + golangci-lint(new-issues) + govulncheck/gitleaks 安全扫描
补齐审计指出的「CI 缺 lint/安全扫描/-race」低成本质量门:

- go job:go test → go test -race(4 模块本地已验证 race-clean,ubuntu 自带 gcc)。
- lint job:golangci-lint(standard 集)仅 PR 跑 + only-new-issues —— 新代码必须干净,
  存量 ~42 处(多为未检 Close/死代码)单独消化,不拿存量红门。加 .golangci.yml(测试放过 errcheck)。
- security job:govulncheck advisory(stdlib/nats CVE 靠 toolchain/依赖升级,只做可见性不阻断)
  + gitleaks 扫提交防密钥泄漏。
- 顺手清掉本会话新代码的 lint:blob GetBytes 的 defer Close、退款测试未检 CreateOrder。

注:gitleaks-action 个人/公开仓库免费;组织仓库需 GITLEAKS_LICENSE。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:44:38 +08:00
Blizzard 5e5f6e9610 feat(billing): 人工退款入口 —— paid 单冲销积分+置 refunded(幂等)
PAYMENT_DESIGN §5 承诺却只留了 OrderRefunded 常量、无入口。补齐全链路:

- store.RefundOrder:订单 CAS(paid→refunded) 为主闸(重复退 changed=false 幂等),
  同事务记 adjust 负分录(ref=订单号)+ 回退物化余额。照抄 MarkOrderPaid 双闸范式;
  新增 idx_ledger_refund_ref 部分唯一索引(kind='adjust' AND ref<>'')做账本级兜底,
  与 grant 索引对称、不与 admin 手工校正(ref 空)冲突。
- 积分若已消费,回退后余额可为负(人工退款预期,账本仍自洽,后续消费被硬拦截)。
- handler AdminRefundOrder + POST /admin/orders/:id/refund(admin 组已挂 Audit 留痕);
  真渠道钱款原路退回需 admin 另在商户后台操作,本地仅冲销积分与订单态(不接自动退款 API)。
- admin 订单流加「退款」按钮(仅 paid 单可见,二次确认+填原因)。
- 测试:RefundOrder 冲销+幂等、只退 paid 两个不变量测试(sqlite 真 DB,余额=账本之和)。
  gateway build/vet/test 全绿,admin tsc 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:35:31 +08:00
Blizzard ec9431bfa4 fix(report): 报告源/产物落 MinIO,去掉 gateway↔mcp-go 本地盘耦合
多机/容器化第一时间断裂的隐患(ARCHITECTURE_REVIEW §7 #1):此前报告
源(reportStore)与 .docx 产物(reportExport/reportRender)都写 mcp-go
本地 SUNDYNIX_REPORTS_DIR,gateway 再按同一路径 c.File 回流——mcp-go
与 gateway 不共享磁盘即断。

- blob 包从 gateway/internal 提到 sundynix-shared/blob,gateway 与 mcp-go
  共用同一 MinIO;新增 PutBytes/GetBytes 走二进制(.docx)。
- mcp-go 注入 blob:report 源/产物优先 Put 到对象存储(键 reports/<id>.{json,docx}),
  导出结果返回 minio://<key>;MinIO 未就绪回退本地盘(单机降级,getSource 兼容旧本地报告)。
- gateway ExportReport 识别 minio:// → GetBytes 流式下载,否则 c.File 本地降级。
- 测试:mcp 本地回落往返单测 + shared/blob 真 MinIO 二进制往返(BLOB_TEST_ENDPOINT 门控)。
  四模块 build 绿,受影响测试全过。
- 附带:go mod tidy 引入 genproto 拆包冲突,四模块统一钉单体 genproto 新版解决
  (注:勿 go work sync,会剪掉 indirect require 回落旧版重现冲突)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:29:31 +08:00
Blizzard 1c7e8e1ea9 docs: 修 DEPTH_ROADMAP/EINO 两处过期标记
- 删 T4.E 熔断器接回 failover 重复的过期 [ ] 项(222 行  为准);
  T2.1 熔断器联动注明已整合。
- EINO Phase D FSM 回写主题名笔误 sundynix.tasks.status → sundynix.status.task
  (文档 141 行自己记过这次迁移)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:29:01 +08:00
Blizzard f0f9eccf6f Merge pull request 'feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen' (#1) from feat/wails3 into main
Reviewed-on: #1
2026-07-17 01:12:31 +00:00
256 changed files with 21156 additions and 1612 deletions
Vendored
BIN
View File
Binary file not shown.
+5
View File
@@ -23,6 +23,11 @@ POSTGRES_PASSWORD=
# 图数据库密码(Neo4j,知识图谱)。
NEO4J_PASSWORD=
# 全文(bleve)索引落盘目录。留空则用相对路径 .data/bleve —— 相对的是 mcp-go 的**启动目录**
# 从仓库根起和从 sundynix-mcp-go/ 起会写出两份互不相干的索引(本地排查全文召回时最容易被这个坑到)。
# 本地开发建议显式写成绝对路径;容器部署已在 compose 里固定为 /data/bleve + 持久卷。
BLEVE_PATH=
# ── 必填:管理员 ─────────────────────────────────────────────
# 管理员用户 ID 白名单(逗号分隔)。生产模式下只有这些用户能进 /admin 控制台。
# 首次部署:先留空起服务 → 注册第一个账号 → 从返回的 user id 填进来 → 重启 gateway。
+90
View File
@@ -0,0 +1,90 @@
name: deploy-132
# 只认 main(含 PR 合并入 main)。照 sundynix-site 的模式:
# runner(128) 构建 4 个应用镜像 → docker save 打包 → scp 镜像包 + compose 到 132
# → 132 docker load + docker compose up -d(用预构建镜像,不在 132 上 build)。
# 于是 132 上只有 compose + .env(+ 临时镜像包),没有源码。
on:
push:
branches: [main]
workflow_dispatch:
env:
REMOTE_DIR: /home/workspace/sundynix-agentix
jobs:
deploy:
runs-on: ubuntu-latest # 须匹配 128 上已注册 runner 的标签;不符请改成你的标签
steps:
# 不用 actions/checkout(它要去 github.com 下载 action,内网 runner 连不上会 EOF);
# 直接从内网 Gitea 用工作流自带 token 拉本仓库当前分支。init+fetch 不怕工作目录非空。
- name: 检出代码(内网 Gitea
run: |
git init -q .
git remote add origin "https://token:${{ github.token }}@git.sundynix.cn/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.ref_name }}"
git checkout -q FETCH_HEAD
- name: 构建 4 个应用镜像(在 runner 上)
run: |
# gateway/dispatcher/mcp-go 的构建上下文是仓库根(Go replace ../sundynix-shared
# 且 gateway 还要 COPY sundynix-admin/ 构建内嵌前端);mcp-py 上下文是自己的目录。
docker build -f sundynix-gateway/Dockerfile -t sundynix/gateway:latest .
docker build -f sundynix-dispatcher/Dockerfile -t sundynix/dispatcher:latest .
docker build -f sundynix-mcp-go/Dockerfile -t sundynix/mcp-go:latest .
docker build -f sundynix-mcp-py/Dockerfile -t sundynix/mcp-py:latest sundynix-mcp-py
- name: 导出镜像为压缩包
run: |
docker save \
sundynix/gateway:latest sundynix/dispatcher:latest \
sundynix/mcp-go:latest sundynix/mcp-py:latest \
| gzip > images.tar.gz
- name: 安装 ssh 工具
run: |
if command -v apt-get >/dev/null; then
apt-get update && apt-get install -y sshpass openssh-client
elif command -v apk >/dev/null; then
apk add --no-cache sshpass openssh-client
fi
- name: 传镜像包与 compose 到 132
env:
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
run: |
H="${{ secrets.DEPLOY_HOST }}"
U="${{ secrets.DEPLOY_USER }}"
# 只送两样:镜像包 + compose 文件,都落到 $REMOTE_DIR 根下(与 .env 同级,扁平布局)。
# 132 上的 .env 由你手工预放在 $REMOTE_DIR/.env,绝不覆盖。
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "mkdir -p $REMOTE_DIR"
sshpass -e scp -o StrictHostKeyChecking=no images.tar.gz "$U@$H:$REMOTE_DIR/"
sshpass -e scp -o StrictHostKeyChecking=no \
deploy/132-app/docker-compose.yml "$U@$H:$REMOTE_DIR/docker-compose.yml"
- name: 132 加载镜像并起服务
env:
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
run: |
H="${{ secrets.DEPLOY_HOST }}"
U="${{ secrets.DEPLOY_USER }}"
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\
cd $REMOTE_DIR && \
gunzip -c images.tar.gz | docker load && \
rm -f images.tar.gz && \
docker compose up -d --no-build && \
docker image prune -f"
- name: 健康检查(gateway :3000/healthz
env:
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
run: |
H="${{ secrets.DEPLOY_HOST }}"
U="${{ secrets.DEPLOY_USER }}"
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\
for i in \$(seq 1 20); do \
curl -sf http://127.0.0.1:3000/healthz && echo ' ✅ 健康' && exit 0; \
sleep 3; \
done; \
echo '❌ 健康检查失败'; \
cd $REMOTE_DIR && docker compose logs --tail 50 gateway; exit 1"
+60 -2
View File
@@ -16,6 +16,8 @@ concurrency:
jobs:
go:
name: Go · build + vet + test
# 本 CI 只在 GitHub 跑;Gitea(git.sundynix.cn)只跑 .gitea/workflows/deploy.yml,不误跑这套。
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -23,17 +25,71 @@ jobs:
with:
go-version: "1.25"
cache-dependency-path: "**/go.sum"
- name: build + vet + test4 模块;bus 用内嵌 NATS,无需外部服务)
- name: build + vet + test -race(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 ./...)
# -race:数据竞争一票否决(4 模块本地已验证 race-clean)。ubuntu runner 自带 gccCGO 可用。
(cd "$m" && go build ./... && go vet ./... && go test -race ./...)
echo "::endgroup::"
done
# golangci-lint:只卡「新问题」——存量约 42 处(未检 Close/死代码)单独消化,不拿存量红门。
# 仅在 PR 跑:dev→main 的 PR 是唯一合入口,据 base 算 diff;push 到 main 不重复跑。
lint:
name: Go · golangci-lint (new issues)
if: ${{ github.event_name == 'pull_request' && !contains(github.server_url, 'sundynix.cn') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module: [sundynix-shared, sundynix-gateway, sundynix-dispatcher, sundynix-mcp-go]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # only-new-issues 要据 base 分支算 diff,需完整历史
- uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: "**/go.sum"
- uses: golangci/golangci-lint-action@v6
with:
version: latest
working-directory: ${{ matrix.module }}
only-new-issues: true
# 安全扫描:govulncheck(依赖 CVE) advisory —— stdlib/nats CVE 要靠 toolchain/依赖升级,
# 不拿它红门,只做可见性;gitleaks(密钥泄漏)在 PR 上扫 diff,硬拦提交密钥。
security:
name: Security · govulncheck + gitleaks
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: "**/go.sum"
- name: govulncheckadvisory:报告不阻断)
continue-on-error: true
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
for m in sundynix-shared sundynix-gateway sundynix-dispatcher sundynix-mcp-go; do
echo "::group::govulncheck $m"
(cd "$m" && govulncheck ./...) || true
echo "::endgroup::"
done
- name: gitleaks(扫提交历史,命中即失败)
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
web:
name: Frontend · tsc + vitest
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -59,6 +115,7 @@ jobs:
# go:embed frontend/dist 要求先出前端产物,故先 npm build。
desktop:
name: Desktop · go build + test (macOS)
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
@@ -87,6 +144,7 @@ jobs:
py:
name: mcp-py · sandbox guard
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+2
View File
@@ -17,6 +17,8 @@ env:
jobs:
build:
name: 构建 ${{ matrix.label }}
# 只在 GitHub 跑;Gitea(git.sundynix.cn)不跑桌面端发布构建。
if: ${{ !contains(github.server_url, 'sundynix.cn') }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
+14
View File
@@ -0,0 +1,14 @@
# golangci-lint v2 配置。默认(standard)集:errcheck / govet / ineffassign / staticcheck / unused。
# 引入策略:CI 用 only-new-issues —— 新代码必须干净,存量约 42 处(多为未检 Close/死代码)
# 单独消化,不拿存量红整个门。本地全量跑:`golangci-lint run ./...`(在各模块目录)。
version: "2"
linters:
default: standard
exclusions:
rules:
# 测试里 defer x.Close() / 建数据的辅助调用不检返回值属常规,放过 errcheck。
- path: _test\.go
linters:
- errcheck
+886 -212
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -79,7 +79,7 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
其它=按序备用),无需新 admin UIModelConfig 加 Fallbacks 骑主配置下发(不改 bus 签名)
- [x] 主调用失败/超时自动切备:`failoverModel`model.ToolCallingChatModel)按序切,compose/ReAct/Chat
全路径透明;调用方取消不切;备用 api_key 一并解密
- [ ] 按任务类型/成本选模(可选,v1 跳过);熔断器联动(failover 在 model 层、熔断在编排层,互补未整合)
- [ ] 按任务类型/成本选模(可选,v1 跳过);熔断器联动(failover 在 model 层、熔断在编排层)—— ✅ 已整合,见 T4.E「熔断器接回 failover」
- 验收 ✅ liveactive=死 ollama 主 + deepseek 备 → 任务连主拒连→自动切 deepseek→4s 完成。
- v1 局限:Stream 仅建流同步报错时切(已回流 token 的中途失败不切)。
@@ -215,7 +215,6 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
### [x] T4.E 编排引擎边角 ✅(5/5 全清)
- [x] Map 节点错误传播 + 汇总 ✅ —— writeSection 返回 (body,err) 仅真·LLM 失败计错(预算/无模型是主动降级不算);writeSections 汇总失败数、失败项 Body 带可见标记;mapNode 全失败→置 b.fatalErr(判 failed 非静默 done-空)、部分失败→trace+流式告警。report handleReport 同步提示部分失败。3 单测(全失败/部分精确计数/mapNode 置 fatalErr)。
- [ ] 熔断器接回 failoverharness/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)。节点-工具映射校验需工具注册表,留后续。
+9 -3
View File
@@ -9,7 +9,7 @@
- [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 退役
- [x] **Phase C · 编排归一**:✅ 全图 `DSL→compose.Graph` 编译器(全节点 + branch + DAG 并行调度)+ callbacks→ExecEvent 归一,等价回归通过;灰度期已过、graph.go 退役、compose 为唯一引擎(**例外**:报告 intent 仍走独立 `handleReport` 流水线,刻意未 compose 化,理由见 Phase C 改动清单
- [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM / ✅ HITL 人工审批中断(审批节点暂停→waiting→批准回 running / 拒绝→rejectedNATS 决定回传 + AckWait 续租,全栈含 Studio 审批节点 + 运行抽屉批准条)/ ⬜ 多智能体(按场景)
**组件化补完(A**:检索 → `ragRetriever`(`components/retriever.Retriever``eino_components.go`);提示词 → `buildMessages` 改用 `prompt.FromMessages`+`MessagesPlaceholder`;工具 → `mcpTool`(`InvokableTool`,模型自主调用)。至此终态架构 8 层中 模型/工具/检索/提示词/编排/智能体(单)/可观测 均已 Eino 组件化;剩 人机交互(中断恢复,按场景)。
@@ -150,7 +150,13 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open
- `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(章节并行 + 汇聚天然契合)。
- ~~`report.go`:报告多步流水线改用 compose(章节并行 + 汇聚天然契合)。~~
**实际未迁,刻意保留为独立路径(诚实修正)**`orchestrator.go``intent==IntentReport`
分流到 `handleReport`,用自研 `writeSections`WaitGroup + 信号量并发 + 部分失败可见标记)做
规划→分章并行→渲染,**不经 compose.Graph**。原因:① 报告是固定形状的专用流水线(规划-map-渲染),
并行能力已由 `writeSections` 拿到,compose 化不带来用户可见收益;② 硬迁移会拿一个有单测背书、
已 live 验证的稳定功能去冒回归风险。故报告是 compose 之外的第二条编排路径,通用 DSL 图仍全走 compose。
真要归一(去掉第二条路径)另立任务评估,非本阶段承诺。
- 检索节点 → `components/retriever`(包 mcp-go RAG);提示词 → `components/prompt.ChatTemplate`
- **对齐策略**compose 版与 graph.go 版**并存**,用 `graph_test.go`/`integration_test.go` 做等价回归;全绿且灰度通过后,`graph.go` 才退役。
@@ -165,7 +171,7 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open
主题:把"执行"从一次性 DAG 升级为**可持久化、可恢复的状态机**。三件事同一条线,一起做。
- **任务生命周期 FSM** ✅ 已完成:`submitted → running → done / failed / timeout` 显式状态机。
- dispatcher 经新主题 `sundynix.tasks.status` 回写(`TaskStatusEvent`):进入执行→running、收尾→done/failed、整体超时上限 `taskExecTimeout=3min`→timeout;网关 `SubscribeTaskStatus` 落 PG`Task.Status/Detail`)。
- dispatcher 经新主题 `sundynix.status.task` 回写(`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 超时兜底——根治"卡运行中看不出来"。
- 桌面端轮询接线仍可补(当前后端 + 端点已就绪)。
+95
View File
@@ -0,0 +1,95 @@
# sundynix-agentix · JARVIS 大脑中枢设计文档
> 版本:2026-07-24
> 定位:把 JARVIS 从「任务系统的语音皮肤」升级为「平台的常驻大脑中枢」——编排、报告、知识库、本地文件、App 界面都只是它手里的工具(Claw/OpenClaw 式全局控制感)。
> 配套文档:`LOCAL_AGENT_DESIGN.md`(本地的手,双平面)、`VOICE_DESIGN.md`(耳朵和嘴)、`ARCHITECTURE_DESIGN.md`(总览)
---
## 0. 一句话结论
> **角色反转:把编排引擎从"执行主体"降级为 JARVIS 的一个工具(tool),把 JARVIS 从"任务的语音入口"升级为持有全部工具的常驻 ReAct 大脑。**
>
> 这是接线工程,不是重构工程——推理循环(react_agent.go)、语音通道、会话记忆、人格、工具协议、HITL 全部现成,缺的只有三条线:平台工具族、客户端动作通道、主动播报器。
## 1. 现状 vs 目标
**现状**:用户说话 → 每句话变一个 task → 编排引擎执行 → 念结果。JARVIS 从属于任务系统。
**目标**JARVIS = 常驻中枢 agent。你说「看看部署状态」,它自己决定是查任务、跑命令、还是调编排;任务跑完它主动开口告诉你。任务系统从属于它。
### 已就绪的器官(盘点)
| 中枢需要的 | 已有的 | 位置 |
|---|---|---|
| 推理循环 | ReAct 循环 + `list_tools` 动态工具发现(加工具零改循环) | `dispatcher/internal/eino/react_agent.go` |
| 耳朵/嘴 | 语音 WS 全双工 + 连续对话(VAD 断句/自动重听) | `gateway/internal/handler/voice.go` |
| 短期记忆 | sessionID → fetchHistory/appendHistory 闭环 | orchestrator.go |
| 人格 | 每用户 UserJarvis(名字/人设/自带豆包) | `store/user_jarvis.go` |
| 工具协议 | 注册表 + `agent_exposed` + inject 参数 | `mcp-go/internal/mcp/gateway.go` |
| 提交关卡 | `preflightCore`/`launchCore`(无 gin,语音已复用) | `handler/task_handler.go` |
| 安全闸 | HITL 审批 + 预算护栏 + 审计 | 全栈 |
| 本地的手 | 双平面设计(档 A/B) | `LOCAL_AGENT_DESIGN.md` |
## 2. 三条缺的线
### 2.1 平台工具族 `platform_*`(让 JARVIS 操作平台本身)
新增第三个工具提供方:**gateway 自己**。理由:平台操作的权威(提交关卡、归属校验、计费)都在 gateway,工具就该长在权威所在地——而不是让 mcp-go 绕一圈回来调 gateway。
- 协议:`contract``SubjectToolsPlatform = "sundynix.tools.platform"`+All/Queue/ToolSubjectPlatform),与 go/py 同族。
- gateway 用 `bus.ServeTool` 起一个迷你注册表(照抄 mcp-go 的 toolDef 模式:唯一事实源,dispatch 与 list_tools 共用)。
- dispatcher `agentTools` 多发现一路:`discoverTools(contract.ToolSubjectPlatform, …)`——**一行**。
**首批工具**(全部 inject `user_id`,严格归属校验):
| 工具 | 说明 |
|---|---|
| `platform_recent_tasks` | 最近任务列表(状态/主题/耗时),答"我刚才那些任务怎么样了" |
| `platform_task_status(task_id)` | 单任务状态 + 输出摘要(TaskOwner 校验归属,别人的任务不可见) |
| `platform_gen_report(topic, kb?)` | 提交报告任务——**必须走 preflightCore/launchCore 同一关卡**(预算/积分/落库),返回 task_id |
后续扩展(同一注册表加条目即可):`platform_run_orchestration(graph_id)`(待编排图有服务端存储后)、`platform_search_kb``platform_usage_today`
### 2.2 客户端动作通道(让 JARVIS 控制界面与本机)
语音 WS 加一类下行消息:`ServerMsg{type:"action", action:"navigate", view:"runs", task_id:…}`
- 客户端 VoiceClient 加 `onAction` 回调 → App 层 dispatcher 执行(切页面/打开面板/聚焦任务)。HUD 从显示器变驾驶舱。
- **`local.*`(读文件/跑命令)走同一条通道**下发到 Wails Go host 执行——即 `LOCAL_AGENT_DESIGN.md` 档 A 的落点,两个设计在此汇合。
- 安全:动作白名单(navigate 等 UI 动作低危先行);`local.*` 按该文档 §8 的门禁(cwd 沙箱/HITL/仅桌面端)。
### 2.3 主动播报器(中枢与工具的本质区别)
它会先开口:任务跑完/失败、部署红了 → JARVIS 主动说"先生,报告好了"。
- 零件全齐:JetStream 有任务终态事件(status 回写已持久化)、语音 WS 常连、TTS 现成。
- 新增一个**值班推送器**:订阅当前语音会话用户的任务终态 → 判断值不值得开口(本会话触发的任务优先;免打扰规则)→ 文本 → 现有 TTS 通道播报 + 对话流插一条。
- 与连续对话状态机合流:播报只在 ready/listening 空闲时插入,说话中排队,别抢麦。
## 3. 安全边界(权力越大越要收紧)
1. 每次工具调用过 ToolPolicy;破坏性操作(exec/写文件/会烧钱的编排提交)过 HITL。
2. `platform_*` 一律 inject user_id + 服务端归属校验,模型不可指定别人的资源。
3. `platform_gen_report` 等提交类工具走 preflightCore(预算/积分硬拦截),JARVIS 不能绕过计费花钱。
4. `local.*` 只对有在线本地 runner 的桌面会话开放(见 LOCAL_AGENT_DESIGN §8)。
5. 全部动作落审计/轨迹(mcpTool 已自动落 ExecEvent span)。
## 4. 落地阶段
| 阶段 | 内容 | 改动面 | 状态 |
|---|---|---|---|
| **P1 平台工具族** | contract 加 platform subjectgateway 迷你工具注册表(recent_tasks/task_status/gen_report);dispatcher 多发现一路 | shared 一处 / gateway 新文件 / dispatcher 一行 | **✅ 2026-07-24 live 验收全过**(真实数据/派报告走关卡/越权拒绝/积分硬拦截实测) |
| **P2 动作通道** | ServerMsg action + 客户端 onAction + navigate 白名单(platform_open_view → NATS 语音事件 → WS | protocol/voice.ts/App | **✅ 2026-07-24 live 验通**(“打开运行页”→界面真切) |
| **P3 主动播报** | 任务终态 → announce 语音事件 → 对话流 + TTS(正朗读则排队不抢麦;prev 比对挡重投重播) | main 状态消费 + voice_event.go | **✅ 2026-07-24 live 验通**(报告跑完 JARVIS 主动开口) |
| **P4 本地的手** | LOCAL_AGENT_DESIGN 档 A`local_list_dir/read_file`platform 注册表)→ NATS `local.exec.<uid>` → gateway runner WS → 桌面 Go host 沙箱执行;桌面设置「本地文件访问」显式开关;`cmd/localsim` 联调工具 | gateway 2 新文件 / desktop runner+设置 | **✅ 2026-07-24 live 验通**(真读文件+离线降级+沙箱逃逸单测全拦) |
| **P4.2 能动的手** | `local_write_file` / `local_exec`:桌面端**原生确认框逐次审批**(默认拒绝/60s 超时即拒)+ 硬黑名单(删库/提权/管道下载执行/写系统路径/装开机项/摸凭据,点同意也不执行)+ 独立开关;工具自报 `timeout_sec` 突破 dispatcher 默认 3s(审批要等人) | desktop `localexec.go` / platform 注册表 / dispatcher 超时机制 | **✅ 2026-07-25 live 验通**(写脚本+执行链式两步,文件真落磁盘;黑名单 20 条单测全拦) |
| **P4.3 调度** | `sundynix_schedule` 表 + leader 锁 ticker30s 扫)+ `platform_schedule_create/list/cancel`;存自然语言指令,到点走语音同一条关卡执行并主动播报;先推进后提交防重复烧钱,停机错过的**不补跑** | store/schedule.go、handler/schedule_tick.go | **✅ 2026-07-25 live 验通**(到点自动触发→自主调 local_exec→真跑命令→出结果) |
| **P5 常驻会话** | 每句一 task → 长驻 companion session(现靠 history 串联已够用,不急) | dispatcher | 暂缓 |
每步独立可用:P1 做完立刻能"嘴遥控平台"(问任务、派报告)。
## 5. P1 验收
- 语音问「我最近的任务都什么状态」→ JARVIS 调 `platform_recent_tasks` 报出真实列表。
- 语音说「帮我生成一份 XX 报告」→ JARVIS 调 `platform_gen_report` → 回 task_id →「已开跑」;任务在运行页可见、计费正常扣。
- 越权测试:构造 task_id 查他人任务 → 拒绝。
+269
View File
@@ -0,0 +1,269 @@
# sundynix-agentix · 本地 Agent 执行设计文档
> 版本:2026-07-23
> 定位:在**保留中心化治理**的前提下,让 Agent 能像 Claude Code 那样**操作用户本机的文件、执行命令**——把"大脑/治理"留服务端,把"手/执行"下放到客户端。
> 配套文档:`ARCHITECTURE_DESIGN.md`(架构总览)、`EINO_ADOPTION.md`(编排引擎)、`VOICE_DESIGN.md`(语音)、`SAAS_DESIGN.md`(多租户治理)、`DEPTH_ROADMAP.md`(路线图)
---
## 0. 一句话结论
> **中心化 ≠ 所有东西都在服务端执行。中心化 = 单一的控制/治理/观测平面。**
>
> 正确形态是**双平面**:**执行平面**下沉到客户端(拿 Claude Code 的手感),**控制平面**留服务端(守住计费/观测/记忆/策略)。这不是把已否决的"编排引擎下沉客户端"翻案——那否的是 DAG 工作流引擎;这里新增的是**另一个产品面**:一个 Claude-Code 式的本地操作 Agent。两者并存。
---
## 1. 目标与范围
### 1.1 要做什么
让用户在桌面端说/输入「把这个目录里的 CSV 都转成 JSON」「读一下这个日志找出报错」「跑一下测试」,Agent 就能**在用户自己的机器上**读写文件、执行命令,边干边把过程流式展示,且全程仍受服务端的预算/审批/审计管控。
```
用户意图 → Agent 决策(LLM) → 本地工具执行(读/写/命令) → 观察 → 再决策 → … → 结果
↑ 经服务端 LLM 代理(记忆/计费/护栏) ↑ 在本机瞬时执行(无额外过网)
```
### 1.2 核心原则
1. **执行在本地,控制在中心**——工具在本机跑(手感),LLM 访问/记忆/计费/观测/策略全经服务端咽喉。
2. **复用已有接口**——`ToolCaller``ToolCallingModel`、NATS req/reply、HITL 审批、prompt 注册表、预算护栏、secrets 托管,尽量不重造。
3. **安全是成败手,不是可选项**——LLM 驱动在用户机器上跑命令是最危险的面,默认收紧:路径沙箱 + 命令白名单 + HITL 审批 + 仅桌面端可用。
4. **可降级**——本地 runner 不在线时,明确报"本地执行器不可用",而非卡死;只读能力尽量保留。
### 1.3 不做什么(本期)
- ❌ 客户端本地跑大模型(推理一律经服务端 LLM 代理出去,这是中心化的支点)。
- ❌ 让 Web 端 / 共享工作区操作"别人的机器"(`local.*` 工具仅对有在线本地 runner 的桌面会话开放)。
- ❌ 客户端离线自治(无网即无推理,换取治理不失控)。
- ❌ 把 DAG 工作流引擎搬到客户端(那是另一条产品线,维持服务端)。
---
## 2. 与 Claude Code 的对比
### 2.1 先破除一个误解
Claude Code 的"手感"**不来自本地跑大模型**——它的 LLM 也在远端(Anthropic API),每一轮决策同样要过网。手感来自三点:
1. **紧凑的本地工具循环**:读→改→跑→看输出→再改,一秒好几轮,**内环不过网**。
2. **全程流式**:思考、工具输出实时可见。
3. **工作目录内的自主感**:锁定一个 cwd,在里面自由操作。
**所以"本地 vs 中心化"是假二选一。** 真正的问题是两个平面各放哪。而 Claude Code 自己就是混合的:**执行本地 + LLM 远端**。
### 2.2 逐项对比
| 维度 | Claude Code | sundynix-agentix(现状) | 本设计(双平面) |
|---|---|---|---|
| Agent 循环位置 | 本地 CLI 进程 | 服务端 dispatcher/Eino | **档 B:客户端 Go host** / 档 A:仍服务端 |
| LLM 访问 | 直连 Anthropic(用户持 key | dispatcher 直连 LLM 池 | **经 gateway LLM 代理**key 服务端托管) |
| 工具执行 | 本地、瞬时 | NATS→mcp-go(服务端) | **本地 runner,瞬时** |
| 每工具是否过网 | 否(内环本地) | 是(server↔mcp | **档 B 否 / 档 A 一跳(server↔client** |
| 多租户/隔离 | ❌ 无 | ✅ 已建 | ✅ 保留 |
| 计费/计量 | ❌ 无 | ✅ 积分/成本/rollup | ✅ 经 LLM 代理 + 事件上抛 |
| 集中可观测 | ❌ 本地日志 | ✅ OTel 全链路 | ✅ 客户端事件上抛服务端 |
| Prompt/模型治理 | 本地配置 | ✅ 注册表 + 热切换 | ✅ 保留 |
| 权限/审批 | 本地 permission 模式 | ✅ HITL 审批节点 | ✅ 策略下推 + 本地快审批 |
| 工作目录沙箱 | ✅ cwd | 无(服务端无本地盘概念) | ✅ 新增本地 cwd 沙箱 |
| 团队/共享知识 | ❌ | ✅ Space/KB 共享 | ✅ 保留 |
| 离线可用 | 部分(无 LLM 则不可) | ❌ | ❌(刻意,换治理) |
### 2.3 结论:我们要成为的东西
> **"Claude Code + 治理层"**——把 Claude Code 的本地执行手感,装进一个多租户、可计费、可观测、可审批的受管平台里。这正是 Claude Code **刻意不做**的部分,也是我们已投入建设的护城河所在。
---
## 3. 现有架构支持度(为什么不用推倒重来)
`sundynix-dispatcher/internal/eino/react_agent.go` 可知,agent 循环有两个关键事实,决定了本方案**是"换实现 + 加代理",不是"重写"**
1. **循环本体是 Eino 标准件 `react.NewAgent`**,可移植。它只吃两个注入:`ToolCallingModel`LLM+ 一组 `tool.BaseTool`(工具)。
2. **这两样都已在干净接口后面**
- LLM`o.agentPool(b).ToolCallingModel()` —— 池抽象,可换成"gateway LLM 代理客户端"。
- 工具:`mcpTool.InvokableRun``m.caller.CallTool(ctx, subject, ToolCall)``caller`**`ToolCaller` 接口**`subject` 是**可替换路由函数**;工具还是 `list_tools` **动态发现**,加工具零改循环。
**最难的两块(LLM 访问、工具调用)已被提前解耦。** 这是本方案成本可控的根本原因。
---
## 3.5 客户端可行性:Go + Wails 是最佳形态
> 直接结论:**支持,而且 Go+Wails 比 Electron/Tauri 都强。** 因为客户端与服务端 agent 是**同一种语言(Go)**——档 B 不是"用另一门语言重写循环",而是**直接共享同一份 Eino 循环代码**。
### 3.5.1 同语言 = 消掉最贵的成本
| 客户端技术 | 档 B 要付的代价 |
|---|---|
| ElectronJS/TS | 把 `react.NewAgent`/`mcpTool`/`ToolCaller` 全用 TS 重写 |
| TauriRust | 用 Rust 重写 |
| **Go + Wails(我们)** | **`import` 即用**dispatcher 与 desktop 共用同一个 `cloudwego/eino`、同一套 `contract` 类型、同一份循环逻辑 |
这把 §7 四个解耦点里最贵的"重写"直接消掉,只剩"搬家"。
### 3.5.2 界线:agent 跑在 Go host,不在 WebView
**Wails app = Go host(原生进程,无沙箱)+ WKWebView(只渲染 UI,经绑定调 Go)。** agent 循环跑在 Go host——所以 **WebView 的沙箱限制与执行完全无关**。逐块看,每一块都在 Go host 里正常跑:
| 部件 | Wails Go host | 说明 |
|---|---|---|
| Eino `react.NewAgent` 循环 | ✅ | 纯 Go 库;host 是正常 Go 进程,`goroutine`/`context`/`StreamReader` 全正常 |
| 本地工具 read/write/bash/grep | ✅ | `os.ReadFile`/`os.WriteFile`/`exec.CommandContext` 以用户 OS 权限跑——正是 Wails 甩纯 Web 应用之处 |
| LLM 代理客户端 | ✅ | `net/http` 打 gateway;实现成 Eino `ToolCallingChatModel` 接口,对循环透明 |
| 流式输出到 UI | ✅ | Go host 用 Wails 运行时事件(`EventsEmit`)推前端渲染;同时自持连接上抛服务端 |
| 长连接/后台 goroutine | ✅ | host 生命周期独立于 webview,启动时起 goroutine 持 gateway 连接、注册 runner |
### 3.5.3 唯一真实的坑:模块依赖接线(非能力问题)
1. **desktop 是独立 module、不在 go.work 里**(见 `wails3-migration``go-workspace-genproto` 坑)。
2. **Go `internal/` 包不能跨模块 import** → dispatcher 里那份循环**必须挪到非 internal 的共享位置**`sundynix-shared/agent` 或新模块)才能被 desktop 引。即 §7 解耦点 #3
3. `sundynix-desktop/go.mod` 要 require `sundynix-shared`,构建走 `GOWORK=off`,靠 **go.mod 依赖(replace 指向本地 / Gitea module proxy**解析,不能靠 go.work。**盯紧已踩过的 genproto/indirect-require 版本冲突。**
4. 次要:Eino 传递依赖不少,加进 desktop 会让**二进制变大、构建变慢**——可接受,但心里有数。
> 一句话:**能力上零障碍,Go+Wails 天然支持;成本上因同语言,档 B 从"重写"降级成"把共享单元搬进 sundynix-shared + 接好 go.mod 依赖"。** 你趟过的 go.work/genproto 坑,恰好就是这次搬家要绕的雷。
---
## 4. 双平面架构
```
┌────────────────────── 控制平面(服务端,中心化治理)──────────────────────┐
│ gateway │
│ ├─ LLM 代理端点 ← 咽喉:注入记忆/prompt、记用量/计费、护栏、OTel、key托管 │
│ ├─ 策略下推 → ToolPolicy / 白名单 / 预算令牌桶(复用 config 下推范式)│
│ ├─ HITL 策略定义 → 哪些操作必须审批(人工/组织级流程仍服务端 pause) │
│ └─ 事件汇聚 ← 客户端上抛的 token/trace/usage,进 NATS/JetStream │
│ dispatcher/EinoDAG 工作流编排(另一产品面,维持不变) │
└─────────────────────────────────────────────────────────────────────────────┘
▲ LLM 调用(每轮决策) ▲ 事件上抛(异步)
│ │
┌────────────────────── 执行平面(客户端 Wails Go host)──────────────────────┐
│ 本地 Agent 运行时(档 B:react 循环搬到这里) │
│ ├─ ToolCallingModel = gateway LLM 代理客户端(实现 Eino ChatModel 接口) │
│ ├─ 本地工具族 local.*read/write/edit/bash/grep/globos / os/exec
│ ├─ cwd 沙箱:所有操作锁在用户选的工作目录根下 │
│ ├─ 本地 HITL:破坏性操作快速弹窗审批(策略由服务端下推) │
│ └─ 策略缓存 + 预算令牌桶:内环本地判定,不阻塞网络 │
└─────────────────────────────────────────────────────────────────────────────┘
```
**关键点:只有"LLM 调用"离开本机(Claude Code 也一样),工具执行全在本地。** 手感回来,治理不漏。
---
## 5. 两档演进路线
> 建议**先做档 A**,用真实用例量延迟;**真的卡手了再上档 B**。你的场景(助手操作几个文件/跑个命令)工具调用频率低,档 A 那点往返大概率无感;Claude-Code 内环之痛只在"读几十文件、反复跑测试"才暴露。
### 档 A:只下放工具执行,循环留服务端 —— **成本低(几天级)**
因为 `ToolCaller` 已是接口 + 可替换 subject,只需:
1. 新增 `local.*` 工具族,`CallTool` 的 subject 路由到 **NATS→gateway→客户端 runner**(而非 →mcp-go)。
2. 客户端 Go host 跑一个"迷你 mcp"执行器,登录后注册到现有连接,收调用就 `os.ReadFile`/`exec.CommandContext`,结果原路回。
3. gateway 做 subject 桥接:把发给某用户 runner 的 NATS 请求,转到该 runner 的连接,等 reply。
**循环、图、LLM、记忆、计费、追踪一行不动。** 就是给工具层多插一个执行目标。
- ✅ 立刻拿到"操作本地"能力,治理全在。
- ❌ 每工具过一次网(server↔client),无 Claude Code 紧凑手感。
### 档 B:把 agent 循环搬到客户端 —— **成本中等(解耦+搬家,非重构)**
拦路的不是架构,是循环今天**和服务端世界缠在一起**。需拆的**四个耦合点**(见 §7)。搬完后:
- ✅ 内环本地、瞬时,真正的 Claude Code 手感。
- ✅ 控制平面(LLM 代理 + 策略下推 + 事件上抛)守住中心化。
---
## 6. 控制平面:三个支点
### 6.1 LLM 代理(咽喉,档 B 的地基)
- gateway 出一个 chat/completions 风格端点;客户端每次推理经它出去。
- 挂载:记忆注入(复用现有 recall)、prompt 版本化(注册表)、用量计量(计费/积分)、护栏/忠实度、OTel span、**provider key 永不下发客户端**。
- 客户端把该端点实现成一个 Eino `ToolCallingChatModel`,喂给 `react.NewAgent`——对循环透明。
- 净新增,但可复用 `sundynix-dispatcher/internal/llm` 的 client 逻辑。
### 6.2 策略下推(治理集中,但别让内环等网络)
复用现有 `PublishConfigUpdated` / `RequestConfig` 下推范式:
- **ToolPolicy / 白名单**:会话开始下推客户端,**内环本地判定**,异步回报;服务端可 push 撤销/调整。
- **预算令牌桶**:服务端发额度给客户端,本地花、异步续、花光**本地硬停**,不必每调用回问。
- **HITL**:常见破坏性操作走**本地快速审批弹窗**(不阻塞、不回服务端 pause);"哪些必须审批"由服务端策略下推,每个决策**记回服务端审计**;只有"组织级人工批"流程才走现有服务端 pause→waiting。
### 6.3 事件上抛(观测/计费不断)
- 客户端把 trace/token/usage 事件经 NATS(或现有 WS)上抛,进 JetStream 持久(计费类一律持久 + 幂等,见 `nats-durability` 约定)。
- 服务端观测面板照常看到"agent 读了哪个文件、跑了什么命令、花了多少 token"。
---
## 7. 档 B 的四个解耦点(真实工作量所在)
| # | 耦合点 | 现状 | 要做的解耦 | 性质 |
|---|---|---|---|---|
| 1 | 循环黏服务端专属件 | `o.sink.PublishToken`NATS 出 token)、`execTracer`span)、`harness.BudgetFrom`(预算)、`board`/`recordAgentOutput`(喂图下游) | 把循环抽成**吃接口的独立单元**token sink / tracer / budget 改注入,客户端本地实现 + 事件上抛。redactor 已是纯函数可直接搬 | 机械解耦 |
| 2 | LLM 代理净新增 | 仅 dispatcher 直连 LLM 池、持 key | gateway 出 chat 端点,客户端实现成 Eino ChatModel | 新增(可复用 llm client |
| 3 | 打包/模块边界 | 桌面端是**独立 module、不在 go.work**(见 `go-workspace-genproto``wails3-migration` 坑) | 把可复用 agent 单元挪进 `sundynix-shared`(或新模块),desktop 与 dispatcher 共享 import | 搬家(注意 go.work 依赖坑) |
| 4 | 事件回传 | 服务端内部 NATS | 客户端 → 服务端事件通道(复用 WS 或 NATS)+ 幂等落库 | 新增(复用现有总线) |
**没有一条是"架构不支持"或"推倒重来"——全是解耦、搬家、加一个代理端点。**
---
## 8. 安全模型(最重要)
LLM 驱动在用户机器上跑命令 = 最高危面。默认全部收紧,宁可先严后松:
1. **路径沙箱**:用户显式选一个工作目录根;所有 `local.*` 只能在根下操作,越界直接拒(软链接/`..` 逃逸也拦)。
2. **命令白名单/黑名单**`rm -rf``curl|sh`、写系统路径、`sudo` 等默认拦;exec 默认需审批。
3. **HITL 审批**:写文件/删除/执行命令默认走审批;可配"这次允许 / 本会话允许 / 加白名单"。
4. **仅桌面端可用**`local.*` 必须 gate 在"有在线本地 runner 的桌面会话";Web/共享工作区不给(否则语义即"让 A 操作 B 的机器",灾难)。写进 ToolPolicy。
5. **归属校验**:本地 runner 只服务自己登录的用户,注册带 `user_id` + 设备指纹,服务端严格校验目标连接归属,绝不串。
6. **审计**:每次本地文件写/命令执行,无论是否需审批,都落审计事件回服务端。
7. **超时 + 资源限制**:命令有超时、输出大小上限,防失控进程/刷屏。
---
## 9. 可靠性 / 寻址
- **连接注册表**:服务端维护 `user_id/device → runner 连接`;任务提交时绑定"发起它的那台 runner"。
- **多设备歧义**:一个用户开多台桌面端 → 任务必须绑定发起端,不能随便选。
- **断线兜底**:客户端中途断线,工具调用超时 + 明确报"本地执行器不在线",任务优雅失败而非卡死。
- **降级**:runner 不在线时,纯服务端能力(问答/检索/报告)不受影响。
---
## 10. 落地阶段(从现有往上加)
| 阶段 | 内容 | 依赖 | 产出 |
|---|---|---|---|
| **P0** | gateway **LLM 代理端点**:客户端推理经它,挂计量/追踪/记忆注入/护栏 | 复用 `internal/llm` | 中心化咽喉立住 |
| **P1(档 A** | `local.*` **只读**工具(list/read+ 客户端 runner 注册 + subject 桥接 + cwd 沙箱 | `ToolCaller` 接口 | **✅ 2026-07-24 落地并 live 验通**(实现见 JARVIS_BRAIN_DESIGN P4gateway `local_runner.go` + desktop `localrunner.go`;沙箱逃逸单测全拦;离线优雅降级) |
| **P2(档 A** | write/edit/bash + **本地 HITL** + ToolPolicy 下推 + 审计 | HITL 节点、config 下推 | 可操作本地,治理收紧 |
| **P3(档 B** | 按 §7 抽循环 → 搬 `sundynix-shared` → 客户端跑 react 循环 + 预算令牌桶 + 事件上抛 | 四个解耦点 | Claude Code 手感 |
| **P4** | 桥接服务端图编排(Task 式子 agent)、会话 resume、CLAUDE.md 式本地项目记忆 | P3 | 两产品面打通 |
**决策门**:P1 跑通后,用真实用例量 server↔client 单次往返延迟。若无感 → 停在档 A 收益已足;若明显卡手(高频内环)→ 才付 P3 的钱。
---
## 11. 风险与取舍
- **产品定位决定 ROI**:档 B 只有当产品是"完整编码/操作 agent 在我仓库里连续折腾"才值;若是"助手偶尔干几件本地事",档 A 足矣。**先确认定位再决定要不要 P3。**
- **模块边界坑**desktop 不在 go.work,搬共享 agent 单元时小心 `genproto`/indirect require 冲突(见 `go-workspace-genproto`)。
- **安全面扩大**:一旦能跑命令,任何提示注入/越权都可能落到用户机器。§8 的门禁是硬要求,不是可选。
- **老引擎兼容**:客户端 Go 运行时无浏览器引擎版本问题;但若本地 agent 的 UI 复用现有前端,注意 WKWebView 版本坑(另见前端白屏排查)。
---
## 12. 待定问题(需产品确认)
1. 产品定位:受管的**团队/企业操作平台** 还是 **更强的个人本地助手**?(决定要不要档 B
2. 本地 runner 的连接:直连 NATS 还是经 gateway WS?(出网策略 + 部署拓扑约束)
3. exec 的默认收紧程度:默认全审批,还是白名单内免审批?
4. 是否需要"CLAUDE.md 式"本地项目记忆文件,与服务端记忆如何合并?
+13 -8
View File
@@ -43,18 +43,23 @@
| **微信支付 Native**(P5.2) | 企业主体 + 商户号 | 二维码(code_url | ✅ 已拍板 |
| 支付宝 / Stripe | — | — | 不做,留 adapter 位 |
Adapter 接口(`internal/payment/channel.go`):
Adapter 接口(`internal/payment/channel.go`**已落地**
```go
type Channel interface {
Name() string
// CreatePay 依据订单生成支付凭据(二维码内容/跳转 URL/兑换码提示
CreatePay(ctx, o *PaymentOrder) (PayIntent, error)
// VerifyCallback 验签并解析回调 → (orderID, channelTxnID, paidAmountFen, error)
VerifyCallback(req *http.Request) (CallbackResult, error)
// QueryOrder 主动查单(掉单补偿用
QueryOrder(ctx, orderID string) (OrderStatus, error)
Name() string // 与 store.Channel*wechat/alipay/stripe)一致,按订单渠道路由
// CreatePay 依据订单生成支付凭据(微信 Native 出 code_url;其它渠道可出跳转 URL
CreatePay(ctx, orderID, description string, amountFen int64) (PayIntent, error)
// QueryOrder 主动查单(前端轮询确认 + 掉单补偿共用)
QueryOrder(ctx, orderID string) (PayResult, error)
// VerifyCallback 验签并解析回调 → 统一 PayResult(验签失败必返 error
VerifyCallback(req *http.Request) (PayResult, error)
}
```
实现说明(2026-07-18):入参用基本类型(orderID/amountFen)而非 `*store.PaymentOrder`,让
`payment` 包不反依赖 `store`,渠道适配层保持纯粹。`Manager` 是渠道注册表(`Get(name)`/`Available()`/
`Status(name)`/`ReloadWechat`),回调路由收敛为 `POST /billing/callback/:channel` 按名路由;
查单/补偿据 `order.Channel` 路由。微信是当前唯一真渠道,支付宝/Stripe = 实现本接口 + 注册即接入。
唯一未泛化的是回调 **ack 应答格式**(现为微信 `{code:SUCCESS}`,其它渠道接入时按渠道分应答)。
## 3b. 两层汇率,各管各的(用户 2026-07-17 强调:积分↔token 必须可动态调)
+4
View File
@@ -0,0 +1,4 @@
# 基础设施机 128 的库密码。cp .env.example .env 后填写。
# 这两个密码必须与 132 应用机 .env 里的 POSTGRES_PASSWORD / NEO4J_PASSWORD 一致。
POSTGRES_PASSWORD=change-me-postgres
NEO4J_PASSWORD=change-me-neo4j
+134
View File
@@ -0,0 +1,134 @@
# sundynix-agentix · 基础设施机 192.168.100.128
#
# cd deploy/128-infra && cp .env.example .env # 填库密码
# docker compose up -d
#
# 说明:
# - 对象存储 MinIO 已单独部署在 192.168.100.126,本机不含业务 MinIO
# 这里的 milvus-minio 仅供 Milvus 自己存段文件(内部网络,不对外)。
# - 端口对局域网暴露(0.0.0.0),供应用机 192.168.100.132 连接。
# ⚠️ Redis / Milvus / NATS 无认证,务必用防火墙只放行 132/126 网段,勿对公网开放。
# - NATS 三节点集群(JetStream Raft):单机多容器,各自独立卷;对 132 暴露 4222/4223/4224。
# 应用侧设 NATS_STREAM_REPLICAS=3durable 流才有 quorum 容错。
# - 全部卷持久化 + restart:unless-stopped,容器损坏/重启不丢数据。
services:
# ---------- NATS 3 节点集群 ----------
nats1:
image: nats:2-alpine
command: ["-c", "/etc/nats/nats-server.conf"]
restart: unless-stopped
volumes:
- ../nats-cluster/nats1.conf:/etc/nats/nats-server.conf:ro
- nats1_data:/data/jetstream
ports: ["4222:4222", "8222:8222"] # 客户端 + 监控
nats2:
image: nats:2-alpine
command: ["-c", "/etc/nats/nats-server.conf"]
restart: unless-stopped
volumes:
- ../nats-cluster/nats2.conf:/etc/nats/nats-server.conf:ro
- nats2_data:/data/jetstream
ports: ["4223:4222"]
nats3:
image: nats:2-alpine
command: ["-c", "/etc/nats/nats-server.conf"]
restart: unless-stopped
volumes:
- ../nats-cluster/nats3.conf:/etc/nats/nats-server.conf:ro
- nats3_data:/data/jetstream
ports: ["4224:4222"]
# ---------- 关系库 / 缓存 ----------
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: sundynix
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?需在 .env 设置}
POSTGRES_DB: sundynix
volumes: ["pg_data:/var/lib/postgresql/data"]
ports: ["5432:5432"]
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"]
ports: ["6379:6379"]
# ---------- 向量库 Milvus(自带 etcd + 自带内部 minio,不复用 126----------
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"]
milvus-minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
restart: unless-stopped
environment:
MINIO_ACCESS_KEY: minioadmin # Milvus 段存储专用,内部网络不对外
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data
volumes: ["milvus_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: milvus-minio:9000
volumes: ["milvus_data:/var/lib/milvus"]
ports: ["19530:19530"] # 供 132 的 mcp-go 连接
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 15s
timeout: 10s
retries: 10
depends_on: [milvus-etcd, milvus-minio]
# ---------- 图谱库 ----------
neo4j:
image: neo4j:5-community
restart: unless-stopped
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?需在 .env 设置}
volumes: ["neo4j_data:/data"]
ports: ["7687:7687", "7474:7474"] # bolt + 浏览器
# ---------- 链路追踪(可选,不需要可删)----------
jaeger:
image: jaegertracing/all-in-one:1.60
restart: unless-stopped
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports: ["16686:16686", "4318:4318"] # UI + OTLP/HTTP 接收
volumes:
nats1_data:
nats2_data:
nats3_data:
pg_data:
redis_data:
milvus_etcd:
milvus_minio_data:
milvus_data:
neo4j_data:
+26
View File
@@ -0,0 +1,26 @@
# 应用机 132 的密钥与凭据。cp .env.example .env 后填写(.env 不进 git)。
# 三个应用服务共享 SUNDYNIX_SECRET_KEY,必须完全一致(否则跨服务密文解不开)。
# —— 引导密钥(生成:openssl rand -base64 32)——
SUNDYNIX_SECRET_KEY=change-me-secret-32
JWT_SECRET=change-me-jwt-32
# —— 库密码(须与 128-infra/.env 一致)——
POSTGRES_PASSWORD=change-me-postgres
NEO4J_PASSWORD=change-me-neo4j
# —— 对象存储(126 上的 MinIO 凭据)——
MINIO_ACCESS_KEY=change-me-minio-access
MINIO_SECRET_KEY=change-me-minio-secret
MINIO_BUCKET=sundynix-docs
# —— 管理员白名单:首次注册后拿 user.id 填这里,重启 gateway 生效(逗号分隔多个)——
ADMIN_USER_IDS=
# —— CORSadmin 同源免;桌面端/web 从 3000 跨源直连需填实际 origin(frp 外网域名等),
# 生产(APP_ENV=production)下缺省不放行任意源。多个逗号分隔。——
CORS_ALLOW_ORIGIN=
# —— 可选调参 ——
DISPATCHER_CONCURRENCY=8
TASK_TOKEN_BUDGET=200000
+109
View File
@@ -0,0 +1,109 @@
# sundynix-agentix · 应用机 192.168.100.132
#
# cd deploy/132-app && cp .env.example .env # 填密钥/密码/MinIO 凭据
# docker compose up -d --build # 在 132 本地构建并运行(Gitea 自动部署也走这条)
#
# 说明:
# - 对外只暴露 gateway:3000frp 映射)。gateway 已内嵌 admin 控制台(同源 serve UI+API),
# 无需单独 admin nginx 容器。dispatcher/mcp-go/mcp-py 只走 NATS,无对外端口。
# - 基础设施在 128PG/NATS集群/Redis/Milvus/Neo4j),对象存储在 126MinIO),env 全部指向它们。
# - 构建上下文为仓库根(../..,因 Go 服务 replace ../sundynix-shared)。
# - NATS_STREAM_REPLICAS=3durable 流走 128 三节点集群,有 Raft quorum 容错。
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://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
NATS_STREAM_REPLICAS: "3"
POSTGRES_DSN: postgres://sundynix:${POSTGRES_PASSWORD:?}@192.168.100.128:5432/sundynix?sslmode=disable
REDIS_ADDR: 192.168.100.128:6379
MINIO_ENDPOINT: 192.168.100.126:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs}
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
JWT_SECRET: ${JWT_SECRET:?}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
# 微信域名校验文件目录(容器内路径);未设则该路由不注册
WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify
# 微信 access_token 中控(腾讯云静态 IP 换 token,gateway 只拉不换)。
# 配了才从中控拉,避免本地动态出网 IP 触发 40164;不配则 gateway 直连微信换 token。
# 值放宿主机 .envWECHAT_TOKEN_URL=https://.../token.json、WECHAT_TOKEN_SECRET=<共享密钥>
WECHAT_TOKEN_URL: ${WECHAT_TOKEN_URL:-}
WECHAT_TOKEN_SECRET: ${WECHAT_TOKEN_SECRET:-}
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
# 让容器能访问宿主机端口(微信 token 中控经 frp stcp 落在宿主机 127.0.0.1:9099
# 容器里的 127.0.0.1 是容器自己,须用 host.docker.internal 指到宿主机)。
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
# ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**/etc/sundynix/wechat-cert/...),
# 不是宿主机路径——容器看不到宿主机的 /home/workspace/...。私钥不进镜像、不进 git。
- /home/workspace/wechat-pay-cert:/etc/sundynix/wechat-cert:ro
# 微信域名校验文件(MP_verify_xxx.txt):配置 JS安全域名/网页授权域名时微信要求
# 能从域名根目录访问到。放宿主机、只读挂进来,不进镜像也不进 git。
- /home/workspace/wechat-verify:/etc/sundynix/wechat-verify:ro
dispatcher:
build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile }
image: sundynix/dispatcher:${TAG:-latest}
restart: unless-stopped
environment:
APP_ENV: production
NATS_URL: nats://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
NATS_STREAM_REPLICAS: "3"
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
DISPATCHER_CONCURRENCY: ${DISPATCHER_CONCURRENCY:-8}
TASK_TOKEN_BUDGET: ${TASK_TOKEN_BUDGET:-200000}
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://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
NATS_STREAM_REPLICAS: "3"
POSTGRES_DSN: postgres://sundynix:${POSTGRES_PASSWORD:?}@192.168.100.128:5432/sundynix?sslmode=disable
REDIS_ADDR: 192.168.100.128:6379
MILVUS_ADDR: 192.168.100.128:19530
NEO4J_URI: neo4j://192.168.100.128:7687
NEO4J_USER: neo4j
NEO4J_PASS: ${NEO4J_PASSWORD:?}
# 报告源/产物 + KB 正文对象存储(本会话新加 blob)——指向 126,勿漏,否则 fallback localhost 连不上。
MINIO_ENDPOINT: 192.168.100.126:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs}
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次发版重建容器就清零,
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
BLEVE_PATH: /data/bleve
volumes:
- mcp-go-data:/data
mcp-py:
build: { context: ../../sundynix-mcp-py, dockerfile: Dockerfile }
image: sundynix/mcp-py:${TAG:-latest}
restart: unless-stopped
environment:
NATS_URL: nats://192.168.100.128:4222,nats://192.168.100.128:4223,nats://192.168.100.128:4224
# secure_sandbox(代码隔离执行)需访问宿主 Docker 拉一次性容器。安全敏感:等于把 132 宿主
# Docker 暴露给该服务(可逃逸);不需要代码执行工具可注释掉这两行。mcp-py 无对外端口。
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# 全文索引持久化:随容器重建保留(向量在 Milvus、图谱在 Neo4j,都在 128
# 只有 bleve 是跟着 mcp-go 走的本地索引,唯独它需要这个卷)。
volumes:
mcp-go-data:
+21
View File
@@ -0,0 +1,21 @@
# NATS 集群节点 1 / 3JetStream Raft)。单机多节点:三容器同宿主(128),各自独立卷。
# 三节点的 max_payload 必须一致;server_name 唯一;routes 指向另外两节点的 cluster 端口。
server_name: nats1
port: 4222
http_port: 8222
max_payload: 8MB
jetstream {
store_dir: "/data/jetstream"
max_memory_store: 1GB
max_file_store: 10GB
}
cluster {
name: sundynix-cluster
listen: 0.0.0.0:6222
routes: [
nats-route://nats2:6222
nats-route://nats3:6222
]
}
+20
View File
@@ -0,0 +1,20 @@
# NATS 集群节点 2 / 3JetStream Raft)。max_payload 与其余节点一致;routes 指向 nats1/nats3。
server_name: nats2
port: 4222
http_port: 8222
max_payload: 8MB
jetstream {
store_dir: "/data/jetstream"
max_memory_store: 1GB
max_file_store: 10GB
}
cluster {
name: sundynix-cluster
listen: 0.0.0.0:6222
routes: [
nats-route://nats1:6222
nats-route://nats3:6222
]
}
+20
View File
@@ -0,0 +1,20 @@
# NATS 集群节点 3 / 3JetStream Raft)。max_payload 与其余节点一致;routes 指向 nats1/nats2。
server_name: nats3
port: 4222
http_port: 8222
max_payload: 8MB
jetstream {
store_dir: "/data/jetstream"
max_memory_store: 1GB
max_file_store: 10GB
}
cluster {
name: sundynix-cluster
listen: 0.0.0.0:6222
routes: [
nats-route://nats1:6222
nats-route://nats2:6222
]
}
+70
View File
@@ -0,0 +1,70 @@
# 微信 access_token 中控(腾讯云静态 IP 换 token)
## 为什么
gateway 换 access_token 时微信看到的是**本地宽带出网 IP(动态会变)**,一变白名单
就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** 的机器
统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)当中控。
微信 IP 白名单**只限制换 token 这一步**(用 appsecret),拿 token 建二维码不查 IP。
所以:腾讯云换 token → gateway 拉 token → gateway 本地建二维码。白名单只填腾讯云 IP。
## 实际采用方案(B:宝塔 nginx + IP + 密钥)
腾讯云未配域名,用 IP 直接 serve token.json,加密钥头保护。token 走公网明文传,
但强随机密钥保护 + 90 分钟轮换,风险可控(单产品早期可接受;要更严可后续换 frp 隧道)。
### 腾讯云(宝塔)
1. **计划任务**(宝塔「计划任务」→ 添加,Shell 脚本,每 90 分钟):
```bash
#!/bin/bash
APPID="wxc236cddde8e7f863"
SECRET="你的公众号AppSecret"
mkdir -p /home/workspace/wxtoken
OUT="/home/workspace/wxtoken/token.json"
resp=$(curl -s "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${SECRET}")
echo "$resp" | grep -q '"access_token"' && echo "$resp" > "$OUT.tmp" && mv "$OUT.tmp" "$OUT"
```
加完点一次「执行」,确认 `/home/workspace/wxtoken/token.json` 生成、内容含 access_token。
2. **建站点 + nginx**(宝塔「网站」→ 添加,域名填 IP `162.14.122.200`,纯静态)。
站点「配置文件」的 `server { }` 里加(密钥换成 `openssl rand -hex 24` 生成的强随机串):
```nginx
location = /wxtoken/token.json {
if ($http_authorization != "Bearer <强随机密钥>") { return 403; }
default_type application/json;
alias /home/workspace/wxtoken/token.json;
add_header Cache-Control "no-store";
}
```
自测:带密钥能取、不带 403。
3. **微信 IP 白名单**:只填腾讯云 `162.14.122.200`(换 token 只发生在这台)。
### gateway132 的 .env
```
WECHAT_TOKEN_URL=http://162.14.122.200/wxtoken/token.json
WECHAT_TOKEN_SECRET=<与 nginx 那串一字不差>
```
配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连。
## 切换 + 验证(关键,坐实 qrcode 不受 IP 限制)
1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面一切、部署,扫码确认能登录。
2. 去公众平台把**本地 IP 删掉、只留腾讯云 `162.14.122.200`**,再扫码:
- **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实、IP 隐患彻底消除。
- **建二维码报 40164** → qrcode 也受 IP 限制,改用备选:腾讯云装 tinyproxy 正向代理,
gateway 加 `WECHAT_HTTP_PROXY`(需再改一处 http 客户端)。
## 安全提醒
- `WECHAT_TOKEN_SECRET` 用强随机串(`openssl rand -hex 24`),别用品牌名等易猜词——
泄露就能拉到 token 冒用公众号 2 小时。
- token 走公网明文传是本方案的固有代价;要消除,后续可改 frp stcp 隧道(token 不出公网)。
## 排查
- gateway 日志 `[wxlogin] 建二维码失败: ... 40164` → 中控没生效或没配 env,仍在本地换 token
- gateway 拉取 403 → `WECHAT_TOKEN_SECRET` 与 nginx 里的密钥不一致
- token.json 过期/为空 → 宝塔计划任务没跑,或脚本手动执行失败
+14 -8
View File
@@ -5,7 +5,7 @@
#
# 设计:
# - 应用经服务名互连(postgres:5432 等),不依赖 127.0.0.1;基建端口不对宿主暴露(仅内网)。
# - 仅暴露 admin 控制台(:${HTTP_PORT}) 与 gateway(:${GATEWAY_PORT},供桌面端直连) + Jaeger UI(可选)。
# - 仅暴露 gateway(:${GATEWAY_PORT}内嵌 admin 控制台 + API + 供桌面端直连) + Jaeger UI(可选)。
# - 引导密钥从 .env 注入(SUNDYNIX_SECRET_KEY/JWT_SECRET/各库密码);模型 API key 仍在控制台配、加密存 PG。
# - APP_ENV=production → 强制校验密钥、锁 CORS、启用管理员白名单。
@@ -64,11 +64,21 @@ services:
NEO4J_URI: neo4j://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASS: ${NEO4J_PASSWORD:?}
# 报告源/产物 + KB 正文对象存储(blob)——与 gateway 同一 MinIO,勿漏否则 fallback localhost。
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET: sundynix-docs
SUNDYNIX_SECRET_KEY: *secret-key
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次重建容器就清零,
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
BLEVE_PATH: /data/bleve
depends_on:
nats: { condition: service_started }
milvus: { condition: service_healthy } # mcp-go 须在 Milvus 后起,否则工具 no responders
volumes:
- mcp_go_data:/data
mcp-py:
build: { context: sundynix-mcp-py, dockerfile: Dockerfile }
@@ -83,13 +93,8 @@ services:
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 }
# admin 运维控制台已内嵌进 gatewaygo:embed,见 sundynix-gateway/internal/webui),
# 不再需要单独的 nginx 容器:访问 gateway:${GATEWAY_PORT} 即同时得到控制台 UI + API。
# ---------- 基础设施(端口不对宿主暴露,仅 compose 内网) ----------
nats:
@@ -181,3 +186,4 @@ volumes:
minio_data:
milvus_data:
neo4j_data:
mcp_go_data:
+208 -6
View File
@@ -11,238 +11,416 @@ cloud.google.com/go/accessapproval v1.13.0 h1:kx3RQSS0VglTngQTPfywgVj+xFgre/vJou
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
cloud.google.com/go/accesscontextmanager v1.14.0 h1:50ofyZiGo2yL3Wt1gZ0j0QnD9y3YrhhcwX0N08uS6KY=
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
cloud.google.com/go/accesscontextmanager v1.15.0 h1:0baVgug9IFV8y5cIoD4iN/m/QtJzk2k+j3MW+zJWCVw=
cloud.google.com/go/accesscontextmanager v1.15.0/go.mod h1:YjW9urferk8i9ALwBF3bmdcogZeQYRn2yWwR8nkhsBc=
cloud.google.com/go/aiplatform v1.125.0 h1:QUGv+XaHN9wcWdb0/J0NFIcaP/veQSvDcqg4GH6QiP4=
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
cloud.google.com/go/aiplatform v1.126.0 h1:5PxeWpQfkAyN8mtgtVp4H5nP+ntwPLFMjNREVdiolR0=
cloud.google.com/go/aiplatform v1.126.0/go.mod h1:iR3za3evdprLe1XL2pLu0cYVCuTbc87QG0pgvcgiJlE=
cloud.google.com/go/analytics v0.35.0 h1:GuwmzJHIaQRvtko6g4wW1ngQ7rpDvDLZ8M3iP3kiflU=
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
cloud.google.com/go/apigateway v1.12.0 h1:fpSMzMpRFOS3OAQBdiX3LIKNEGYe0ley2EI+lGaCK2s=
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
cloud.google.com/go/apigateway v1.13.0 h1:IENNmFQlMsl58RlvYHf6wxBNQFeP+lAnVNcyxtmByTE=
cloud.google.com/go/apigateway v1.13.0/go.mod h1:pvEpOuuOIw2ev9VCcOyVkDXHHL4lvgMuqIe7XjJ8JoU=
cloud.google.com/go/apigeeconnect v1.12.0 h1:YvZhi0QkHobBRMrN6ri9PUPnnhgEd4VpW0+yo1WI7r0=
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
cloud.google.com/go/apigeeregistry v1.0.0 h1:S0DHrbgpO8/b+YJY/Af2rEQ5d7dkiO1LB59UdvkS6Aw=
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
cloud.google.com/go/apigeeregistry v1.1.0 h1:2zIogaYq7dF4E1dAGC4MUnasRuSmohsMl7wVuJ963/4=
cloud.google.com/go/apigeeregistry v1.1.0/go.mod h1:4ZFhQlxMuyfDMz9ORDSV8FPZtf2yPQkKjigsFtrrE4Y=
cloud.google.com/go/appengine v1.14.0 h1:dTww1xDqBpeR0BpLsiqfjyAnaK7S1vniJ5YR7L83Jh4=
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
cloud.google.com/go/appengine v1.15.0 h1:0MiBM2KGk1WAhh489WoELQ7IeW2qw6HTE6nZIlrOw5U=
cloud.google.com/go/appengine v1.15.0/go.mod h1:/8gGZsOX5GDjOo4mAWk8IV59p2991dxTbEtKIlhDjzU=
cloud.google.com/go/area120 v0.15.0 h1:9v6HeQsBpfRqoZtNeLI4V8SQ7Jcmt6s2gmgbjl0fVFc=
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
cloud.google.com/go/artifactregistry v1.25.0 h1:CWAoXkJBX02h68W7Z2ZNBqvH1wxET3s+fnKZcn2gM3c=
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
cloud.google.com/go/artifactregistry v1.26.0 h1:iq5kkdY2FJY8RkkNghUelb3EOcqyy7dVhfDGVk/Xw9g=
cloud.google.com/go/artifactregistry v1.26.0/go.mod h1:c5FPi5GtDBP+OAr5kKhCBNQDT9ZgAyobXQjekx93VWs=
cloud.google.com/go/asset v1.27.0 h1:Lj2lg/FB7VIBAkvUTVVx7Z9HRSPyVw7WN9butFJSONg=
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
cloud.google.com/go/asset v1.28.0 h1:M6YE1exBuZQhTi8wfIKrwjKK6b2ySfHKgrqXjPZA4EM=
cloud.google.com/go/asset v1.28.0/go.mod h1:Pnvjhay8/FgodOH9uJC8OkfJfRtSnNIIU4WSxg5JfJw=
cloud.google.com/go/assuredworkloads v1.18.0 h1:jk+W89a1UsdIzybt/UbRMRlJTXCqQaGuKCoFNz7nUb4=
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
cloud.google.com/go/assuredworkloads v1.19.0 h1:XyNQ9SBX0eUkZYCZhC8dOY6ZFeSF6/OIjHn5dQs78Jo=
cloud.google.com/go/assuredworkloads v1.19.0/go.mod h1:/UGGtFCMokM3sGJ4FxjfmLvvFpPa5I/Oz68mwk4Su+0=
cloud.google.com/go/automl v1.20.0 h1:Gh9BlFogtzwSxaEfnx33XD8xJ+z0v33yLQd46/Ka7uM=
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
cloud.google.com/go/automl v1.21.0 h1:brKXr4i9AMVfgbCKOYSu4rnIoz1ILIgcIAy3nerzEGc=
cloud.google.com/go/automl v1.21.0/go.mod h1:MNbhUevuECzM3jqSOM7hmOedOdRJkm8xbbXW44SU15U=
cloud.google.com/go/baremetalsolution v1.9.0 h1:c56Ygy+4Lr8WtnLG4nV2VIzhhGVgsa6gSEPdp83/PYI=
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
cloud.google.com/go/baremetalsolution v1.10.0 h1:6MaZilXzGZJ3mGqbSCdDezs9yrze6kcmBABsRUZ/aYI=
cloud.google.com/go/baremetalsolution v1.10.0/go.mod h1:xhhT9VQiKPFd2fUs4oeDSRrxV0sb0PGeVmuZoUE2cBA=
cloud.google.com/go/batch v1.19.0 h1:i4xCFKCvzfkSldUPYWL+DgBpKVTC3N8DSK6E9rFSbqQ=
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
cloud.google.com/go/batch v1.20.0 h1:nTL5x9HA1yVdPIdeqcbLjNnV9iYDYvMNmP6AnZ+qbHU=
cloud.google.com/go/batch v1.20.0/go.mod h1:ABT/5QqsIDsONa+n/8C7XYPjwh/kjOEPXukcRTaMsCg=
cloud.google.com/go/beyondcorp v1.7.0 h1:SHAZlC51z6ZO/OZZABnrI/Yk/z3GkhBREQC7qtgTo2I=
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
cloud.google.com/go/beyondcorp v1.8.0 h1:rsyle6zjxce2s57BpULw5u170xS+KSjFENSY7C0MZbs=
cloud.google.com/go/beyondcorp v1.8.0/go.mod h1:aVxzwamO8H4GXWQHowBAmL0KYNfYpW4E6Do2wfP0RYs=
cloud.google.com/go/bigquery v1.77.0 h1:L5AW3jhzEKpFVg4i0mVHxKpxogrqT7dczWBSr4m9MKU=
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
cloud.google.com/go/bigquery v1.79.0 h1:+tW6oRuP/4dOgdl/p32on0+3OktVQtU+veOMxhjoOhE=
cloud.google.com/go/bigquery v1.79.0/go.mod h1:QTt5tgZxqqvZs3dOZKpvriGqy+CdvY9LyetirFZRPOE=
cloud.google.com/go/bigtable v1.47.0 h1:NGLgDSr/i79BTGCjxH/maPKxyvl5q8/SsBsyLK52kdI=
cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4=
cloud.google.com/go/bigtable v1.50.0 h1:lihc3U/eVrlIjK55i93K8sol+pKtFozIJ9vooEuNd4I=
cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic=
cloud.google.com/go/billing v1.26.0 h1:6RRjbRd6iZKZFb7/MgRvmXKq/Ism02ckkZLJazj4CQ0=
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
cloud.google.com/go/binaryauthorization v1.15.0 h1:yzkO2Hv1HHDs3+98Twtae9a9a2bEkufu7zTc9tRCiMc=
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
cloud.google.com/go/binaryauthorization v1.16.0 h1:f1iNUaHWP9XPdh6grftTlIRuJSBytud4j51l1Jce94E=
cloud.google.com/go/binaryauthorization v1.16.0/go.mod h1:E+iC5Avu4pdItdzGiSGHnh6TfQrl+KmPxDDg/T/VuHs=
cloud.google.com/go/certificatemanager v1.14.0 h1:31fCXgMFDLSXh9HeF2M6hLE+dPF/1UFyIJXLmqpr41g=
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
cloud.google.com/go/certificatemanager v1.15.0 h1:PwljEZlI3lZgoFUG0pnIBNOwrQUaAP+gS0ZahTwUZBA=
cloud.google.com/go/certificatemanager v1.15.0/go.mod h1:8dfGG2/TbUpCNqsCF/TIMOGV0OVvU6nhkZWTU4MmCXU=
cloud.google.com/go/channel v1.26.0 h1:lvEuQo7hmVsgedO9aLaIBvXRVg5EoK3jskKdYJl+Vyg=
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
cloud.google.com/go/channel v1.27.0 h1:fqDZDzVw61c4iIUe7rQ+cBYlBdSZ3yqnzw5WrZTPjqs=
cloud.google.com/go/channel v1.27.0/go.mod h1:9ekufBLXuQ6j1oyqtDSIp29qWU5EwCi8WUi9qkLn3MA=
cloud.google.com/go/cloudbuild v1.30.0 h1:iOvtaQAcMmdLJaseR6qV76RgFHAAwZlwbpHwWMTqIdo=
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
cloud.google.com/go/cloudbuild v1.32.0 h1:tlF+KSIJJ0kaKkxACpk9htZ1euDpyOqPuO1aHR4j3oI=
cloud.google.com/go/cloudbuild v1.32.0/go.mod h1:mYgcM8CMaPmAnO7GxSQ9ADAxVRwS+1b7s6WVkt29OXY=
cloud.google.com/go/clouddms v1.13.0 h1:/oIzRKf/FgUYqSBwSnwrrtJPkSQ2EMzY8UHQwhGXoJk=
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
cloud.google.com/go/clouddms v1.14.0 h1:oaktwVyUeKTuzjpde3xbpmc9bAQPQq6WJWOBr7K6YEA=
cloud.google.com/go/clouddms v1.14.0/go.mod h1:qSwET2Q27cJ4wCDsPsbkagXqQqkWfOy+gU3RjMsT/c8=
cloud.google.com/go/cloudtasks v1.18.0 h1:KzT7hfix/9/xAf20tNPIxwX59XGpRF0Lun2t8LHOj9E=
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
cloud.google.com/go/compute v1.62.0 h1:tJ7lKJ8YEVa6vZX03Jc8o1YePbjKDOQhDw1BscMZ1bs=
cloud.google.com/go/compute v1.62.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw=
cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/contactcenterinsights v1.22.0 h1:VzNZG5RxHhRWlhmPg3GHUmPDqsZXbHq1GJ9yw6ISJbY=
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
cloud.google.com/go/contactcenterinsights v1.23.0 h1:EtrRf8T7ajR+dR8nseDxwKy3o1VaWP1GFAu7lgMmT9A=
cloud.google.com/go/contactcenterinsights v1.23.0/go.mod h1:uB/kygbfYH/gWEq3NEgq3QRI7/MvpjFyX81ajcW5YAI=
cloud.google.com/go/container v1.51.0 h1:KdeWHqwlOvABdhkhSTSb4QFySMOuXnc3UUoQMmq9lic=
cloud.google.com/go/container v1.51.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs=
cloud.google.com/go/container v1.53.0 h1:3zUySblDfZI9c+5gUWVOacKrdsQwiEerK4HM8eS/Ud8=
cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s=
cloud.google.com/go/containeranalysis v0.19.0 h1:89ZhFvJHWzX9/jUJy/IEVbtSa2hv4+NtsvwWMEsUYnY=
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
cloud.google.com/go/datacatalog v1.32.0 h1:fyYn8ODkGil5y3zTIqgIhOfzTu1ACaU2o+C750CO6Ac=
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
cloud.google.com/go/datacatalog v1.33.0 h1:8V80PpoAGdOOr2QhBrp4wZ66MDCbATdAB/fmVmo5rlU=
cloud.google.com/go/datacatalog v1.33.0/go.mod h1:/EMN04S73fZcPdtNg86VYLDrhi2HheMehQtMCS86Klk=
cloud.google.com/go/dataflow v0.16.0 h1:BchGCAl9QIZ/pyTGokv5V3daxBTrXsOmrU4ARXoTNd4=
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
cloud.google.com/go/dataform v1.0.0 h1:EExrLoU1kh8wYxjeRW/LUIlC4yk4QW5ikoZMbI0mgtE=
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
cloud.google.com/go/dataform v1.2.0 h1:eAIsWhr6AbUIX+wOdfctO6hAr9UoQnTR2qQW739XH1I=
cloud.google.com/go/dataform v1.2.0/go.mod h1:Lhkjd6L04/nBqsEo7S9Tx7D+Vm0pDDDZuKczewAJuX0=
cloud.google.com/go/datafusion v1.13.0 h1:rpmpw3F9clEDTk1uCAMjPwJblRGjlW1tQEMEiTC/tR8=
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
cloud.google.com/go/datafusion v1.14.0 h1:56rjOW8xFBnTOLgTEWdU09KhVC2/X+y/ukzux7zYqBE=
cloud.google.com/go/datafusion v1.14.0/go.mod h1:2z+uDUKkLPacNNos5lW1Jf1IRDoFyeE+glJ4hmxF2Uc=
cloud.google.com/go/datalabeling v0.14.0 h1:hlmO3GBCfiU23UovEKnJoKDKzr+7Du5x1UxXm+4U5AY=
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
cloud.google.com/go/datalabeling v0.15.0 h1:9TL2kwlO/ODD1fne3uvgTLa53SttwrQi92Bn7KWnKhg=
cloud.google.com/go/datalabeling v0.15.0/go.mod h1:H8WSRKD9XYCDXDlZE3bPgvV7UYI0F05e+ufKev2AFc8=
cloud.google.com/go/dataplex v1.35.0 h1:EKEhiy/SGYwCH2DZ2r8JEFq1Hx+x+fjJZXRDY3rgPEk=
cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o=
cloud.google.com/go/dataplex v1.36.0 h1:r4O6RHEkwHr41ItsTUym0ZgH1iDf48R+W8fI7KoMNl8=
cloud.google.com/go/dataplex v1.36.0/go.mod h1:ftgNMXBt+wJ4wPVNvYJ3UY3VTZtKS/i/uFEQppaEbKk=
cloud.google.com/go/dataproc/v2 v2.23.0 h1:7PR3Aa+NO+AESWUv1dt6aFHfXizIx/zo6N2sdQuEWI0=
cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4=
cloud.google.com/go/dataproc/v2 v2.25.0 h1:IF1wxkXvcgoTP92NVqX6QNDf2SdKNZFPa2IWeSKP310=
cloud.google.com/go/dataproc/v2 v2.25.0/go.mod h1:hkiM6kzc8CwLGoquMN1oghyhuI1fE0girmChH4h9W7w=
cloud.google.com/go/dataqna v0.13.0 h1:VR1y/NuN+RvPkmfmlowbmk60BLINR4MgZAusVFIGQjU=
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
cloud.google.com/go/datastore v1.24.0 h1:auNUPJTT9gFcHNj2iKOEeE23nrjf7dE7VA6TO3jw8h0=
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
cloud.google.com/go/datastore v1.25.0 h1:zUjMnCLCcRZVDSdQIXsbnNCl1SVRNw5Jm0J77gPaPKs=
cloud.google.com/go/datastore v1.25.0/go.mod h1:jvJVNe+S2nHVIndV1H/B4s9K3MLsTMqOKlxSrzHTxB4=
cloud.google.com/go/datastream v1.20.0 h1:/Xv8hdolIN5SpMgxiiuDc8AncfEuXU9TWRhvQkngCq8=
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
cloud.google.com/go/datastream v1.21.0 h1:hrWGASBj85xs6wpnx9ifdSBYiRLhKDgQrA1HjeNYBhc=
cloud.google.com/go/datastream v1.21.0/go.mod h1:z9AlkQGdXqkeyO5HE+D6sYbOkLJYB4BCZpXFPX/1Vpo=
cloud.google.com/go/deploy v1.32.0 h1:vA9yH8EEXOsq1caJpvkJl9wJYA9VU8xuU45V7iC9XHk=
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
cloud.google.com/go/deploy v1.33.0 h1:2PdZ8kbIztLVRMrIN/wHbalZeZZikQlxBptBFK+eEtM=
cloud.google.com/go/deploy v1.33.0/go.mod h1:QdF3plD8D5gV2RmkTXBB6cHrq490WlpFr1SChdOJO2Y=
cloud.google.com/go/dialogflow v1.82.0 h1:PKC7h47s036UsW4YxTV2aRCTOChEzMzioczRdlKSApk=
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
cloud.google.com/go/dialogflow v1.84.0 h1:BymJ6nPotDcnphK2/D0VHMAfakjJlIV5GqxLHQg/Tos=
cloud.google.com/go/dialogflow v1.84.0/go.mod h1:OU8Lj1aw5Vr2hl9ifW+vsKnc2b4iJH+41U7nZ4whg3U=
cloud.google.com/go/dlp v1.34.0 h1:SI7KSLOtAfhEAw3af8NxpKHGHbJ9BkoM7805TMaU6m4=
cloud.google.com/go/dlp v1.34.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q=
cloud.google.com/go/dlp v1.36.0 h1:2KOeg+++hpyln8WvbVZGTdgtXxWzpiKQ3re+3dPj5ts=
cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ=
cloud.google.com/go/documentai v1.48.0 h1:qodgYZJgA89EWNJeeWXWBe/5kq9C5cQhthJweI6Z6CE=
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
cloud.google.com/go/documentai v1.49.0 h1:WLZOaWvK4NH7xMLxANsDgIdXugeqmVyYRCbVN1m87DQ=
cloud.google.com/go/documentai v1.49.0/go.mod h1:VyQA+SxPnCPlVLSJ5UcFx+LQm8JCzK7uUXdkOaAHvG8=
cloud.google.com/go/domains v0.15.0 h1:X5RjcYzpsVkuTMZ3OfuSDIv9pBtMlDEk7XXunlfB518=
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
cloud.google.com/go/domains v0.16.0 h1:h+lcYPxyEulj5SYuH3+OwOsLZb4DTW11QX6nlxxXQ1I=
cloud.google.com/go/domains v0.16.0/go.mod h1:O5AhaEyUAgZC2X4M10nSu3dQt2cJLtbjhtrNrdeSPF8=
cloud.google.com/go/edgecontainer v1.9.0 h1:9S7YGenFNDVMoh5tulCbSniETQ+XxgjDDie/sEhdkw8=
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
cloud.google.com/go/edgecontainer v1.10.0 h1:4WOjcIZRCB4ynxH9Zs1tteIcm2LD58VUCLLmIwLYUAc=
cloud.google.com/go/edgecontainer v1.10.0/go.mod h1:g4xb11IzVWa9peXNTlnNguKP8uJVvMK4zZeDlGS2Wus=
cloud.google.com/go/errorreporting v0.9.0 h1:LlE2SVIbz0k+OSeNTksk34inr3Fy62JMhHUvNaS8f7c=
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
cloud.google.com/go/essentialcontacts v1.12.0 h1:+AtGn+hYLdr62sMqP0ZGMRzp9A4t+MSWe8eZoD/ho+M=
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
cloud.google.com/go/eventarc v1.23.0 h1:/EUAdoBWSlqQRbpQYTV2Msmg4esw3Mum3tEU7zkhLi4=
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
cloud.google.com/go/eventarc v1.25.0 h1:+XAJNEmxJIPZLMtzLYdUZN61Kmqx62hWLry4AsWaTwo=
cloud.google.com/go/eventarc v1.25.0/go.mod h1:ncY2NKHKiX+sUjIfxVozrivvmJQ4HWo2znxms7AxlP8=
cloud.google.com/go/filestore v1.15.0 h1:ZYFAnP4elMogIQAXFwPx4nKpcvY0dJOZV+zl2l50MGQ=
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
cloud.google.com/go/filestore v1.16.0 h1:2GXy5jvq2oUX6HnCpx3GjzSqlcMz8K9gkHaF9HPcYNE=
cloud.google.com/go/filestore v1.16.0/go.mod h1:szr35omqptDEuXgBbJ8PdVdYM3lf/Md96kNufWr1tVs=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs=
cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM=
cloud.google.com/go/functions v1.24.0 h1:0nb8LMMABq/oChZg+ovRD5bsc/dNm5ti/aoHRZ9MoUs=
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
cloud.google.com/go/functions v1.25.0 h1:ndUtLkam3XF9b0t2zVACH9D/EgFBISbVuQFqRz/X58k=
cloud.google.com/go/functions v1.25.0/go.mod h1:b/tqakoKeAkj9RspEjqswWf5299Lkz9C/742QUD3OEk=
cloud.google.com/go/gkebackup v1.13.0 h1:QyeJc4XPqV0hzoAcAQIi8YMveT8eRI21oOK16qKItJo=
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
cloud.google.com/go/gkebackup v1.14.0 h1:li3BtGRis1QYrkLo8+Iq2wf5WbP9v3sz9VoUw8WqgaA=
cloud.google.com/go/gkebackup v1.14.0/go.mod h1:kaD4l/s0ONcb3L9iHC8PzG1XkC5ggPwA/KAl6yAyQGs=
cloud.google.com/go/gkeconnect v1.0.0 h1:IDuAD/w0Xph8k/31vdt6pBkDdRx55lVXz7gW8ABOfCs=
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
cloud.google.com/go/gkehub v0.21.0 h1:Fvx6c94yYToZBlsYF7tBIt+LW1u6uY6WYK/h3Z6IZYI=
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
cloud.google.com/go/gkehub v0.22.0 h1:gHKPoWQuWpd9dXLoC58dxwBvSYTNc5/fdYYcs3JUn1s=
cloud.google.com/go/gkehub v0.22.0/go.mod h1:WiXX1w9ZHwKZVUDwL//YQfjfWS7yE0I/ym3smZn9iwE=
cloud.google.com/go/gkemulticloud v1.11.0 h1:MTqEPjiNVY9bcliSfQR23HHaTPlfFinDh+4ARB5Gn14=
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
cloud.google.com/go/gkemulticloud v1.12.0 h1:3LPj8ro7bPdZ0BlQJwby25G32rRezGLRdhNM6U3AxGs=
cloud.google.com/go/gkemulticloud v1.12.0/go.mod h1:vLNCxGah7pPIoNSX4Yx+hb8klqA0lzzXTWBSut9KzRo=
cloud.google.com/go/gsuiteaddons v1.12.0 h1:kz9DyBk84wZCda2Rdha1MOIf7/9x6R+N+LuH9B3zYFs=
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
cloud.google.com/go/iap v1.17.0 h1:BDAVJy+juq7cMRumIx9toc4pt1K7zXoZdAI3lDD6D3g=
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
cloud.google.com/go/ids v1.10.0 h1:uk4kW7UYUtIzlQigKreGKXq4HzbXrspjJ5SzUfPV6qg=
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
cloud.google.com/go/ids v1.11.0 h1:394LkKIavhv/+oQVMtbXe9WQqCeY9wSFcHktqC9ZkLE=
cloud.google.com/go/ids v1.11.0/go.mod h1:+drdvU0pQ4x5uYiWCv364VOeIpTN/PETBrdR51D4Tjk=
cloud.google.com/go/iot v1.13.0 h1:pyt1EuMFpbV/2BlmYlXr+HBnUNSPKlP9dVK/dpFTu1U=
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE=
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
cloud.google.com/go/kms v1.32.0 h1:s+rEluaaZKhLVjrIWG7uNBsnWbiitElzNzFGyp6+nIg=
cloud.google.com/go/kms v1.32.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA=
cloud.google.com/go/language v1.18.0 h1:q58bL7rmxvw6Q6VHt+wjFsAE1Tj/JkuAEIR4+84rx9U=
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
cloud.google.com/go/lifesciences v0.15.0 h1:sLkI7iAWGPkptWD5f6P9UX6JKiCp5gc4uoa07F8WykI=
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
cloud.google.com/go/lifesciences v0.16.0 h1:T+7N3oVHK8a7BhX6HJLrBW8xEbMV9iY8Mb+aOl7r6+A=
cloud.google.com/go/lifesciences v0.16.0/go.mod h1:axEwGa3A63+vCXIis+0Zkseu8KecqtNoSn7x0zyjJfM=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/managedidentities v1.12.0 h1:tGderKWJBrOee9BtGul26gA6425tdbZXkbK0ZSkbAE4=
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
cloud.google.com/go/managedidentities v1.13.0 h1:ZzWkg3LSrIi9OdzCK3bxutexvw1dDDg9+tOMitbkOXw=
cloud.google.com/go/managedidentities v1.13.0/go.mod h1:lUYH5r6QEJTHqjgga0WFeiieqJ0iRwEuQSk20O41Vj0=
cloud.google.com/go/maps v1.36.0 h1:MkS6PUuiVmn7YsDv1SEzffTmZ4ucgaiBpZzsz1cE5mk=
cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao=
cloud.google.com/go/maps v1.37.0 h1:YoTRWohpNKeKxdQynorwHH69NZuUM06divZtqGPYjUA=
cloud.google.com/go/maps v1.37.0/go.mod h1:oalKFBmf2eHmdr3OvfEiiBlOakNlVitYYEPcM3TTUB4=
cloud.google.com/go/mediatranslation v0.13.0 h1:qqMRAK0mhc9M+lM6pb791oh3DYUbwLcIy58jOyXtadE=
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
cloud.google.com/go/memcache v1.16.0 h1:J6Iq97D6rlDMTJRnTjP3tttRrop8bZDCEKpbsmu0K1c=
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
cloud.google.com/go/memcache v1.17.0 h1:l/co1jsmVLkNyl8uMqj1u1HIwd3AhCvGdHbEAIPb6jc=
cloud.google.com/go/memcache v1.17.0/go.mod h1:QQpFWgJvrFaQ6DgmitHejdbkLg8SJfHg5BzltKEWSt0=
cloud.google.com/go/metastore v1.19.0 h1:oAFi3AkO9YZHoDYXo3cbLXlBS4PUUxe5/9kR+q4ta1g=
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
cloud.google.com/go/metastore v1.20.0 h1:kz0XBkP531MB1IR9jTY6/DB33UFP5Df1vFpPNgW1iPk=
cloud.google.com/go/metastore v1.20.0/go.mod h1:/bhZoizjM5iOrqWJeAFDw7c16C783wEftqofnJgKKYI=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/networkconnectivity v1.26.0 h1:cnPha9p2FFBbxVQA0D5fRBQsROq6tVFmsDZfrGEObtY=
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
cloud.google.com/go/networkconnectivity v1.27.0 h1:ieYJjbUn2L9ZSzEB9+qKgtoD3/l1/qAnId/Z6KE4ZBo=
cloud.google.com/go/networkconnectivity v1.27.0/go.mod h1:pCnczH2W/cnLSlnsnN+VzBoXlM81ZoUGuuacFBGThyw=
cloud.google.com/go/networkmanagement v1.28.0 h1:x4U4osf+1qmq7/FRIfjM781mJSeXhmjoDWrbhB4f3Mo=
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
cloud.google.com/go/networkmanagement v1.30.0 h1:LqYm91vSQk3NM6fHjthseRh4xrDvhXIWQBsQY/Ksus8=
cloud.google.com/go/networkmanagement v1.30.0/go.mod h1:3SBf5T7jyGzw5jqJWE7TUDRhIl2E029jggbeoFEgt5E=
cloud.google.com/go/networksecurity v0.17.0 h1:6rH2+St9F9n6r49bZ0u7X0Bp7tmWkE0ZguYCWU69xKc=
cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg=
cloud.google.com/go/networksecurity v0.19.0 h1:PzJnbVd0sS2+xDR9hnk31NAyqFt9qB8ohLn7XIsKIBA=
cloud.google.com/go/networksecurity v0.19.0/go.mod h1:VWDFX+stDgzZYDsCX1Wy/JO9Tlw7g/V1UHbiORVgqq0=
cloud.google.com/go/notebooks v1.17.0 h1:fiezRHPH/H4HatBxbzEQljlmDV8MBv2ffWs1Z6TyHhw=
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
cloud.google.com/go/notebooks v1.18.0 h1:BgxdQcoRSSjiyszWNOTu3fmXG/6Ks4W2sHgjx0BAke0=
cloud.google.com/go/notebooks v1.18.0/go.mod h1:fXU6A3TJ2YobFy6fxOr4tKZZ8QgTjdJAqDIykOB85Gk=
cloud.google.com/go/optimization v1.11.0 h1:lh0CcgHOGEAilUn4xS4/gIsSZA4AmTqEhGXdpz6Z+N0=
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
cloud.google.com/go/optimization v1.12.0 h1:Cd4DgujNhEMcjfaVLGqbjfDwVDnmS1Bc93cx9TE8ZYw=
cloud.google.com/go/optimization v1.12.0/go.mod h1:28gzCUmeCLcT4vctGEo71QF4b60TYkKQo5y8Gs2KPq8=
cloud.google.com/go/orchestration v1.16.0 h1:aVakYx6wLQV8I8ZDydplEKzQ2+hTJ3Qh/lU5/mwijQA=
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
cloud.google.com/go/orchestration v1.17.0 h1:dqf2HzqUi4CaaDiNfv6jVoZjz9lF0HWjyIEgccxPHMk=
cloud.google.com/go/orchestration v1.17.0/go.mod h1:Lf/Czqh4Jfy3IFpvDkKWjfjkYFI+tj6nAjq5ihivrq4=
cloud.google.com/go/orgpolicy v1.20.0 h1:kpVcE/OsC5aAzHCsAiuQSg3+s6ILzgPTuPZyS7n7ejA=
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
cloud.google.com/go/osconfig v1.21.0 h1:jpq0DNmjS4FkTbNILFdp03uZUQt8D2izpUtgtmSDieQ=
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
cloud.google.com/go/osconfig v1.22.0 h1:r5lzneR9GNixJ96lZ6oIfJK9Cd5sKpnzZJe1LcGZf4w=
cloud.google.com/go/osconfig v1.22.0/go.mod h1:bUL0FaSR2ahPcFRRYnd6a0LyUzsQYIdUpBq8Tmxg8fE=
cloud.google.com/go/oslogin v1.18.0 h1:OkccORMcY2XEHwN5+AP0XH/SCUNBl39GGjaQuSCVcIw=
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
cloud.google.com/go/phishingprotection v0.13.0 h1:6WJF1z3Ie8fZAiCRpeSGB81h6s86XWS2SBC9iQUZxX8=
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
cloud.google.com/go/policytroubleshooter v1.15.0 h1:nHNbD/2XYM5krsBN9C1W+qSFOlOcbbjct51LXCM1qig=
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
cloud.google.com/go/policytroubleshooter v1.16.0 h1:V3CcqzY6FhY2c2idRGZv0Ef4mFinLnXnfDFtAhxyTfM=
cloud.google.com/go/policytroubleshooter v1.16.0/go.mod h1:FZg3IW3exF6wc9eO/iBYijsGqiiCzc9mjZhsxgATXYA=
cloud.google.com/go/privatecatalog v0.15.0 h1:sQSFvJIXKM9RYFK3fPIBDSykC5I2TOmCZMXz9Q3DFsY=
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
cloud.google.com/go/privatecatalog v0.16.0 h1:SBhcVNVhgEGT1edcfSFY/KSiy2xSQTSQUpY12f10rmw=
cloud.google.com/go/privatecatalog v0.16.0/go.mod h1:Dq1bSHRRaDqFr7Rb7UntXVjh1reeY6YdzYicL0EPTrM=
cloud.google.com/go/pubsub v1.50.2 h1:54Up97HnThdP4H8jjWJSSQ/mnYG2EKon7ZSNETRq0tM=
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
cloud.google.com/go/pubsub v1.51.0 h1:XOaCejsqX7EEtUdQz+WPag66wWsUUGliyCOfGPKfo90=
cloud.google.com/go/pubsub v1.51.0/go.mod h1:NERXf11sd82UV3VnflcUj8POIyQUXT/QwrKlxD8di/I=
cloud.google.com/go/pubsub/v2 v2.5.1 h1:+TwXJr78P9RrMV3S8lKHIhJo2E99jI7ta65e+ujJjts=
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
cloud.google.com/go/pubsub/v2 v2.6.0 h1:8pjR0id+GTB+krKx5G6AGJoYrHog58w2Q89PCOrfM64=
cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY=
cloud.google.com/go/pubsublite v1.8.2 h1:jLQozsEVr+c6tOU13vDugtnaBSUy/PD5zK6mhm+uF1Y=
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
cloud.google.com/go/pubsublite v1.10.0 h1:kqNk9e0gt0/Eu6lChjDlgu4B8k/399FZFpAU4LNq4Co=
cloud.google.com/go/pubsublite v1.10.0/go.mod h1:o9NVNBY4m8LubZqRCJtBdxpjP8DAsYizsxC6Z1vI7Dk=
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0 h1:9qDHoQtUIZ8FDN5uFAfnNz8jyyXdpAJf6rMN4ZMmMcU=
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
cloud.google.com/go/recommendationengine v0.14.0 h1:kQ+PcZcQBv+FMlZRTp29UYvl3VD5/jsU0MsNgOFTw3I=
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
cloud.google.com/go/recommendationengine v0.15.0 h1:G0tmQXp67YDaduvde7AX+cIdTtDCo2PBMeqv8SBOpnw=
cloud.google.com/go/recommendationengine v0.15.0/go.mod h1:Yx45rCF3A5fLSeXxSkXOCTXSBDBogrQnR7kUTJHwYxw=
cloud.google.com/go/recommender v1.19.0 h1:fJ6oO/7Ta/yzfRHcuJUln9iCeo6FDb5yIi2L7eLldzs=
cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU=
cloud.google.com/go/redis v1.23.0 h1:y/NCxLQR46TQufJNjgINfWsRjCxkgClU37mMf/D1EE4=
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
cloud.google.com/go/redis v1.24.0 h1:lV6xFUF7t3fm2ZRFjB4IVNkznG9EVwetYVJYwzmW8pc=
cloud.google.com/go/redis v1.24.0/go.mod h1:ebtw9WLFKswecHO2ifNykuteNJNwoPqMCHz4UI11kF4=
cloud.google.com/go/resourcemanager v1.15.0 h1:OwcTLrKaly0SMPoYHssPG4FBzRF0tyimeySOFD/YPJ0=
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
cloud.google.com/go/resourcemanager v1.16.0 h1:aDf2RuuQ1Z+kbHHDzHyJNMHPGRFK+cMYfZIhWRaeRX0=
cloud.google.com/go/resourcemanager v1.16.0/go.mod h1:Hn4HPkLRnTuiUhFEFJg736Brt7BwlS84xYU06sc3STc=
cloud.google.com/go/resourcesettings v1.8.3 h1:13HOFU7v4cEvIHXSAQbinF4wp2Baybbq7q9FMctg1Ek=
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
cloud.google.com/go/retail v1.31.0 h1:nJnfVzX+GOIe+PwDNSYG080ydirDzoD53z+c3y7ZzpU=
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
cloud.google.com/go/retail v1.32.0 h1:u27GLYlnNsQrqzcvSlpZK0L6Ig8k3PEuXGkUdNFCOn4=
cloud.google.com/go/retail v1.32.0/go.mod h1:t9w9mBarD59BnFHTST2LoiCP5608ZlEfniHLgA6OoH0=
cloud.google.com/go/run v1.21.0 h1:gQJUy0//XNXXpiZs42KlbLPhbycxbpS2QymGRFlPXv4=
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
cloud.google.com/go/run v1.22.0 h1:U56fxJWdrT+yjo4S/Vrtw5m69NdNL11Cyv9jX2JOi1s=
cloud.google.com/go/run v1.22.0/go.mod h1:Wo0aTNrqfftGmbxPPraeOxSUDUZ2c7IVNg2dk8Qm1Bs=
cloud.google.com/go/scheduler v1.16.0 h1:EPdChptxnvCasdMixuu58247qhKMO1iAlrVaQhvuRyE=
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
cloud.google.com/go/secretmanager v1.20.0 h1:GjE3NoyFXo7ipRPy26PMmg4oRX1Ra8fswH45r16rWV0=
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
cloud.google.com/go/security v1.25.0 h1:U7Op1u7GSbmqEBtQEqxAKG7jMpMFNJ9mTFf+E0HYTJY=
cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk=
cloud.google.com/go/security v1.26.0 h1:xp/htSPHpyqo225Ju6PjNS9jxfcOEQMxnF229Oze62g=
cloud.google.com/go/security v1.26.0/go.mod h1:nd0i5OHXtJduMt0n6UnEojy7fiTfnfj/PSDeD7LAD+c=
cloud.google.com/go/securitycenter v1.44.0 h1:/jinB3GeXuNkWfrzK1EdWR+kD4J0z0YGyEe52+gPIoM=
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
cloud.google.com/go/securitycenter v1.45.0 h1:k5uzLtTSxnh4fwG+8nIcuwDjEE3J+6xe2wcZ72cys7s=
cloud.google.com/go/securitycenter v1.45.0/go.mod h1:7mAlzsCsKlEVmciAFORl431laDGpoKGFkSQndAzFs30=
cloud.google.com/go/servicedirectory v1.17.0 h1:yrohWkwM8t5JfEFCmmlyksKnpMpvxM8XbRpwg+yIo64=
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
cloud.google.com/go/shell v1.12.0 h1:eDwvv8ya1BCHCwHCzEIYp/9maLhGCco0LIjeGT4evBA=
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
cloud.google.com/go/shell v1.13.0 h1:vJ/g4BXCwBRMmUxHoNjdbE0DRh5Aysw/+vrZ/aPl0yc=
cloud.google.com/go/shell v1.13.0/go.mod h1:9WWf3xHQUElP5fL/lB9IJ/MMMnN2W/T86cBp+pXFFWo=
cloud.google.com/go/spanner v1.91.0 h1:XwXfcZ0kc1NT9Uu2IsThFiWtYptB+WgLn/KZEZcyzRg=
cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0=
cloud.google.com/go/spanner v1.92.0 h1:cfeMNmtFjz+OYzQVCIuGBw4Cik4CbF2ptXMuRQcUar0=
cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY=
cloud.google.com/go/speech v1.35.0 h1:jxWycO5+PfhBWxqnuJNDjNMi85zRK2Jcb4CVhOz6JcA=
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
cloud.google.com/go/speech v1.36.0 h1:ZcmRKcY02vkF3o/Eaa9yI3i1baNFTF4ctyDZVRfnNS0=
cloud.google.com/go/speech v1.36.0/go.mod h1:tiSA8MiX49o1ngq5Ww2JFTvfjKxtAuBKY/UIH6coCPg=
cloud.google.com/go/storagetransfer v1.18.0 h1:Y8kA7TiPPjiQH7Xsuf2KlBAJd7Jcn5J8aR5ABO81p/g=
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
cloud.google.com/go/storagetransfer v1.19.0 h1:jh+3SegMUulEhuJbkADCv8lmfRC8/pCpxja4oex2Ns8=
cloud.google.com/go/storagetransfer v1.19.0/go.mod h1:sy4ImXynHkm9CKmbILtmzLN36PHh7JOhUTpqXf5SvMs=
cloud.google.com/go/talent v1.13.0 h1:/nZYKG20ZHfZDr7ikRuDnssxk8fuaDxGR+KH3iB4gak=
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
cloud.google.com/go/talent v1.14.0 h1:dUNSwgBUFlDEMHJpmntEvDeLkPQAoEzBrvDbNfS/UeQ=
cloud.google.com/go/talent v1.14.0/go.mod h1:jieYQngp1YqRtqV2t92w3LTrjuLV05kMM4BZMUUneaw=
cloud.google.com/go/texttospeech v1.21.0 h1:u1Zvij2JgV3Vci3M2YrotjqnmW4px0uhoVoW8Vv6IP0=
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
cloud.google.com/go/texttospeech v1.22.0 h1:xGqQv2LB4nttaBNMepCm19EQ0Sk0YdjzPvklDU+LYsI=
cloud.google.com/go/texttospeech v1.22.0/go.mod h1:bAksATiWPKaw8r8wVgANa4GkVdsyFE4y9ulRzKyuJec=
cloud.google.com/go/tpu v1.13.0 h1:OAtRW+A/+bTLsPS5/trnK7Cz1GceMa2ZlLQzP/ZbSTg=
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
cloud.google.com/go/tpu v1.14.0 h1:u2f8Lhezy6piu0BB3+4pTLw9xyOeU5n8PCgWjbjCitk=
cloud.google.com/go/tpu v1.14.0/go.mod h1:1pggTTG5npfxea6vYjyl60Fg09VgbM7efBgVjnFZjpo=
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
cloud.google.com/go/translate v1.17.0 h1:6ecjspRHAOHU+x+e4HOK/2o+bzw7KHwu//eyLVf4TuM=
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
cloud.google.com/go/translate v1.18.0 h1:2dxWfGMG7y5vobxD2pPyngnfPPIk4MYdNB9QK5sEbuc=
cloud.google.com/go/translate v1.18.0/go.mod h1:aRVIE+P+7fngk8HwwFAgis5QA7wphGpKrFpdNoWtGCM=
cloud.google.com/go/video v1.32.0 h1:9Us/tkhNRg3WY9wIrVC3Jcs1P0nXKE4XnS1zYJ3xTTY=
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
cloud.google.com/go/video v1.33.0 h1:zPA41kNscRrJeRo/xD73TjNCFAnRDnP9ViNPuWARVeg=
cloud.google.com/go/video v1.33.0/go.mod h1:hEx8TNpQT6kdjMVsywePvT8BCb63Ee3F/R0GRa9wnzo=
cloud.google.com/go/videointelligence v1.16.0 h1:WSvC2OI6Su3ulwz0aS7qOVQHO7ZtohUyI6GMqvETY/o=
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
cloud.google.com/go/videointelligence v1.17.0 h1:cd+s4jLMS59xiYLT6eQ6PVKP8R0PskgifXDa7YwMUUU=
cloud.google.com/go/videointelligence v1.17.0/go.mod h1:Phxz7AQpvXoOvz+KrrOZEJRo4CDgYXMDVDqhCtdF1jc=
cloud.google.com/go/vision/v2 v2.14.0 h1:l4CjEOm9veghGSutx79p+WG6vI6/5DPjRsAasmi9zX4=
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
cloud.google.com/go/vision/v2 v2.15.0 h1:aTR1vj4++WtS9HD6YdGuoaYygMTJ873WaoV9sYjlQCc=
cloud.google.com/go/vision/v2 v2.15.0/go.mod h1:DUdjdFkXqPvEoPC4WDYFvYCn0LlAZ4vVz29A0bXvW90=
cloud.google.com/go/vmmigration v1.15.0 h1:F2uqT8+JXvSywV381YoQ4To3RnJETRtPkvcbdWXGmgM=
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
cloud.google.com/go/vmmigration v1.16.0 h1:OPGMxx73owRHMve5B4L6W+0KQUwlunyE9kfbYzFHqug=
cloud.google.com/go/vmmigration v1.16.0/go.mod h1:ILrSjXnHMpdamkkAU8fjMKKMsH27B6FLC5kv/6TkLy0=
cloud.google.com/go/vmwareengine v1.8.0 h1:TmHKgTRH+mjq2VaaxrNcXqWyeleX7YaJPvrfWFCn0eE=
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
cloud.google.com/go/vmwareengine v1.9.0 h1:vdSutihjzH5iYEFlN+1x+gDm4xzj6Md2yMzXADKI5cQ=
cloud.google.com/go/vmwareengine v1.9.0/go.mod h1:zXXuUaIpvDhsV6sR+JdQfcQ4V5+pDarrp7FW7nOdS2I=
cloud.google.com/go/vpcaccess v1.13.0 h1:aU7IKE/IAUgOzXCOgPsku4nV2DwmRpJHn6+QMf5Ub70=
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
cloud.google.com/go/vpcaccess v1.14.0 h1:fFa1i67AjC/3MuG5GPlvx36sIMeffpsIs3kI1C7S3Jk=
cloud.google.com/go/vpcaccess v1.14.0/go.mod h1:MxbVgr+2fpIFIEIdSmgnb8ykNWRPVtslpmWijp7an68=
cloud.google.com/go/webrisk v1.16.0 h1:OKkOJ81+YjGnrfN3oBNdpycZqKFNE4w52fSGo32rgNw=
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
cloud.google.com/go/webrisk v1.17.0 h1:t2WMlBo3xXxH6k5kTSkyIYUl24AHgmBI76F/5OqAPRY=
cloud.google.com/go/webrisk v1.17.0/go.mod h1:ypwCZ+G/SXyUZ+x3ppxn1hu+6tDifGNd/OpwPtCdJHI=
cloud.google.com/go/websecurityscanner v1.12.0 h1:iV+hAXeEo8kKtFWDZWPp9Z0fsziZnuOt4nCCYp68RP0=
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
cloud.google.com/go/workflows v1.19.0 h1:O5LlH7x1QovbDssany0TBe+hcSOcK5gPgIeaoByy0ZU=
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
cloud.google.com/go/workflows v1.20.0 h1:qROpn1zRDdeIjUP9w9O3MHXJEg8v/pzVMicAcw9vLvQ=
cloud.google.com/go/workflows v1.20.0/go.mod h1:TC9yx7VpjGdBBeKM8FG2EMtms5Q9nyTqI+2uV9bDNs4=
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
@@ -276,6 +454,8 @@ github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
@@ -305,6 +485,7 @@ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqr
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
@@ -553,6 +734,8 @@ github.com/kataras/iris/v12 v12.1.8 h1:O3gJasjm7ZxpxwTH8tApZsvf274scSGQAUpNe47c3
github.com/kataras/neffos v0.0.14 h1:pdJaTvUG3NQfeMbbVCI8JT2T5goPldyyfUB2PJfh1Bs=
github.com/kataras/pio v0.0.2 h1:6NAi+uPJ/Zuid6mrAKlgpbI11/zK/lV4B2rxWaJN98Y=
github.com/kataras/sitemap v0.0.5 h1:4HCONX5RLgVy6G4RkYOV3vKNcma9p236LdGOipJsaFE=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
@@ -659,6 +842,7 @@ github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtz
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rollbar/rollbar-go v1.0.2 h1:uA3+z0jq6ka9WUUt9VX/xuiQZXZyWRoeKvkhVvLO9Jc=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
@@ -785,11 +969,9 @@ go.etcd.io/gofail v0.1.0 h1:XItAMIhOojXFQMgrxjnd2EIIHun/d5qL0Pf7FzVTkFg=
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
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=
@@ -797,8 +979,6 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHS
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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@@ -821,6 +1001,8 @@ 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 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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=
@@ -874,6 +1056,8 @@ 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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
@@ -894,6 +1078,24 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sundynix-agentix · 运维控制台</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>sundynix agentix · 事件驱动的 AI Agent 工作台</title>
</head>
<body>
<div id="root"></div>
+13
View File
@@ -1,4 +1,14 @@
# 运维控制台静态托管 + 反代网关 API(一个 URL 同时给前端与 /api)。
# WebSocket 升级:nginx 默认会吃掉 Upgrade/Connection 头,被代理的 WS 永远握手不成功
# (现象:HTTP 接口全好,只有 WS 报 ws error)。语音会话 /api/v1/voice/stream 与
# 本地执行器 /api/v1/local/runner 都走 WS,必须显式转发。
# 用 map 而非写死 "upgrade":普通请求带上 Connection: upgrade 会干扰 keepalive。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
@@ -17,6 +27,9 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket(语音会话 / 本地执行器):不转发这两个头则握手必失败。
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# SSE(token 流 / 执行轨迹):关闭缓冲 + 长超时,否则流式会被攒住/断开。
proxy_buffering off;
proxy_cache off;
+357 -3
View File
@@ -8,14 +8,19 @@
"name": "sundynix-admin",
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
"react-router-dom": "^7.1.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -1958,6 +1963,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2089,9 +2114,7 @@
"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"
}
@@ -2278,6 +2301,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2357,6 +2389,44 @@
"node": ">= 6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -2460,6 +2530,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2494,6 +2573,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2516,6 +2601,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -2668,6 +2759,19 @@
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -2717,6 +2821,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2805,6 +2918,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3210,6 +3332,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3220,6 +3354,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -3374,6 +3517,42 @@
"node": ">=12.20.0"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -3387,6 +3566,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3441,6 +3629,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -3630,6 +3827,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3765,6 +3979,15 @@
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3775,6 +3998,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3947,6 +4176,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -3984,6 +4219,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -4040,6 +4301,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -4274,6 +4545,13 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -5086,6 +5364,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"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",
@@ -5103,6 +5387,35 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -5120,12 +5433,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+6 -1
View File
@@ -11,14 +11,19 @@
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
"react-router-dom": "^7.1.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+31 -5
View File
@@ -1,11 +1,19 @@
import { useEffect, useState } from "react";
import { HashRouter } from "react-router-dom";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AppShell } from "./shell/AppShell";
import { Login } from "./Login";
import { me, clearToken, type AuthUser } from "./api";
// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置
export default function App() {
// 官网落地页(融进本工程,src/site/),挂根 /
import { SiteLayout } from "./site/components/layout/site-layout";
import HomePage from "./site/pages/home";
import DownloadPage from "./site/pages/download";
import PricingPage from "./site/pages/pricing";
import NotFoundPage from "./site/pages/not-found";
// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。
// 未登录 → Login;登录后 → AppShell(其内部再渲染 /admin/* 子路由)。
function AdminGate() {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
@@ -26,7 +34,6 @@ export default function App() {
return <Login onAuthed={setUser} />;
}
return (
<HashRouter>
<AppShell
user={user}
onLogout={() => {
@@ -34,6 +41,25 @@ export default function App() {
setUser(null);
}}
/>
</HashRouter>
);
}
// 用 BrowserRouter(路径式):/ = 官网、/admin = 运维后台。
// gateway 的 NoRoute 对非 /api、非文件路径回退 index.html,故深链/刷新都能工作。
export default function App() {
return (
<BrowserRouter>
<Routes>
{/* 后台:/admin 前缀,最具体,优先匹配 */}
<Route path="/admin/*" element={<AdminGate />} />
{/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer */}
<Route element={<SiteLayout />}>
<Route index element={<HomePage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="download" element={<DownloadPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
+357 -6
View File
@@ -71,7 +71,7 @@ export async function me(): Promise<AuthUser | null> {
}
// ---- 模型配置(id 为雪花字符串)----
export type Kind = "chat" | "embedding";
export type Kind = "chat" | "voice" | "embedding";
export interface Model {
id: string;
@@ -227,6 +227,77 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
}
// ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)----
// ---- 微信扫码登录配置 ----
export interface WechatMPConfig {
appid: string;
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
app_secret: string; // 单管理员后台:明文回显
welcome: string; // 关注后自动回复的欢迎语(空=用默认)
enabled: boolean;
}
export interface WechatUserRow {
id: string;
name: string;
wechat_openid: string;
created_at: string;
tenant_name: string;
balance_micro: number;
}
export async function adminWechatUsers(): Promise<WechatUserRow[]> {
const res = guard(await fetch(`${ADMIN}/wechat-users`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { users?: WechatUserRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `wechat users failed: ${res.status}`);
return d.users ?? [];
}
export async function getWechatMP(): Promise<WechatMPConfig> {
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", welcome: d.welcome ?? "", enabled: !!d.enabled };
}
// app_secret 传空串 = 沿用已保存的。
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string; welcome: string }): Promise<{ enabled: boolean }> {
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
return { enabled: !!d.enabled };
}
// —— 语音(火山豆包)配置 · 新版 API Key 鉴权 ——
export interface VoiceConfig {
api_key: string; // 明文回显(单管理员后台)
asr_resource_id: string;
tts_resource_id: string;
tts_voice_type: string;
asr_enabled: boolean;
tts_enabled: boolean;
}
export async function getVoiceConfig(): Promise<VoiceConfig> {
const res = guard(await fetch(`${ADMIN}/voice`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as Partial<VoiceConfig> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `voice config failed: ${res.status}`);
return {
api_key: d.api_key ?? "", asr_resource_id: d.asr_resource_id ?? "",
tts_resource_id: d.tts_resource_id ?? "", tts_voice_type: d.tts_voice_type ?? "",
asr_enabled: !!d.asr_enabled, tts_enabled: !!d.tts_enabled,
};
}
// api_key 传空串 = 沿用已保存的。
export async function saveVoiceConfig(body: {
api_key: string; asr_resource_id: string; tts_resource_id: string; tts_voice_type: string;
}): Promise<{ asr_enabled: boolean; tts_enabled: boolean }> {
const res = guard(await fetch(`${ADMIN}/voice`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
const d = (await res.json().catch(() => ({}))) as { asr_enabled?: boolean; tts_enabled?: boolean; error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
return { asr_enabled: !!d.asr_enabled, tts_enabled: !!d.tts_enabled };
}
export interface WechatPayConfig {
mchid: string;
cert_serial: string;
@@ -235,7 +306,7 @@ export interface WechatPayConfig {
public_key_id: string; // PUB_KEY_ID_ 开头
appid: string;
notify_url: string;
has_apiv3_key: boolean;
apiv3_key: string; // 单管理员后台:明文回显
}
export async function getWechatPay(): Promise<{ config: WechatPayConfig; enabled: boolean; reason: string }> {
@@ -301,6 +372,184 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
return { diffs: d.diffs ?? [], ok: !!d.ok };
}
// ---- 支付联调:走「用户面」的真实下单/查单接口 ----
// 刻意不做 admin 专用旁路:验的就是用户真实走的那条路(下单 → 渠道 → 回调/查单 → 入账),
// 造条测试专线的话验过了也不代表线上通。因此这两个打的是 /api/v1/billing/*(非 /admin/*),
// 租户由服务端按当前登录用户解析,无需带租户头。
export interface TestOrder {
order_id: string;
code_url: string;
amount_fen: number;
expires_at: string;
}
export async function createPayOrder(packId: string): Promise<TestOrder> {
const res = guard(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ pack_id: packId }),
}),
);
const d = (await res.json().catch(() => ({}))) as Partial<TestOrder> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `下单失败: ${res.status}`);
return d as TestOrder;
}
// warn:已付但金额与订单不符 —— 服务端不入账、挂起人工核对,订单会一直停在 pending。
export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder; warn?: string }> {
const res = guard(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { order?: PayOrder; warn?: string; error?: string };
if (!res.ok) throw new Error(d.error ?? `查询失败: ${res.status}`);
return { order: d.order as PayOrder, warn: d.warn };
}
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
export interface SubPlan {
id: string;
name: string;
price_fen: number;
duration_days: number; // 一个周期多少天
refill_credits_micro: number; // 每次发放的积分
refill_interval_days: number; // 每几天发一次
active: boolean;
sort: number;
}
export interface SubRow {
id: string;
tenant_id: string;
tenant_name: string;
plan_name: string;
status: string; // active / expired
started_at: string;
expires_at: string;
refill_seq: number; // 已发放次数
}
export async function adminSubPlans(): Promise<SubPlan[]> {
const res = guard(await fetch(`${ADMIN}/sub-plans`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { plans?: SubPlan[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `sub plans failed: ${res.status}`);
return d.plans ?? [];
}
// refill_credits 以「积分」为单位(面向人),服务端转 micro。
export async function saveSubPlan(p: {
id?: string;
name: string;
price_fen: number;
duration_days: number;
refill_credits: number;
refill_interval_days: number;
active: boolean;
sort: number;
}): Promise<void> {
const res = guard(await fetch(`${ADMIN}/sub-plans`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
const d = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
}
export async function adminSubscriptions(): Promise<SubRow[]> {
const res = guard(await fetch(`${ADMIN}/subscriptions`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { subscriptions?: SubRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `subscriptions failed: ${res.status}`);
return d.subscriptions ?? [];
}
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
const res = guard(await fetch(`${ADMIN}/orders/${id}/refund`, {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ memo }),
}));
const d = (await res.json().catch(() => ({}))) as { status?: string; detail?: string; error?: string };
if (!res.ok) throw new Error(d.error ?? `refund failed: ${res.status}`);
return { status: d.status ?? "", detail: d.detail };
}
// ---- 全平台任务/运行观测(管理端,来自 sundynix_task 跨租户)----
export interface AdminTask {
task_id: string;
tenant_id: string;
tenant_name: string;
owner: string;
owner_email: string;
status: string;
detail: string;
topic: string;
at: string; // RFC3339
eval_level: string;
eval_overall: number;
}
export interface AdminEval {
overall: number;
rule: number;
llm: number;
faithful: number;
level: string;
flags: string; // JSON 数组字符串
reason: string;
sources: number;
corrected: boolean;
}
export interface AdminTaskDetail extends AdminTask {
graph: string;
output: string;
trace: string;
eval: AdminEval | null;
}
// 任务下钻。走 /admin 而非用户面的 /tasks/:id/replay —— 后者受租户插件过滤,
// 管理员看别的租户的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
export async function adminTaskDetail(id: string): Promise<{ task: AdminTaskDetail; exec: unknown[] }> {
const res = guard(await fetch(`${ADMIN}/tasks/${encodeURIComponent(id)}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { task?: AdminTaskDetail; exec?: unknown[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `task detail failed: ${res.status}`);
return { task: d.task as AdminTaskDetail, exec: d.exec ?? [] };
}
// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。
export async function adminTasks(
status = "",
tenant = "",
limit = 50,
): Promise<{ tasks: AdminTask[]; counts: Record<string, number> }> {
const q = new URLSearchParams();
if (status) q.set("status", status);
if (tenant) q.set("tenant", tenant);
q.set("limit", String(limit));
const res = guard(await fetch(`${ADMIN}/tasks?${q.toString()}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { tasks?: AdminTask[]; counts?: Record<string, number>; error?: string };
if (!res.ok) throw new Error(d.error ?? `tasks failed: ${res.status}`);
return { tasks: d.tasks ?? [], counts: d.counts ?? {} };
}
// ---- 全平台空间(Space)观测(管理端,跨租户 sundynix_space----
export interface AdminSpace {
id: string;
tenant_id: string;
tenant_name: string;
name: string;
kind: string; // personal / project / tenant
creator: string;
creator_email: string;
archived: boolean;
members: number;
created_at: string;
}
export async function adminSpaces(limit = 200): Promise<AdminSpace[]> {
const res = guard(await fetch(`${ADMIN}/spaces?limit=${limit}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { spaces?: AdminSpace[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `spaces failed: ${res.status}`);
return d.spaces ?? [];
}
// ---- 自动评测观测(真数据,来自 sundynix_eval----
export interface EvalDay {
day: string; // YYYYMMDD
@@ -369,6 +618,8 @@ export async function guardrailEvents(limit = 100): Promise<GuardrailEvent[]> {
export interface DatasourceKB {
id: string;
name: string;
/** 检索作用域键前半段:三库里的库名是 "<space_id>/<name>",检索试验台按此定位。 */
space_id: string;
kind: string;
tenant_id: string;
tenant_name: string;
@@ -377,6 +628,45 @@ export interface DatasourceKB {
total_words: number;
}
/** 检索命中:text=命中片段,score=该路的相似度/融合分(不同 mode 分数体系不同,勿跨路比较)。 */
export interface KbHit {
text: string;
score: number;
}
/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank)""=生产链路(混合+rerank)。 */
export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid";
/** RouteDiag 一路召回的诊断。三种"空"必须能分辨:没配置(disabled)、报错(error)、确实没匹配(empty)。 */
export interface RouteDiag {
name: "vector" | "fulltext" | "graph";
status: "ok" | "empty" | "disabled" | "error";
hits: number;
ms: number;
error?: string;
note?: string;
}
// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。返回命中 + 每一路的诊断。
export async function adminKbSearch(
kb: string,
q: string,
topK = 5,
mode: SearchMode = "",
): Promise<{ hits: KbHit[]; routes: RouteDiag[] }> {
const res = guard(
await fetch(`${ADMIN}/kb/search`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ kb, q, topK, mode }),
}),
);
const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; routes?: RouteDiag[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`);
return { hits: d.hits ?? [], routes: d.routes ?? [] };
}
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string };
@@ -411,11 +701,27 @@ export interface ToolGroup {
up: boolean;
tools: ToolInfo[] | null;
}
export interface NatsStreamHealth {
name: string;
leader: string;
replicas_healthy: number;
replicas_total: number;
messages: number;
}
export interface NatsClusterStatus {
connected: boolean;
connected_to: string;
known_servers: number;
rtt_ms: number;
streams: NatsStreamHealth[] | null;
degraded: number;
}
export interface SystemStatus {
checked_at: string;
infra: StatusItem[];
services: StatusItem[];
tools: ToolGroup[];
nats?: NatsClusterStatus;
}
export async function getStatus(): Promise<SystemStatus> {
@@ -524,8 +830,20 @@ export interface GuardrailEventItem {
at: string;
}
export async function listAudit(limit = 50, offset = 0): Promise<AuditEntry[]> {
const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
// 筛选走服务端(全库匹配)。此前是取回一页再在前端过滤,翻页外的记录搜不到——
// 对审计来说,“搜不到”会被当成“没发生过”,是会误导结论的。
export interface AuditQuery {
action?: string; // HTTP 方法,精确
path?: string; // 路径前缀
q?: string; // actor / ip / detail / path 模糊
}
export async function listAudit(limit = 50, offset = 0, f: AuditQuery = {}): Promise<AuditEntry[]> {
const p = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (f.action) p.set("action", f.action);
if (f.path) p.set("path", f.path);
if (f.q?.trim()) p.set("q", f.q.trim());
const res = guard(await fetch(`${ADMIN}/audit?${p}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`audit failed: ${res.status}`);
return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
}
@@ -674,8 +992,11 @@ export async function listTenants(): Promise<TenantRow[]> {
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
}
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
export async function createTenant(name: string, slug: string, ownerEmail?: string, plan?: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants`, {
method: "POST", headers: authHeaders(true),
body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "", plan: plan ?? "free" }),
}));
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 失败 → 当提示
@@ -718,3 +1039,33 @@ export async function setSharedBilling(tenantId: string, on: boolean): Promise<v
throw new Error(d.error ?? `set shared billing failed: ${res.status}`);
}
}
// —— KB 存储迁移(管理员一次性运维操作:owner/kb 作用域 → space_id/kb 作用域)——
export interface MigrateKbResult {
total: number; // 扫描文档总数
enqueued: number; // 成功入队重灌的文档数
skipped: number; // 已是新作用域跳过数
}
export async function migrateKbStorage(): Promise<MigrateKbResult> {
const res = guard(await fetch(`${ADMIN}/migrate-kb-storage`, { method: "POST", headers: authHeaders(true) }));
const d = (await res.json().catch(() => ({}))) as Partial<MigrateKbResult> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `migrate kb failed: ${res.status}`);
return { total: d.total ?? 0, enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
}
export async function setTenantPlan(tenantId: string, plan: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/plan`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ plan }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `set tenant plan failed: ${res.status}`);
}
}
export async function setTenantStatus(tenantId: string, status: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/status`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ status }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `set tenant status failed: ${res.status}`);
}
}
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { grantCredits, type TenantRow } from "../api";
// GrantCreditsModal:替换 window.prompt 的完整充值弹窗。
// 支持正数充值 / 负数人工校正;带操作原因 + 备注 + 风险提示 + 汇率说明。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const REASONS = [
"线下回款核销",
"补偿发放(服务故障)",
"测试赠送",
"人工校正(超扣补偿)",
"合同预付款",
"其它(见备注)",
];
interface Props {
tenant: TenantRow | null;
onClose: () => void;
onDone: () => void;
}
export function GrantCreditsModal({ tenant, onClose, onDone }: Props) {
const [amount, setAmount] = useState("100");
const [reason, setReason] = useState(REASONS[0]);
const [memo, setMemo] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const amountRef = useRef<HTMLInputElement>(null);
// 每次打开聚焦数量输入框
useEffect(() => {
if (tenant) {
setAmount("100");
setReason(REASONS[0]);
setMemo("");
setErr("");
setTimeout(() => amountRef.current?.select(), 50);
}
}, [tenant]);
if (!tenant) return null;
const n = Number(amount);
const isValid = !isNaN(n) && n !== 0;
const handleConfirm = async () => {
if (!isValid) {
setErr("积分数量不能为 0");
return;
}
setSaving(true);
setErr("");
try {
const finalMemo = reason === "其它(见备注)" ? memo : reason + (memo ? `${memo}` : "");
await grantCredits(tenant.id, n, finalMemo);
onDone();
onClose();
} catch (e) {
setErr((e as Error).message);
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
{/* 头部 */}
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900"></h3>
<p className="mt-0.5 text-xs text-gray-400"></p>
</div>
<button onClick={onClose} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
</div>
{/* 内容 */}
<div className="space-y-4 px-6 py-5">
{/* 租户信息 */}
<div className="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50 p-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-100 text-sm font-bold text-violet-700">
{tenant.name.charAt(0)}
</span>
<div>
<div className="text-sm font-semibold text-gray-800">{tenant.name}</div>
<div className="font-mono text-xs text-gray-400">
<span className="font-medium text-violet-600">{credits(tenant.credit_balance_micro)}</span>
</div>
</div>
</div>
{/* 积分数量 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600"></label>
<input
ref={amountRef}
type="number"
value={amount}
onChange={(e) => { setAmount(e.target.value); setErr(""); }}
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
placeholder="正数=充值,负数=扣减/校正"
/>
<p className="mt-1 text-xs text-gray-400">=/=/1 = 1000 token</p>
</div>
{/* 操作原因 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600"></label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
>
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
{/* 备注 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600">
{reason === "其它(见备注)" && <span className="text-rose-500"> *</span>}
</label>
<input
type="text"
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="如:2026-07-18 线下转账 ¥500 核销"
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
/>
</div>
{/* 风险提示 */}
<div className="rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-700">
</div>
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
</div>
{/* 底部 */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<span className="text-xs text-gray-400"></span>
<div className="flex gap-2">
<button onClick={onClose} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">
</button>
<button
onClick={() => void handleConfirm()}
disabled={saving || !isValid}
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-40"
>
{saving ? "发放中…" : "确认发放"}
</button>
</div>
</div>
</div>
</div>
);
}
+39 -5
View File
@@ -37,10 +37,18 @@ export function ModelManager({
const set = (k: keyof ModelInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
const editing = !!form.id;
// onEdit 把某行载入表单就地编辑(api_key 留空=沿用现有密钥,后端按 id 更新而非新增)。
const onEdit = (m: Model) => {
setForm({ id: m.id, kind, provider: m.provider, base_url: m.base_url, api_key: "", model: m.model });
setMsg("");
};
const onSave = async () => {
try {
await saveModel({ ...form, kind });
setMsg("✓ 已保存");
setMsg(editing ? "✓ 已更新并热更新" : "✓ 已保存");
setForm(empty);
refresh();
} catch (e) {
@@ -108,7 +116,21 @@ export function ModelManager({
</button>
)}
<button
onClick={() => deleteModel(m.id).then(refresh)}
onClick={() => onEdit(m)}
className="rounded border px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
</button>
<button
onClick={() => {
// 删除不可撤销,且删掉在用模型会直接打断线上推理——所以要二次确认,
// 且把"这条正在用"讲明白(此前是一点就删,无任何提示)。
const warn = m.active
? `${m.model}」正在使用中,删除后该 ${m.kind === "embedding" ? "向量" : "对话"}能力立即不可用。`
: `将删除「${m.model}」(${m.provider})。`;
if (!window.confirm(`${warn}\n此操作不可撤销,确定继续?`)) return;
void deleteModel(m.id).then(refresh);
}}
className="rounded border px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-50"
>
@@ -123,7 +145,9 @@ export function ModelManager({
</section>
<section className="max-w-xl">
<h2 className="mb-3 text-sm font-semibold text-gray-700">线 APIOpenAI </h2>
<h2 className="mb-3 text-sm font-semibold text-gray-700">
{editing ? `编辑「${form.model}」(改完保存即热更新)` : "登记(开发期:第三方在线 APIOpenAI 兼容)"}
</h2>
<div className="grid grid-cols-2 gap-3">
<label className="text-xs text-gray-500">
Provider
@@ -162,12 +186,14 @@ export function ModelManager({
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
value={form.api_key}
onChange={(e) => set("api_key", e.target.value)}
placeholder="sk-…"
placeholder={editing ? "留空=沿用现有密钥" : "sk-…"}
/>
</label>
</div>
<div className="mt-3 flex items-center gap-2">
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white"></button>
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">
{editing ? "更新" : "保存"}
</button>
<button
onClick={onTest}
disabled={testing || !form.base_url}
@@ -175,6 +201,14 @@ export function ModelManager({
>
{testing ? "测试中…" : "测试连接"}
</button>
{editing && (
<button
onClick={() => { setForm(empty); setMsg(""); }}
className="rounded border px-3 py-1 text-sm text-gray-500 hover:bg-gray-50"
>
</button>
)}
{msg && <span className="text-xs text-gray-600">{msg}</span>}
</div>
</section>
+110 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { adminOrders, adminReconcile, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
import { useCallback, useEffect, useRef, useState } from "react";
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
// - 状态计数卡片(pending/paid/expired + 累计到账额)
@@ -27,6 +28,13 @@ export function OrderStream() {
// 对账结果:null=未跑;[]=零差异;有元素=有差异
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
const [checking, setChecking] = useState(false);
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
// 退款 Modal
const [refundTarget, setRefundTarget] = useState<PayOrder | null>(null);
const [refundMemo, setRefundMemo] = useState("");
const [refundErr, setRefundErr] = useState("");
const refundMemoRef = useRef<HTMLTextAreaElement>(null);
const load = useCallback(() => {
adminOrders(filter)
@@ -51,8 +59,94 @@ export function OrderStream() {
}
};
const openRefund = (o: PayOrder) => {
setRefundTarget(o);
setRefundMemo("");
setRefundErr("");
setTimeout(() => refundMemoRef.current?.focus(), 50);
};
const doRefund = async () => {
if (!refundTarget) return;
if (!refundMemo.trim()) { setRefundErr("退款原因不能为空"); return; }
setRefunding(refundTarget.id);
setRefundErr("");
try {
const r = await adminRefundOrder(refundTarget.id, refundMemo.trim());
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
setRefundTarget(null);
load();
} catch (e) {
setRefundErr((e as Error).message);
} finally {
setRefunding("");
}
};
return (
<div className="space-y-4">
{/* 退款 Modal */}
{refundTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && setRefundTarget(null)}
>
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900">退</h3>
<p className="mt-0.5 text-xs text-gray-400"></p>
</div>
<button onClick={() => setRefundTarget(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
</div>
<div className="space-y-4 px-6 py-5">
{/* 订单摘要 */}
<div className="space-y-2 rounded-xl border border-gray-100 bg-gray-50 p-3 text-sm">
<div className="flex justify-between text-xs uppercase tracking-wide text-gray-400">
<span></span>
<span className="font-mono">{refundTarget.id.slice(-12)}</span>
</div>
<div className="flex justify-between"><span className="text-gray-600"></span><span className="font-medium">{refundTarget.tenant_name || refundTarget.tenant_id}</span></div>
<div className="flex justify-between"><span className="text-gray-600"></span><span className="font-medium text-gray-800">{credits(refundTarget.credits_micro)} </span></div>
{refundTarget.amount_fen > 0 && <div className="flex justify-between"><span className="text-gray-600"></span><span className="font-medium text-emerald-600">{yuan(refundTarget.amount_fen)}</span></div>}
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600">退</label>
<textarea
ref={refundMemoRef}
rows={3}
value={refundMemo}
onChange={(e) => { setRefundMemo(e.target.value); setRefundErr(""); }}
placeholder="请说明退款原因,此内容将写入审计日志…"
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
/>
</div>
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs text-rose-700 space-y-1">
<div className="font-semibold"> </div>
<div>· <strong>{credits(refundTarget.credits_micro)} </strong></div>
<div>· 退</div>
<div>· </div>
</div>
{refundErr && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{refundErr}</div>}
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<span className="text-xs text-gray-400"></span>
<div className="flex gap-2">
<button onClick={() => setRefundTarget(null)} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50"></button>
<button
onClick={() => void doRefund()}
disabled={!!refunding || !refundMemo.trim()}
className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-40"
>
{refunding ? "退款中…" : "确认退款"}
</button>
</div>
</div>
</div>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400"> + </span>
@@ -117,7 +211,8 @@ export function OrderStream() {
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
@@ -128,16 +223,26 @@ export function OrderStream() {
<td className="py-1.5 pr-3 text-xs text-gray-500">{CHANNEL_LABEL[o.channel] ?? o.channel}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(o.credits_micro)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-500">{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"}</td>
<td className="py-1.5">
<td className="py-1.5 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_BADGE[o.status] ?? "bg-gray-100 text-gray-500"}`}>
{STATUS_LABEL[o.status] ?? o.status}
</span>
</td>
<td className="py-1.5 text-right">
{o.status === "paid" ? (
<button onClick={() => openRefund(o)} disabled={refunding === o.id}
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
{refunding === o.id ? "退款中…" : "退款"}
</button>
) : (
<span className="text-[11px] text-gray-300"></span>
)}
</td>
</tr>
))}
{orders.length === 0 && (
<tr>
<td colSpan={6} className="py-6 text-center text-xs text-gray-400"></td>
<td colSpan={7} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { createPayOrder, payOrderStatus, type CreditPack, type PayOrder } from "../api";
// 支付联调:在管理端就地下一笔**真单**并盯到终态,用来验证「渠道配置 → 下单 → 扫码付款
// → 回调/查单 → 入账」整条链路在当前环境真的通。
//
// 刻意走用户面的 /api/v1/billing/* 而非 admin 旁路:造条测试专线的话,验过了也不能
// 说明线上用户那条路通。代价是这会真扣钱 —— 所以 UI 上必须讲清楚,并给出退款去处。
//
// 轮询策略与 sundynix-web 的支付弹窗一致:递归 setTimeout(不让慢请求摞起来)、
// 切后台暂停(扫完码要去微信 App)、失败指数退避、倒计时以服务端 expires_at 为准。
const POLL_BASE_MS = 2500;
const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
type Phase = "creating" | "waiting" | "paid" | "expired";
export function PayProbeDialog({ pack, onClose }: { pack: CreditPack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [warn, setWarn] = useState("");
const [phase, setPhase] = useState<Phase>("creating");
const [order, setOrder] = useState<PayOrder | null>(null);
const [left, setLeft] = useState(0);
const [polls, setPolls] = useState(0); // 轮询次数:链路是否在动,一眼可见
const orderRef = useRef("");
const expiresRef = useRef(0);
useEffect(() => {
let alive = true;
let timer: number | null = null;
let failures = 0;
const stop = () => {
if (timer) window.clearTimeout(timer);
timer = null;
};
const schedule = (ms: number) => {
stop();
timer = window.setTimeout(() => void tick(), ms);
};
const tick = async () => {
if (!alive) return;
if (document.hidden) return schedule(POLL_BASE_MS);
try {
const { order: o, warn: w } = await payOrderStatus(orderRef.current);
if (!alive) return;
failures = 0;
setPolls((n) => n + 1);
setOrder(o);
setWarn(w ?? "");
if (o.status === "paid") {
setPhase("paid");
stop();
return;
}
if (o.status === "expired" || o.status === "failed") {
setPhase("expired");
stop();
return;
}
schedule(POLL_BASE_MS);
} catch (e) {
if (!alive) return;
failures += 1;
setErr((e as Error).message);
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
}
};
const onVisible = () => {
if (!document.hidden && alive && orderRef.current) schedule(0);
};
document.addEventListener("visibilitychange", onVisible);
void (async () => {
try {
const o = await createPayOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime();
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 }));
setPhase("waiting");
schedule(POLL_BASE_MS);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
stop();
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
useEffect(() => {
if (phase !== "waiting") return;
const t = window.setInterval(() => {
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
setLeft(s);
if (s === 0) setPhase("expired");
}, 1000);
return () => window.clearInterval(t);
}, [phase]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl">
<div className="flex items-baseline justify-between">
<h4 className="text-sm font-semibold text-gray-800"> · {pack.name}</h4>
<button onClick={() => onClose(phase === "paid")} className="text-xs text-gray-400 hover:text-gray-600">
</button>
</div>
<p className="mt-1 rounded-md bg-amber-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-700">
<b></b> · 退
</p>
<div className="mt-4 flex flex-col items-center gap-3">
{err && !qr ? (
<p className="py-10 text-center text-xs text-rose-500">{err}</p>
) : phase === "creating" ? (
<p className="py-16 text-xs text-gray-400"></p>
) : (
<>
<div className="relative rounded-lg border border-gray-100 bg-white p-2">
<img src={qr} alt="微信支付二维码" width={220} height={220} />
{(phase === "expired" || phase === "paid") && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-white/85 text-sm font-medium text-gray-700">
{phase === "paid" ? "✅ 已入账" : "二维码已失效"}
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-gray-800">¥{(pack.price_fen / 100).toFixed(2)}</div>
{phase === "waiting" && <div className="mt-0.5 text-[11px] tabular-nums text-gray-400"> {mmss(left)} </div>}
</div>
</>
)}
{warn && (
<p className="w-full rounded-md bg-amber-50 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-700">{warn}</p>
)}
{/* 链路证据:这些字段就是判断「哪一环通了」的依据,比一句“成功”有用 */}
{orderRef.current && (
<dl className="w-full space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
<Row k="订单号" v={<span className="font-mono">{orderRef.current}</span>} />
<Row
k="订单状态"
v={
<span
className={
phase === "paid" ? "font-medium text-emerald-600" : phase === "expired" ? "text-gray-400" : "text-amber-600"
}
>
{order?.status ?? "pending"}
{phase === "waiting" && " · 等待扫码支付"}
</span>
}
/>
<Row k="轮询次数" v={<span className="tabular-nums">{polls}</span>} />
{order?.credits_micro != null && phase === "paid" && (
<Row k="到账积分" v={<span className="tabular-nums text-emerald-600">{(order.credits_micro / 1e6).toLocaleString()}</span>} />
)}
{err && <Row k="最近错误" v={<span className="text-rose-500">{err}</span>} />}
</dl>
)}
{phase === "paid" && (
<p className="text-center text-[11px] leading-relaxed text-emerald-600">
/
</p>
)}
{phase === "waiting" && (
<p className="text-center text-[11px] leading-relaxed text-gray-400">
</p>
)}
</div>
</div>
</div>
);
}
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-gray-400">{k}</dt>
<dd className="truncate text-gray-700">{v}</dd>
</div>
);
}
@@ -0,0 +1,230 @@
import { useState } from "react";
import { adminKbSearch, type DatasourceKB, type KbHit, type RouteDiag, type SearchMode } from "../api";
// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
// 图谱路空=Neo4j 未起或未抽三元组;融合有而生产为空=rerank 把结果滤掉了。
//
// ⚠️ 分数不可跨路比较:向量是余弦相似度(0~1),RRF 是 1/(k+rank) 的融合分(通常 <0.05)。
const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
{ mode: "vector", label: "向量", hint: "Milvus · 语义相似" },
{ mode: "fulltext", label: "全文", hint: "Bleve · 关键词倒排" },
{ mode: "graph", label: "图谱", hint: "Neo4j · 实体关系" },
{ mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
];
type Results = Partial<Record<string, { hits: KbHit[]; routes?: RouteDiag[]; err?: string }>>;
// 每一路的诊断徽章。三种"空"必须分得清 —— 没配置 / 报错 / 确实没匹配,
// 以前它们在界面上长得一模一样(都是 0 条),排查时只能靠猜。
const STATUS_TONE: Record<string, { label: string; cls: string }> = {
ok: { label: "正常", cls: "bg-emerald-50 text-emerald-600" },
empty: { label: "无匹配", cls: "bg-gray-100 text-gray-500" },
disabled: { label: "未启用", cls: "bg-amber-50 text-amber-700" },
error: { label: "报错", cls: "bg-rose-50 text-rose-600" },
};
const ROUTE_CN: Record<string, string> = { vector: "向量", fulltext: "全文", graph: "图谱" };
export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
const withDocs = kbs.filter((k) => k.doc_count > 0);
const [kbKey, setKbKey] = useState("");
const [q, setQ] = useState("");
const [topK, setTopK] = useState(5);
const [res, setRes] = useState<Results>({});
const [running, setRunning] = useState(false);
const [ran, setRan] = useState(false);
const run = async () => {
if (!kbKey || !q.trim()) return;
setRunning(true);
setRan(true);
// 生产链路 + 四路诊断并行跑,各自独立成败(一路挂不影响其它路展示)。
const modes: SearchMode[] = ["", ...ROUTES.map((r) => r.mode)];
const settled = await Promise.all(
modes.map(async (m) => {
try {
return [m, await adminKbSearch(kbKey, q.trim(), topK, m)] as const;
} catch (e) {
return [m, { hits: [], err: (e as Error).message }] as const;
}
}),
);
setRes(Object.fromEntries(settled));
setRunning(false);
};
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-1 flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"> query </span>
</div>
{/* 查询条件 */}
<div className="mt-4 flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400"></span>
<select
value={kbKey}
onChange={(e) => setKbKey(e.target.value)}
className="w-64 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
>
<option value=""></option>
{withDocs.map((k) => (
<option key={k.id} value={`${k.space_id}/${k.name}`}>
{k.name} · {k.tenant_name || k.tenant_id} ({k.doc_count} )
</option>
))}
</select>
</label>
<label className="flex flex-1 flex-col gap-1" style={{ minWidth: 220 }}>
<span className="text-[11px] text-gray-400"></span>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void run()}
placeholder="输入一个真实用户会问的问题…"
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400">topK</span>
<input
type="number"
min={1}
max={20}
value={topK}
onChange={(e) => setTopK(Math.max(1, Math.min(20, Number(e.target.value) || 5)))}
className="w-20 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
/>
</label>
<button
onClick={() => void run()}
disabled={running || !kbKey || !q.trim()}
className="rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40"
>
{running ? "检索中…" : "跑一遍"}
</button>
</div>
{withDocs.length === 0 && (
<p className="mt-3 text-xs text-amber-600"></p>
)}
{ran && (
<div className="mt-5 space-y-4">
{/* 各路健康状况:先回答"哪一路能用",再看"召回了什么" */}
{(() => {
const routes = res[""]?.routes ?? res["hybrid"]?.routes ?? [];
if (!routes.length) return null;
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400"></span>
{routes.map((r) => {
const tone = STATUS_TONE[r.status] ?? STATUS_TONE.empty;
return (
<span
key={r.name}
title={r.error || r.note || ""}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] ${tone.cls}`}
>
{ROUTE_CN[r.name] ?? r.name} · {tone.label}
<span className="tabular-nums opacity-60">{r.hits}/{r.ms}ms</span>
</span>
);
})}
</div>
);
})()}
{/* 非正常路的原因写全,别让人去翻日志 */}
{(res[""]?.routes ?? res["hybrid"]?.routes ?? [])
.filter((r) => r.status === "disabled" || r.status === "error")
.map((r) => (
<p key={r.name} className={`text-[11px] ${r.status === "error" ? "text-rose-600" : "text-amber-700"}`}>
{ROUTE_CN[r.name] ?? r.name}{r.status === "error" ? "报错" : "未启用"}{r.error || r.note}
</p>
))}
{/* 生产链路:用户实际拿到的结果 */}
<RouteCard
label="生产链路"
hint="混合检索 + rerank —— Agent 实际拿到的就是这个"
data={res[""]}
highlight
/>
{/* 诊断四路 */}
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{ROUTES.map((r) => {
// 单路那次请求里,取它自己那一路的诊断(RRF 融合没有单独一路,故为空)
const diag = res[r.mode]?.routes?.find((d) => d.name === r.mode);
return <RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} diag={diag} />;
})}
</div>
</div>
</div>
)}
</div>
);
}
function RouteCard({
label,
hint,
data,
diag,
highlight,
}: {
label: string;
hint: string;
data?: { hits: KbHit[]; routes?: RouteDiag[]; err?: string };
diag?: RouteDiag;
highlight?: boolean;
}) {
const hits = data?.hits ?? [];
return (
<div className={`rounded-lg border p-3 ${highlight ? "border-violet-200 bg-violet-50/40" : "border-gray-100 bg-gray-50/40"}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-700">{label}</span>
<span className={`text-[11px] tabular-nums ${hits.length ? "text-emerald-600" : "text-gray-400"}`}>
{hits.length}
</span>
</div>
<div className="mt-0.5 text-[10px] text-gray-400">{hint}</div>
{data?.err ? (
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
) : hits.length === 0 ? (
// 空的原因比"空"本身重要:没配置 / 报错 / 确实没匹配,处理方式完全不同
<p className={`mt-2 text-[11px] leading-relaxed ${diag?.status === "error" ? "text-rose-500" : diag?.status === "disabled" ? "text-amber-600" : "text-gray-400"}`}>
{diag?.status === "disabled"
? `未启用:${diag.note || "该路未配置"}`
: diag?.status === "error"
? `报错:${diag.error}`
: diag?.note || "这一路没召回(索引里确实没有匹配内容)"}
</p>
) : (
<ol className="mt-2 space-y-1.5">
{hits.map((h, i) => (
<li key={i} className="text-[11px] leading-relaxed">
<div className="flex items-baseline gap-1.5">
<span className="shrink-0 text-gray-300">#{i + 1}</span>
<span className="shrink-0 tabular-nums text-violet-600">{h.score.toFixed(4)}</span>
</div>
<p className="mt-0.5 line-clamp-3 text-gray-600" title={h.text}>
{h.text}
</p>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api";
// 任务下钻:点列表一行看这一次运行到底发生了什么 —— 轨迹、最终输出、评测明细。
//
// 数据全部取自 sundynix_task 收尾时落库的列,不依赖 Redis 流(那个只有 10min TTL
// 历史任务早没了)。所以这里看到的是"复盘"而非"实时",正在跑的任务轨迹会是空的——
// 这一点在 UI 上要讲清楚,否则会被当成"轨迹丢了"。
//
// 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。
// 字段名以库里真实轨迹为准(不是猜的):seq/ts/node/kind/phase/label/detail。
// ts 是毫秒时间戳;kind ∈ system|model|tool|agent|plan|section|render|approval
// phase ∈ info|start|end|await|error。
type ExecEvent = {
seq?: number;
ts?: number;
node?: string;
kind?: string;
phase?: string;
label?: string;
detail?: string;
[k: string]: unknown;
};
const KIND_TONE: Record<string, string> = {
system: "bg-gray-100 text-gray-600",
model: "bg-violet-50 text-violet-600",
tool: "bg-sky-50 text-sky-600",
agent: "bg-indigo-50 text-indigo-600",
plan: "bg-amber-50 text-amber-700",
section: "bg-emerald-50 text-emerald-600",
render: "bg-teal-50 text-teal-600",
approval: "bg-orange-50 text-orange-600",
};
// 只有 error/await 值得跳出来:前者是失败点,后者是卡在人工审批。
const PHASE_TONE: Record<string, string> = {
error: "text-rose-600 font-medium",
await: "text-orange-600",
};
const hhmmss = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString("zh-CN", { hour12: false }) : "");
export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: () => void }) {
const [d, setD] = useState<AdminTaskDetail | null>(null);
const [exec, setExec] = useState<ExecEvent[]>([]);
const [err, setErr] = useState("");
const [tab, setTab] = useState<"trace" | "output" | "eval" | "graph">("trace");
useEffect(() => {
let alive = true;
adminTaskDetail(task.task_id)
.then((r) => {
if (!alive) return;
setD(r.task);
setExec(r.exec as ExecEvent[]);
})
.catch((e) => alive && setErr((e as Error).message));
return () => {
alive = false;
};
}, [task.task_id]);
// flags 是库里存的 JSON 字符串,实测可能是 "null"(不是 "[]")——JSON.parse 出来是 null
// 不校验就会在 flags.length 上抛 TypeError 把整个控制台白屏掉。必须确认真是数组。
const flags: string[] = (() => {
try {
const v: unknown = d?.eval?.flags ? JSON.parse(d.eval.flags) : null;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
})();
return (
<div className="fixed inset-0 z-50 flex justify-end bg-gray-900/30" onClick={onClose}>
<div
className="flex h-full w-full max-w-2xl flex-col bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="border-b border-gray-100 px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-gray-800">{task.topic || "(无主题)"}</h3>
<p className="mt-0.5 font-mono text-[11px] text-gray-400">{task.task_id}</p>
</div>
<button onClick={onClose} className="shrink-0 text-xs text-gray-400 hover:text-gray-600">
</button>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-[11px]">
<Row k="租户" v={task.tenant_name || task.tenant_id || "—"} />
<Row k="提交人" v={task.owner_email || task.owner || "—"} />
<Row k="状态" v={task.status} />
<Row k="提交时间" v={new Date(task.at).toLocaleString("zh-CN")} />
{task.detail && <Row k="失败原因" v={task.detail} span />}
</dl>
</div>
{/* 页签 */}
<div className="flex gap-1 border-b border-gray-100 px-5 pt-3">
{(
[
["trace", `执行轨迹${exec.length ? ` (${exec.length})` : ""}`],
["output", "最终输出"],
["eval", "评测"],
["graph", "DSL"],
] as const
).map(([k, label]) => (
<button
key={k}
onClick={() => setTab(k)}
className={`rounded-t px-3 py-1.5 text-xs ${
tab === k ? "border-b-2 border-violet-500 font-medium text-violet-600" : "text-gray-400 hover:text-gray-600"
}`}
>
{label}
</button>
))}
</div>
{/* 内容 */}
<div className="flex-1 overflow-auto px-5 py-4">
{err && <p className="text-xs text-rose-500">{err}</p>}
{!d && !err && <p className="text-xs text-gray-400"></p>}
{d && tab === "trace" && (
exec.length === 0 ? (
<Empty
text={
task.status === "running" || task.status === "submitted"
? "任务还在跑,轨迹要等收尾才落库(这里是复盘视图,不是实时流)"
: "这次运行没有留下轨迹(早于轨迹落库功能上线,或收尾时落库失败——后者会在 gateway 日志里有 [task] 告警)"
}
/>
) : (
<ol className="space-y-2">
{exec.map((e, i) => (
<li
key={e.seq ?? i}
className={`rounded-lg border p-2.5 text-[11px] ${
e.phase === "error" ? "border-rose-200 bg-rose-50/50" : "border-gray-100 bg-gray-50/60"
}`}
>
<div className="flex items-baseline gap-2">
<span className="shrink-0 tabular-nums text-gray-300">#{e.seq ?? i + 1}</span>
{e.label && <span className={`font-medium text-gray-700 ${PHASE_TONE[e.phase ?? ""] ?? ""}`}>{e.label}</span>}
{e.kind && (
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_TONE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
{e.kind}
</span>
)}
{e.phase && e.phase !== "info" && (
<span className={`text-[10px] ${PHASE_TONE[e.phase] ?? "text-gray-400"}`}>{e.phase}</span>
)}
<span className="ml-auto shrink-0 tabular-nums text-gray-300">{hhmmss(e.ts)}</span>
</div>
{e.node && <div className="mt-0.5 font-mono text-[10px] text-gray-400">{e.node}</div>}
{e.detail && <p className="mt-1 whitespace-pre-wrap break-words text-gray-600">{e.detail}</p>}
{/* 兜底:字段形状变了也别把线索吞掉(上一版就是猜错字段名,白渲染了一列空行) */}
{!e.label && !e.detail && (
<pre className="mt-1 overflow-x-auto text-[10px] text-gray-500">{JSON.stringify(e)}</pre>
)}
</li>
))}
</ol>
)
)}
{d && tab === "output" && (
d.output ? (
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed text-gray-700">{d.output}</pre>
) : (
<Empty text="没有最终输出(任务未完成或失败)" />
)
)}
{d && tab === "eval" && (
d.eval ? (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2">
<Score label="综合" v={d.eval.overall} />
<Score label="规则" v={d.eval.rule} />
<Score label="质量" v={d.eval.llm} />
<Score label="忠实度" v={d.eval.faithful} hint={d.eval.sources === 0 ? "无检索来源,未评" : undefined} />
</div>
<dl className="space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
<Row k="分级" v={d.eval.level} />
<Row k="检索来源数" v={String(d.eval.sources)} />
<Row k="是否纠偏重生成" v={d.eval.corrected ? "是(低分自动纠偏后采纳)" : "否"} />
</dl>
{flags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{flags.map((f) => (
<span key={f} className="rounded bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
{f}
</span>
))}
</div>
)}
{d.eval.reason && (
<p className="whitespace-pre-wrap rounded-lg border border-gray-100 p-3 text-[11px] leading-relaxed text-gray-600">
{d.eval.reason}
</p>
)}
</div>
) : (
<Empty text="尚无评测结果(任务未完成,或评测仍在进行)" />
)
)}
{d && tab === "graph" && (
d.graph ? (
<pre className="overflow-x-auto text-[10px] leading-relaxed text-gray-600">
{(() => {
try {
return JSON.stringify(JSON.parse(d.graph), null, 2);
} catch {
return d.graph; // 解析不了就原样显示,别把排查线索吞掉
}
})()}
</pre>
) : (
<Empty text="没有记录提交时的 DSL" />
)
)}
</div>
</div>
</div>
);
}
function Row({ k, v, span }: { k: string; v: string; span?: boolean }) {
return (
<div className={`flex items-baseline gap-2 ${span ? "col-span-2" : ""}`}>
<dt className="shrink-0 text-gray-400">{k}</dt>
<dd className="min-w-0 break-words text-gray-700">{v}</dd>
</div>
);
}
function Score({ label, v, hint }: { label: string; v: number; hint?: string }) {
return (
<div className="rounded-lg border border-gray-100 p-2 text-center" title={hint}>
<div className="text-[10px] text-gray-400">{label}</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-800">{(v * 100).toFixed(0)}</div>
</div>
);
}
function Empty({ text }: { text: string }) {
return <p className="py-10 text-center text-[11px] leading-relaxed text-gray-400">{text}</p>;
}
+28 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { PayProbeDialog } from "./PayProbeDialog";
import { adminPacks, savePack, genRedeemCodes, listRedeemCodes, getWechatPay, saveWechatPay, type CreditPack, type RedeemCodeRow, type WechatPayConfig } from "../api";
// 充值渠道(P5.1,设计见 PAYMENT_DESIGN.md):
@@ -26,10 +27,9 @@ export function TopupChannels() {
}
// ---- 微信支付配置:DB 存储、保存即热生效(不重启 gateway)。
// APIv3 密钥只写不回显(密文入库,与模型 API Key 同一把密钥加密);
// 商户证书私钥文件放服务器磁盘,这里只填路径。
// APIv3 密钥密文入库,但单管理员后台明文回显方便核对;证书私钥文件放服务器磁盘,此处只填路径。
function WechatConfigBlock() {
const empty: WechatPayConfig = { mchid: "", cert_serial: "", private_key_path: "", public_key_path: "", public_key_id: "", appid: "", notify_url: "", has_apiv3_key: false };
const empty: WechatPayConfig = { mchid: "", cert_serial: "", private_key_path: "", public_key_path: "", public_key_id: "", appid: "", notify_url: "", apiv3_key: "" };
const [cfg, setCfg] = useState<WechatPayConfig>(empty);
const [apiv3, setApiv3] = useState(""); // 留空=沿用已存
const [enabled, setEnabled] = useState(false);
@@ -41,6 +41,7 @@ function WechatConfigBlock() {
getWechatPay()
.then((r) => {
setCfg(r.config);
setApiv3(r.config.apiv3_key);
setEnabled(r.enabled);
setReason(r.reason);
})
@@ -64,8 +65,6 @@ function WechatConfigBlock() {
});
setEnabled(r.enabled);
setReason(r.reason);
setApiv3("");
if (apiv3) setCfg((c) => ({ ...c, has_apiv3_key: true }));
} catch (e) {
setErr((e as Error).message);
} finally {
@@ -107,12 +106,11 @@ function WechatConfigBlock() {
{field("公钥 IDPUB_KEY_ID_ 开头)", "public_key_id", "PUB_KEY_ID_01…")}
{field("微信支付公钥文件路径(服务器磁盘)", "public_key_path", "/etc/sundynix/wechat/pub_key.pem", "md:col-span-2")}
<label className="text-xs text-gray-500">
APIv3 {cfg.has_apiv3_key && <span className="ml-1 text-emerald-600"></span>}
APIv3
<input
type="password"
value={apiv3}
onChange={(e) => setApiv3(e.target.value)}
placeholder={cfg.has_apiv3_key ? "留空则沿用已保存的" : "32 字节"}
placeholder="32 字节"
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none"
/>
</label>
@@ -250,6 +248,7 @@ function RedeemBlock() {
// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ----
function PacksBlock() {
const [rows, setRows] = useState<CreditPack[]>([]);
const [probe, setProbe] = useState<CreditPack | null>(null); // 正在联调的积分包
const [name, setName] = useState("");
const [creditsIn, setCreditsIn] = useState("");
const [yuan, setYuan] = useState("");
@@ -326,7 +325,8 @@ function PacksBlock() {
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
@@ -335,22 +335,39 @@ function PacksBlock() {
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(p.credits_micro)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{(p.price_fen / 100).toFixed(2)}</td>
<td className="py-2 text-right">
<td className="py-2 pr-3 text-right">
<button onClick={() => void toggle(p)}
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
</button>
</td>
<td className="py-2 text-right">
{/* 就地下真单验链路。下架的包下单会被服务端拒,按钮也就没意义 */}
<button onClick={() => setProbe(p)} disabled={!p.active} title={p.active ? "生成付款码,验证支付链路" : "已下架的包不可下单"}
className="rounded border border-violet-200 px-2 py-0.5 text-[11px] text-violet-600 hover:bg-violet-50 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300 disabled:hover:bg-transparent">
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">线</td>
<td colSpan={5} className="py-6 text-center text-xs text-gray-400">线</td>
</tr>
)}
</tbody>
</table>
</div>
{probe && (
<PayProbeDialog
pack={probe}
onClose={() => {
setProbe(null);
void load(); // 联调单也是真单,回来刷一次列表
}}
/>
)}
</div>
);
}
@@ -1,11 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
import { BillingRules } from "../components/BillingRules";
import { TopupChannels } from "../components/TopupChannels";
import { OrderStream } from "../components/OrderStream";
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
import { GrantCreditsModal } from "./GrantCreditsModal";
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果
// 仪表盘的「计费与用量」区块:全平台(或单租户)的积分消耗/Token/成本 + 趋势 + 租户排行
// 自包含取数与筛选,直接塞进仪表盘即可,不需要父级传参。
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
const MICRO = 1_000_000;
@@ -38,7 +37,7 @@ function fillDays(trend: UsageDay[], days: number): UsageDay[] {
}
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
export function UsagePage() {
export function UsageSection() {
const [days, setDays] = useState(30);
const [tenant, setTenant] = useState(""); // "" = 全平台
const [report, setReport] = useState<UsageReport | null>(null);
@@ -46,6 +45,10 @@ export function UsagePage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState("");
// 发放积分 Modal
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
const [allTenants, setAllTenants] = useState<TenantRow[]>([]);
const load = async () => {
setRefreshing(true);
@@ -63,30 +66,34 @@ export function UsagePage() {
}
};
useEffect(() => {
// 预载租户列表用于发放积分 Modal
listTenants().then(setAllTenants).catch(() => {});
}, []);
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, tenant]);
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
const [granting, setGranting] = useState("");
const doGrant = async (tenantId: string, name: string) => {
const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100");
if (s === null) return;
const n = Number(s);
if (!n) return;
setGranting(tenantId);
try {
await grantCredits(tenantId, n, "admin 手动充值");
await load();
} catch (e) {
window.alert((e as Error).message);
} finally {
setGranting("");
const openGrant = (r: UsageTenantSum) => {
const found = allTenants.find((t) => t.id === r.tenant_id);
if (found) {
setGrantTenant(found);
} else {
// 如果全载列表还没到,直接用 UsageTenantSum 构造一个临时对象
setGrantTenant({
id: r.tenant_id, name: r.name, slug: "", plan: "free",
status: "active", credit_balance_micro: r.balance_micro,
shared_billing: false, members: 0,
});
}
};
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
if (!report) return null;
@@ -96,18 +103,12 @@ export function UsagePage() {
return (
<div className="space-y-6">
{/* 配置端:计费规则(改规则即对后续任务生效) */}
<BillingRules onSaved={() => void load()} />
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价 + 微信配置,P5.1/P5.2 */}
<TopupChannels />
{/* 观测端:充值订单流 + 对账(P5.3) */}
<OrderStream />
{/* 发放积分 Modal */}
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
{/* 观测端:用量结果 */}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400"></span>
</div>
@@ -214,11 +215,10 @@ export function UsagePage() {
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
<td className="py-2 text-right">
<button
onClick={() => void doGrant(r.tenant_id, r.name || r.tenant_id)}
disabled={granting === r.tenant_id}
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40"
onClick={() => openGrant(r)}
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50"
>
{granting === r.tenant_id ? "…" : "充值"}
</button>
</td>
</tr>
+120
View File
@@ -8,3 +8,123 @@ body,
height: 100%;
margin: 0;
}
/* 全局隐藏滚动条(保留滚动功能)——官网与控制台所有页面、以及页内任意滚动区
(侧栏、表格 overflow-auto、Modal 等)统一生效。 */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧 Edge/IE */
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Chrome / Safari */
}
/* ══ 官网落地页设计 token(跟随 logo:青→蓝→紫)══
仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。
bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */
:root {
--ground: #f5f7f9;
--surface: #ffffff;
--ink: #151c24;
--ink-2: #526069;
--ink-3: #8595a0;
--hairline: #e0e7ec;
--hairline-strong: #c6d2da;
--accent: #0284c7;
--accent-ink: #075985;
--accent-soft: #e0f2fe;
--code-bg: #ebf1f5;
--term-bg: #0e1620;
--term-ink: #c9e4f5;
--brand-g1: #0891b2;
--brand-g2: #2563eb;
--brand-g3: #9333ea;
}
.dark {
--ground: #0b1117;
--surface: #111925;
--ink: #e6edf3;
--ink-2: #93a6b4;
--ink-3: #62737f;
--hairline: #1f2c38;
--hairline-strong: #32434f;
--accent: #38bdf8;
--accent-ink: #7dd3fc;
--accent-soft: #0b3049;
--code-bg: #14202b;
--term-bg: #0a121b;
--term-ink: #bfe3f7;
--brand-g1: #22d3ee;
--brand-g2: #60a5fa;
--brand-g3: #c084fc;
}
/* v3 对 var() 色不支持 /opacity 修饰,用 color-mix 还原 bg-ground/85sticky 头透底 + 模糊)。 */
.bg-ground-85 {
background-color: color-mix(in srgb, var(--ground) 85%, transparent);
}
/* 品牌渐变文字(logo 同源:青→蓝→紫),落地页克制使用 */
.text-brand-gradient {
background: linear-gradient(92deg, var(--brand-g1), var(--brand-g2) 55%, var(--brand-g3));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* 终端光标 */
@keyframes caret-blink {
50% {
opacity: 0;
}
}
.caret-blink {
animation: caret-blink 1.1s steps(1) infinite;
}
/* 滚动进场(配合 Reveal 组件) */
.reveal {
opacity: 0;
transform: translateY(16px);
transition:
opacity 0.6s ease,
transform 0.6s ease;
}
.reveal-in {
opacity: 1;
transform: none;
}
/* 终端事件流:逐行浮现 */
@keyframes term-line-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.term-line {
opacity: 0;
animation: term-line-in 0.45s ease forwards;
}
@media (prefers-reduced-motion: reduce) {
.caret-blink,
.term-line {
animation: none;
}
.term-line {
opacity: 1;
}
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
+197 -83
View File
@@ -1,33 +1,64 @@
import { useEffect, useState } from "react";
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
import { listAudit, type AuditEntry } from "../api";
// 操作方法配色
// 操作方法配色
const ACTION_STYLE: Record<string, string> = {
POST: "bg-emerald-50 text-emerald-600",
PUT: "bg-amber-50 text-amber-600",
DELETE: "bg-rose-50 text-rose-600",
PATCH: "bg-violet-50 text-violet-600",
POST: "bg-emerald-50 text-emerald-600 border border-emerald-100",
PUT: "bg-amber-50 text-amber-600 border border-amber-100",
DELETE: "bg-rose-50 text-rose-600 border border-rose-100",
PATCH: "bg-violet-50 text-violet-600 border border-violet-100",
GET: "bg-gray-50 text-gray-600 border border-gray-100",
};
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
// uid 太长,展示时截断(保留首尾,hover 看全)。
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}${id.slice(-4)}` : id || "系统");
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
const LIMIT = 20;
const ROUTE_OPTS = [
{ value: "", label: "所有路由" },
{ value: "/api/v1/admin", label: "管理员接口 (/admin)" },
{ value: "/api/v1/prompts", label: "提示词接口 (/prompts)" },
{ value: "/api/v1/auth", label: "鉴权接口 (/auth)" },
{ value: "/api/v1/spaces", label: "空间接口 (/spaces)" },
];
export function AuditPage() {
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [err, setErr] = useState("");
const load = async () => {
setRefreshing(true);
// 筛选器状态
const [methodFilter, setMethodFilter] = useState<string>(""); // 空 = 全部
const [routeFilter, setRouteFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
// 分页状态
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const load = async (pageNum = page, showRef = false) => {
if (showRef) setRefreshing(true);
else setLoading(true);
try {
const [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
setAudit(a);
setEvents(e);
const offset = (pageNum - 1) * LIMIT;
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
const data = await listAudit(LIMIT + 1, offset, {
action: methodFilter,
path: routeFilter,
q: searchQuery,
});
if (data.length > LIMIT) {
setAudit(data.slice(0, LIMIT));
setHasMore(true);
} else {
setAudit(data);
setHasMore(false);
}
setUpdatedAt(new Date());
setErr("");
} catch (er) {
@@ -39,36 +70,47 @@ export function AuditPage() {
};
useEffect(() => {
void load();
const t = setInterval(() => void load(), 30000);
return () => clearInterval(t);
}, []);
void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
// 筛选条件变化 → 回第 1 页重新向服务端查询(防抖 300ms,免得每敲一个字打一次库)。
// 已在第 1 页时 setPage 不会触发上面的 effect,所以这里直接 load。
useEffect(() => {
const t = window.setTimeout(() => {
if (page !== 1) setPage(1);
else void load(1);
}, 300);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [methodFilter, routeFilter, searchQuery]);
const blocked = events.filter((e) => e.kind === "blocked").length;
const suspect = events.filter((e) => e.kind === "suspect").length;
// 筛选已由服务端完成(全库匹配 + 分页),前端不再二次过滤:
// 否则会在"服务端已筛过的一页"上再筛一次,翻页计数与实际结果对不上。
const filteredAudit = audit;
const handlePrevPage = () => {
if (page > 1) setPage(page - 1);
};
const handleNextPage = () => {
if (hasMore) setPage(page + 1);
};
return (
<div className="space-y-5">
{/* 顶栏 */}
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mr-auto">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"> & </h3>
<p className="text-xs text-gray-400"> + · </p>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-gray-150 bg-white p-5 shadow-sm">
<div>
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<p className="text-xs text-gray-400"> · </p>
</div>
<div className="grid grid-cols-3 divide-x divide-gray-100">
<Stat value={audit.length} label="操作留痕" />
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
{updatedAt && <span> {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
<div className="flex items-center gap-3 text-xs text-gray-400">
{updatedAt && <span>{updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
<button
onClick={() => void load()}
onClick={() => void load(page, true)}
disabled={refreshing}
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
>
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
@@ -78,54 +120,134 @@ export function AuditPage() {
</div>
</div>
{/* 安全事件(护栏命中) */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mb-3 flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> / {events.length}</span>
</div>
{events.length === 0 ? (
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400"> 🎉</div>
) : (
<div className="divide-y divide-gray-100">
{events.map((e) => (
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
{e.kind === "blocked" ? "拦截" : "灰区"}
</span>
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
</div>
{/* 筛选工具栏 */}
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
{/* HTTP Method Button Group */}
<div className="flex items-center gap-1.5 rounded-lg border border-gray-150 bg-gray-50 p-1 text-xs">
{["", "GET", "POST", "PUT", "DELETE"].map((m) => (
<button
key={m}
onClick={() => { setMethodFilter(m); setPage(1); }}
className={`rounded px-3 py-1 font-semibold transition-all ${
methodFilter === m
? "bg-violet-600 text-white shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{m || "全部"}
</button>
))}
</div>
)}
</section>
{/* 操作审计 */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mb-3 flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> / / / {audit.length}</span>
<div className="flex flex-wrap items-center gap-3">
{/* Route prefix select */}
<select
value={routeFilter}
onChange={(e) => { setRouteFilter(e.target.value); setPage(1); }}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-700 focus:outline-none focus:border-violet-400 shadow-sm"
>
{ROUTE_OPTS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* Search Input */}
<input
type="text"
placeholder="搜索操作者 ID / IP / 详情…"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-56 shadow-sm"
/>
</div>
{audit.length === 0 ? (
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400"></div>
</div>
</div>
{/* 审计日志列表 */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-baseline justify-between border-b pb-3">
<div className="flex items-baseline gap-2">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> {filteredAudit.length} </span>
</div>
</div>
{loading ? (
<div className="py-12 text-center text-xs text-gray-400 animate-pulse"></div>
) : err ? (
<div className="py-12 text-center text-xs text-rose-500">{err}</div>
) : filteredAudit.length === 0 ? (
<div className="py-12 text-center text-xs text-gray-400"></div>
) : (
<div className="divide-y divide-gray-100">
{audit.map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
<div className="space-y-1.5">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium w-16"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium w-16"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium w-24"></th>
<th className="py-2 pr-3 font-medium w-28"> IP</th>
<th className="py-2 font-medium w-36"></th>
</tr>
</thead>
<tbody>
{filteredAudit.map((a) => (
<tr key={a.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
<td className="py-2.5 pr-3">
<span className={`inline-block w-14 rounded px-1.5 py-0.5 text-center text-[10px] font-bold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
{a.action}
</span>
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
</div>
</td>
<td className="py-2.5 pr-3 max-w-[12rem] truncate font-mono text-[11px] text-gray-600" title={a.path}>
{a.path}
</td>
<td className={`py-2.5 pr-3 font-mono font-semibold ${statusTone(a.status)}`}>
{a.status}
</td>
<td className="py-2.5 pr-3 text-gray-500 text-[11px] max-w-[20rem] truncate" title={a.detail}>
{a.detail || "—"}
</td>
<td className="py-2.5 pr-3 font-mono text-gray-400" title={a.actor}>
{shortId(a.actor)}
</td>
<td className="py-2.5 pr-3 text-gray-400 font-mono">
{a.ip}
</td>
<td className="py-2.5 text-gray-400">
{fmt(a.at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination controls */}
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
<span className="text-xs text-gray-400">
<span className="font-semibold text-gray-700">{page}</span>
</span>
<div className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={page === 1}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
>
</button>
<button
onClick={handleNextPage}
disabled={!hasMore}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
>
</button>
</div>
</div>
</div>
)}
</section>
@@ -133,11 +255,3 @@ export function AuditPage() {
);
}
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
return (
<div className="px-4 first:pl-0">
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
<div className="text-[10px] text-gray-400">{label}</div>
</div>
);
}
+70 -101
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { UsageSection } from "../components/UsageSection";
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
@@ -150,6 +151,75 @@ export function DashboardPage() {
/>
</div>
{/* C. 全局任务吞吐 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 /</p>
</div>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
</defs>
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
{areaPath && <path d={areaPath} fill="url(#g)" />}
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
</g>
))}
</svg>
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
{trend.map((d) => (
<span key={d.key}>{d.key}</span>
))}
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 </p>
</div>
<div className="space-y-3">
{ov.status_count.length === 0 && <div className="text-xs text-gray-400"></div>}
{ov.status_count
.slice()
.sort((a, b) => b.count - a.count)
.map((d) => {
const st = statusStyle(d.key);
const pct = (d.count / totalStatus) * 100;
return (
<div key={d.key}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
{st.label}
</span>
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
</div>
</div>
);
})}
</div>
</div>
</div>
{/* 计费与用量(原独立页并入:积分消耗/Token/成本 + 趋势 + 各租户排行 + 充值) */}
<UsageSection />
{/* B. 控制面配置态(管理端独有) */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
@@ -224,90 +294,6 @@ export function DashboardPage() {
</div>
</div>
{/* C. 全局任务吞吐 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 /</p>
</div>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
</defs>
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
{areaPath && <path d={areaPath} fill="url(#g)" />}
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
</g>
))}
</svg>
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
{trend.map((d) => (
<span key={d.key}>{d.key}</span>
))}
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 </p>
</div>
<div className="space-y-3">
{ov.status_count.length === 0 && <div className="text-xs text-gray-400"></div>}
{ov.status_count
.slice()
.sort((a, b) => b.count - a.count)
.map((d) => {
const st = statusStyle(d.key);
const pct = (d.count / totalStatus) * 100;
return (
<div key={d.key}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
{st.label}
</span>
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
</div>
</div>
);
})}
</div>
</div>
</div>
{/* D. 系统健康拓扑 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> + + MCP NATS </p>
</div>
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700"></span>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
<HealthGroup title="基建" items={status.infra.map((i) => ({ name: i.name, up: i.up, detail: i.detail }))} />
<HealthGroup title="应用服务" items={status.services.map((s) => ({ name: s.name, up: s.up, detail: s.detail }))} />
<HealthGroup
title="MCP 工具组"
items={status.tools.map((t) => ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
/>
</div>
</div>
</div>
);
}
@@ -321,23 +307,6 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
);
}
function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) {
return (
<div>
<div className="mb-1 mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">{title}</div>
{items.map((it) => (
<div key={it.name} className="flex items-center justify-between border-b border-gray-50 py-1.5 text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className={`h-2 w-2 rounded-full ${it.up ? "bg-emerald-500" : "bg-rose-500"}`} />
{it.name}
</span>
<span className={`text-[10px] ${it.up ? "text-gray-400" : "text-rose-500"}`}>{it.up ? it.detail || "在线" : "离线"}</span>
</div>
))}
</div>
);
}
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
const TONE: Record<Tone, string> = {
violet: "bg-violet-50 text-violet-600",
+204 -13
View File
@@ -1,12 +1,9 @@
import { useEffect, useState } from "react";
import { ModelManager } from "../components/ModelManager";
import { adminDatasources, type DatasourceKB } from "../api";
import { Link } from "react-router-dom";
import { RetrievalBench } from "../components/RetrievalBench";
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
// 此前该页 GraphRAG 拓扑图与「向量/全文/图谱权重滑块」全 mock——而且权重概念本身是虚构的:
// mcp-go 的 RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),没有"每路占几成"的权重。
// 故删假滑块+假拓扑,换成真数据源清单 + 诚实的检索管线说明。
const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}` : `${n}`);
@@ -16,6 +13,17 @@ export function DatasourcesPage() {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// 运行时的 active embedding 模型展示
const [activeEmbedding, setActiveEmbedding] = useState<string>("加载中…");
// 运维工具折叠状态
const [opsOpen, setOpsOpen] = useState(false);
// 迁移 Modal 状态
const [migrationModal, setMigrationModal] = useState<"none" | "confirm" | "running" | "done">("none");
const [migrationResult, setMigrationResult] = useState<MigrateKbResult | null>(null);
const [migrationErr, setMigrationErr] = useState("");
useEffect(() => {
adminDatasources()
.then((r) => {
@@ -25,19 +33,199 @@ export function DatasourcesPage() {
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
// 获取激活的 embedding 模型
listModels("embedding")
.then((models) => {
const active = models.find((m) => m.active);
if (active) {
setActiveEmbedding(`${active.model} (${active.provider})`);
} else {
setActiveEmbedding("未配置/未激活 (将回退代码默认)");
}
})
.catch(() => setActiveEmbedding("获取失败"));
}, []);
const startMigration = async () => {
setMigrationModal("running");
setMigrationErr("");
try {
const res = await migrateKbStorage();
setMigrationResult(res);
setMigrationModal("done");
} catch (e) {
setMigrationErr((e as Error).message);
setMigrationModal("confirm"); // 退回到确认态展示错误
}
};
const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
return (
<div className="flex flex-col gap-6">
{/* Embedding 模型配置(真实组件) */}
<ModelManager
kind="embedding"
title="Embedding 模型(embedding → mcp-go RAG"
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
modelHint="text-embedding-v3"
/>
{/* 顶部激活模型只读展示 & 引导 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"> (Embedding)</h4>
<div className="mt-1 flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
</div>
</div>
<Link
to="/admin/models"
className="flex items-center gap-1 text-xs font-medium text-violet-600 hover:text-violet-700 hover:underline"
>
</Link>
</div>
{/* 运维工具 (RAG 迁移) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<button
onClick={() => setOpsOpen(!opsOpen)}
className="flex w-full items-center justify-between font-semibold text-gray-700 focus:outline-none"
>
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm font-semibold text-gray-700"></span>
</div>
<span className="text-xs text-gray-400">{opsOpen ? "收起 ▲" : "展开 ▼"}</span>
</button>
{opsOpen && (
<div className="mt-4 border-t border-gray-100 pt-4 space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 text-xs bg-amber-50/50 border border-amber-100 p-4 rounded-lg">
<div className="space-y-1">
<div className="font-semibold text-amber-800 flex items-center gap-1.5">
<span></span>
<span className="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded text-[9px] font-bold"></span>
</div>
<p className="text-gray-500 leading-relaxed max-w-xl">
<code className="bg-gray-100 px-1 rounded">owner/kb_id/doc</code> <code className="bg-gray-100 px-1 rounded">space_id/kb_id/doc</code>
</p>
</div>
<button
onClick={() => setMigrationModal("confirm")}
className="self-start md:self-center shrink-0 bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold py-2 px-4 rounded-lg shadow-sm transition"
>
</button>
</div>
</div>
)}
</div>
{/* 迁移 Modal */}
{migrationModal !== "none" && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900">
{migrationModal === "confirm" && "确认开始存储迁移?"}
{migrationModal === "running" && "正在迁移中…"}
{migrationModal === "done" && "迁移完成 🎉"}
</h3>
<p className="mt-0.5 text-xs text-gray-400"> KB space_id</p>
</div>
{migrationModal !== "running" && (
<button
onClick={() => setMigrationModal("none")}
className="text-xl leading-none text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
<div className="space-y-4 px-6 py-5 text-sm">
{migrationModal === "confirm" && (
<>
<p className="text-gray-600 leading-relaxed">
Space
</p>
<div className="rounded-lg border border-amber-100 bg-amber-50 p-3 text-xs text-amber-700 space-y-1">
<div className="font-semibold"> </div>
<div>· </div>
<div>· <code className="font-mono bg-amber-100/50 px-1">sundynix_doc</code> </div>
<div>· </div>
</div>
{migrationErr && (
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">
{migrationErr}
</div>
)}
</>
)}
{migrationModal === "running" && (
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<svg className="animate-spin h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-xs text-gray-500 font-medium animate-pulse"></span>
</div>
)}
{migrationModal === "done" && migrationResult && (
<div className="space-y-3">
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
RRF
</div>
<div className="divide-y divide-gray-100 border rounded-xl overflow-hidden bg-gray-50/50">
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-800 font-mono">{migrationResult.total}</span>
</div>
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-amber-600 font-mono">{migrationResult.enqueued}</span>
</div>
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-600 font-mono">{migrationResult.skipped}</span>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4">
{migrationModal === "confirm" && (
<>
<button
onClick={() => setMigrationModal("none")}
className="rounded-lg border border-gray-200 px-4 py-2 text-xs text-gray-500 hover:bg-gray-50"
>
</button>
<button
onClick={() => void startMigration()}
className="rounded-lg bg-amber-600 hover:bg-amber-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
>
</button>
</>
)}
{migrationModal === "done" && (
<button
onClick={() => setMigrationModal("none")}
className="rounded-lg bg-violet-600 hover:bg-violet-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
>
</button>
)}
</div>
</div>
</div>
)}
{/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
@@ -53,6 +241,9 @@ export function DatasourcesPage() {
</p>
</div>
{/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */}
<RetrievalBench kbs={rows} />
{/* 平台数据源计数 */}
<div className="grid grid-cols-3 gap-4">
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
+115 -35
View File
@@ -1,16 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { guardrailEvents, type GuardrailEvent } from "../api";
import { listGuardrailEvents, type GuardrailEventItem } from "../api";
// 输入护栏观测:真数据(来自 guardrail_eventmiddleware.Guardrail 命中即落库)。
// 命中事件流(blocked 硬拦 / suspect 灰区放行→Tier2 LLM 裁决) + 计数 + 原因分布。
//
// 诚实边界:护栏规则(Tier1 正则/敏感词 + Tier2 LLM 分类器)目前是中间件里的代码常量,
// 尚不支持运行时编辑(改规则需改代码重部署)。故本页只做观测,不摆"能改却不生效"的假配置面。
// 若要规则运行时可配,需另建配置存储 + 中间件读库(参考 prompt 控制面的热下发)。
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600", suspect: "bg-amber-50 text-amber-600" };
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600 border border-rose-100", suspect: "bg-amber-50 text-amber-600 border border-amber-100" };
const KIND_LABEL: Record<string, string> = { blocked: "硬拦截", suspect: "灰区放行" };
const LIMIT = 20;
// 解析 signals(JSON 数组字符串)为可读标签。
function signalLabels(raw: string): string[] {
if (!raw) return [];
@@ -23,26 +21,40 @@ function signalLabels(raw: string): string[] {
}
export function GuardrailsPage() {
const [events, setEvents] = useState<GuardrailEvent[]>([]);
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
const [filter, setFilter] = useState<"" | "blocked" | "suspect">("");
const [searchQuery, setSearchQuery] = useState("");
// 分页状态
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const load = () => {
setLoading(true);
guardrailEvents(100)
const offset = (page - 1) * LIMIT;
listGuardrailEvents(LIMIT + 1, offset)
.then((r) => {
if (r.length > LIMIT) {
setEvents(r.slice(0, LIMIT));
setHasMore(true);
} else {
setEvents(r);
setHasMore(false);
}
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
};
useEffect(load, []);
useEffect(load, [page]);
const blocked = events.filter((e) => e.kind === "blocked").length;
const suspect = events.filter((e) => e.kind === "suspect").length;
// 原因/信号 Top(真实命中分布)。
// 原因/信号 Top(基于当前拉取列表的命中分布)。
const topReasons = useMemo(() => {
const m = new Map<string, number>();
for (const e of events) {
@@ -52,34 +64,58 @@ export function GuardrailsPage() {
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
}, [events]);
const shown = filter ? events.filter((e) => e.kind === filter) : events;
const shown = useMemo(() => {
return events.filter((e) => {
// 1. 类型过滤
if (filter && e.kind !== filter) return false;
// 2. 搜索过滤 (Actor 或 IP)
if (searchQuery) {
const q = searchQuery.toLowerCase();
const matchActor = e.actor?.toLowerCase().includes(q) ?? false;
const matchIp = e.ip?.toLowerCase().includes(q) ?? false;
const matchReason = e.reason?.toLowerCase().includes(q) ?? false;
if (!matchActor && !matchIp && !matchReason) return false;
}
return true;
});
}, [events, filter, searchQuery]);
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
const handlePrevPage = () => {
if (page > 1) setPage(page - 1);
};
const handleNextPage = () => {
if (hasMore) setPage(page + 1);
};
if (loading && page === 1) return <div className="text-sm text-gray-400 p-5"></div>;
if (err) return <div className="text-sm text-rose-500 p-5">{err}</div>;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-gray-800"></h2>
<h2 className="text-base font-semibold text-gray-800"></h2>
<p className="text-xs text-gray-400"> · · </p>
</div>
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50"></button>
<button onClick={load} className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 shadow-sm">
</button>
</div>
{/* 计数 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
<Stat label="硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒(近 100 条内)" />
<Stat label="灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 执行前裁决" />
<Stat label="命中总数" value={String(events.length)} tone="violet" sub="近 100 条护栏事件" />
<Stat label="当前页硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒" />
<Stat label="当前页灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 裁决" />
<Stat label="当前页命中总数" value={String(events.length)} tone="violet" sub={`分页数: ${LIMIT}`} />
</div>
{/* 规则说明(诚实:规则在代码里,非运行时可配) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h4 className="mb-2 text-sm font-semibold text-gray-700"></h4>
<h4 className="mb-2 text-sm font-semibold text-gray-700"></h4>
<div className="space-y-1.5 text-xs leading-relaxed text-gray-500">
<p><span className="font-medium text-gray-700">Tier1</span> + <span className="text-rose-600">blocked</span> </p>
<p><span className="font-medium text-gray-700">Tier2</span> <span className="text-amber-600">suspect</span> Dispatcher LLM </p>
<p><span className="font-medium text-gray-700">Tier1</span> + <span className="text-rose-600 font-medium">blocked</span> </p>
<p><span className="font-medium text-gray-700">Tier2</span> <span className="text-amber-600 font-medium">suspect</span> Dispatcher LLM </p>
<p className="text-gray-400"> + </p>
</div>
</div>
@@ -103,21 +139,36 @@ export function GuardrailsPage() {
)}
{/* 事件流 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<div className="flex flex-wrap items-center gap-3">
{/* IP / Actor Search */}
<input
type="text"
placeholder="搜索操作人 ID / IP / 原因…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-52 shadow-sm"
/>
{/* Type buttons */}
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
<button key={v} onClick={() => setFilter(v)}
<button key={v} onClick={() => { setFilter(v); setPage(1); }}
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
{label}
</button>
))}
</div>
</div>
</div>
{shown.length === 0 ? (
<div className="py-8 text-center text-xs text-gray-400"> </div>
<div className="py-8 text-center text-xs text-gray-400"> </div>
) : (
<div className="space-y-4">
<div className="max-h-96 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
@@ -126,35 +177,63 @@ export function GuardrailsPage() {
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"> / </th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium"> (Actor/IP)</th>
</tr>
</thead>
<tbody>
{shown.map((e) => (
<tr key={e.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-xs text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
<td className="py-2 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>{KIND_LABEL[e.kind] ?? e.kind}</span>
<tr key={e.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
<td className="py-2.5 pr-3 text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
<td className="py-2.5 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
{KIND_LABEL[e.kind] ?? e.kind}
</span>
</td>
<td className="py-2 pr-3">
<td className="py-2.5 pr-3 max-w-[20rem] truncate" title={e.reason}>
{e.kind === "blocked" ? (
<span className="text-xs text-gray-700">{e.reason || "—"}</span>
<span className="text-gray-700">{e.reason || "—"}</span>
) : (
<div className="flex flex-wrap gap-1">
{signalLabels(e.signals).map((sig, i) => (
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
))}
{signalLabels(e.signals).length === 0 && <span className="text-xs text-gray-400"></span>}
{signalLabels(e.signals).length === 0 && <span className="text-gray-400"></span>}
</div>
)}
</td>
<td className="py-2 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
<td className="py-2 text-[11px] text-gray-400">{e.actor || e.ip || "匿名"}</td>
<td className="py-2.5 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
<td className="py-2.5 text-[11px] text-gray-400" title={`Actor: ${e.actor}, IP: ${e.ip}`}>
{e.actor ? `${e.actor.slice(0, 8)}` : ""} {e.ip ? `(${e.ip})` : "匿名"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
<span className="text-xs text-gray-400">
<span className="font-semibold text-gray-700">{page}</span>
</span>
<div className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={page === 1}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
>
</button>
<button
onClick={handleNextPage}
disabled={!hasMore}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
>
</button>
</div>
</div>
</div>
)}
</div>
</div>
@@ -171,3 +250,4 @@ function Stat({ label, value, sub, tone }: { label: string; value: string; sub?:
</div>
);
}
@@ -0,0 +1,114 @@
import { useEffect, useState } from "react";
import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api";
// 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。
// AppSecret 明文回显(单管理员后台,方便核对/复制;密文仍加密入库)。
export function LoginConfigPage() {
const [cfg, setCfg] = useState<WechatMPConfig | null>(null);
const [appid, setAppid] = useState("");
const [token, setToken] = useState("");
const [secret, setSecret] = useState(""); // 留空=沿用已存
const [welcome, setWelcome] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [ok, setOk] = useState(false);
useEffect(() => {
getWechatMP()
.then((c) => {
setCfg(c);
setAppid(c.appid);
setToken(c.token);
setSecret(c.app_secret);
setWelcome(c.welcome);
})
.catch((e) => setErr((e as Error).message));
}, []);
const save = async () => {
if (busy) return;
setBusy(true);
setErr("");
setOk(false);
try {
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim(), welcome: welcome.trim() });
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, welcome: welcome.trim(), enabled: r.enabled } : c));
setOk(true);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
// 回调 URL 用当前后端地址推断(生产 GATEWAY 为空串=同源,显示成本域名)
const origin = GATEWAY || (typeof window !== "undefined" ? window.location.origin : "");
const callbackURL = origin.replace(/:\d+$/, "") + "/wx/mp/callback";
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400">AppSecret </p>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700"></h4>
{cfg?.enabled ? (
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600"></span>
) : (
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500"></span>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="text-xs text-gray-500">
AppID
<input value={appid} onChange={(e) => setAppid(e.target.value)} placeholder="wx..."
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
AppSecret
<input value={secret} onChange={(e) => setSecret(e.target.value)}
placeholder="公众号开发密钥"
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500 md:col-span-2">
Token
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500 md:col-span-2">
<textarea value={welcome} onChange={(e) => setWelcome(e.target.value)} rows={3}
placeholder={"欢迎关注!\n下载客户端登录即可开始体验。\n下载地址:https://…"}
className="mt-1 block w-full resize-y rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
</div>
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
{ok && <p className="mt-2 text-xs text-emerald-600"></p>}
<div className="mt-3">
<button onClick={() => void save()} disabled={busy}
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "保存中…" : "保存"}
</button>
</div>
</div>
{/* 配置清单:把「必须在公众平台做什么」写在眼前,省得来回翻文档 */}
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
<h4 className="mb-2 text-sm font-semibold text-gray-700"></h4>
<ol className="list-decimal space-y-1.5 pl-4">
<li>(URL) <code className="break-all rounded bg-white px-1">{callbackURL}</code></li>
<li>(Token) <b></b></li>
<li> <b></b></li>
<li>IP IP access_token 40164</li>
<li></li>
</ol>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { ModelsPage } from "./ModelsPage";
import { BillingRules } from "../components/BillingRules";
// 模型配置:模型登记/激活/连通性测试 + 它的计费规则(token→积分汇率、硬拦截、按模型定价与权重)。
// 配了模型就顺手定它的价,两块放一起。
// 支付相关(渠道配置 / 订单对账)已拆到「运维 → 支付」下的两个子页,不在这里。
// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页。
export function ModelConfigPage() {
return (
<div className="space-y-6">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"></h3>
<p className="text-xs text-gray-400"> / · </p>
</div>
<ModelsPage />
<BillingRules />
</div>
);
}
+59 -3
View File
@@ -1,13 +1,69 @@
import { useState } from "react";
import { ModelManager } from "../components/ModelManager";
import type { Kind } from "../api";
// 模型配置页:工作主力(chat) / JARVIS 语音(voice) / 向量化(embedding) 三 Tab 统一管理。
// 工作与语音分开:编排/报告用强模型(chat),JARVIS 语音对话用快模型(voice, 低时延)——各配各激活。
const TABS: { key: Kind; label: string }[] = [
{ key: "chat", label: "工作主力模型" },
{ key: "voice", label: "JARVIS 语音模型" },
{ key: "embedding", label: "向量化模型" },
];
// 对话模型(chat)配置页 → Dispatcher 经 NATS 热更新。
export function ModelsPage() {
const [tab, setTab] = useState<Kind>("chat");
return (
<div className="space-y-6">
{/* 头部标题与 Tab 切换 */}
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
<div>
<h3 className="text-base font-semibold text-gray-800"></h3>
<p className="text-xs text-gray-400">/· JARVIS · RAG</p>
</div>
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
tab === t.key ? "bg-white text-violet-700 shadow-sm" : "text-gray-500 hover:text-gray-700"
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* 模型管理组件 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
{tab === "chat" && (
<ModelManager
kind="chat"
title="对话模型(chat → Dispatcher"
title="工作主力模型 (chat → Dispatcher 编排/报告)"
baseUrlHint="https://api.deepseek.com"
modelHint="deepseek-chat"
modelHint="deepseek-v4-pro"
/>
)}
{tab === "voice" && (
<ModelManager
kind="voice"
title="JARVIS 语音模型 (voice → 语音对话,选快模型抢首字时延)"
baseUrlHint="https://api.deepseek.com"
modelHint="deepseek-v4-flash"
/>
)}
{tab === "embedding" && (
<ModelManager
kind="embedding"
title="向量化模型 (embedding → mcp-go RAG)"
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
modelHint="text-embedding-v3"
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { TopupChannels } from "../components/TopupChannels";
// 支付 · 配置:充值渠道(积分包定价、兑换码生成/台账)+ 微信支付参数。
// 与「支付 · 订单与对账」拆开:这里只管「怎么收钱」,那边看「收到了什么」。
export function PaymentConfigPage() {
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400"> · · </p>
</div>
<TopupChannels />
</div>
);
}
@@ -0,0 +1,15 @@
import { OrderStream } from "../components/OrderStream";
// 支付 · 订单与对账:全平台充值订单流 + 状态计数 + 一键对账 + 人工退款。
// 配置面在「支付 · 配置」,这里只做流水观测与处置。
export function PaymentOrdersPage() {
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400"> · · 退</p>
</div>
<OrderStream />
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { adminSpaces, type AdminSpace } from "../api";
// 全平台空间(Space)观测:跨租户看所有空间(类型/租户/建者/成员数/归档态)。
// Space 是租户与成员之间的中间容器(Tenant > Space > 成员),数据来自 sundynix_space。
const KIND: Record<string, { label: string; badge: string }> = {
personal: { label: "个人", badge: "bg-gray-100 text-gray-500" },
project: { label: "项目", badge: "bg-cyan-50 text-cyan-600" },
tenant: { label: "全员", badge: "bg-violet-50 text-violet-600" },
};
const kind = (k: string) => KIND[k] ?? { label: k, badge: "bg-gray-100 text-gray-500" };
export function SpacesPage() {
const [spaces, setSpaces] = useState<AdminSpace[]>([]);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const [hideArchived, setHideArchived] = useState(true);
const load = useCallback(() => {
setLoading(true);
adminSpaces(300)
.then((s) => {
setSpaces(s);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
const shown = useMemo(() => (hideArchived ? spaces.filter((s) => !s.archived) : spaces), [spaces, hideArchived]);
const stats = useMemo(() => {
const byKind: Record<string, number> = {};
let archived = 0;
for (const s of spaces) {
byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
if (s.archived) archived++;
}
return { total: spaces.length, byKind, archived };
}, [spaces]);
return (
<div className="space-y-5">
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700">(Space)</h3>
<span className="text-[11px] text-gray-400">Agent / </span>
</div>
{/* 计数卡片 */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Stat label="空间总数" value={stats.total} tone="text-gray-800" />
<Stat label="项目空间" value={stats.byKind.project ?? 0} tone="text-cyan-600" />
<Stat label="全员空间" value={stats.byKind.tenant ?? 0} tone="text-violet-600" />
<Stat label="已归档" value={stats.archived} tone="text-gray-400" />
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-gray-400">{loading ? "加载中…" : `${shown.length} 个空间`}</span>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-500">
<input type="checkbox" checked={hideArchived} onChange={(e) => setHideArchived(e.target.checked)} />
</label>
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">
</button>
</div>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<div className="max-h-[32rem] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{shown.map((s) => (
<tr key={s.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{s.name}</td>
<td className="py-2 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${kind(s.kind).badge}`}>{kind(s.kind).label}</span>
</td>
<td className="py-2 pr-3 text-gray-600">{s.tenant_name || <span className="text-gray-300">{s.tenant_id || "—"}</span>}</td>
<td className="py-2 pr-3 text-xs text-gray-500">{s.creator_email || s.creator || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{s.members}</td>
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(s.created_at).toLocaleDateString("zh-CN")}</td>
<td className="py-2 text-xs">
{s.archived ? <span className="text-gray-400"></span> : <span className="text-emerald-600"></span>}
</td>
</tr>
))}
{shown.length === 0 && !loading && (
<tr>
<td colSpan={7} className="py-8 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</div>
</div>
);
}
+465 -230
View File
@@ -1,20 +1,24 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
const REFRESH_SEC = 5;
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
const SERVICE_META: Record<string, { role: string; icon: IconName; colorClass: string }> = {
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway", colorClass: "bg-violet-50 text-violet-600" },
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu", colorClass: "bg-amber-50 text-amber-600" },
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool", colorClass: "bg-cyan-50 text-cyan-600" },
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box", colorClass: "bg-rose-50 text-rose-600" },
};
const INFRA_META: Record<string, { role: string; port: string; icon: IconName }> = {
postgres: { role: "关系库", port: "5432", icon: "db" },
redis: { role: "缓存 / 限流", port: "6379", icon: "db" },
nats: { role: "消息总线 · JetStream", port: "4222", icon: "bus" },
milvus: { role: "向量库", port: "19530", icon: "db" },
neo4j: { role: "图数据库", port: "7687", icon: "bus" },
postgres: { role: "关系库", port: "5432", icon: "db" },
redis: { role: "限流 / 缓存", port: "6379", icon: "db" },
nats: { role: "JetStream 总线", port: "4222", icon: "bus" },
milvus: { role: "向量检索库", port: "19530", icon: "db" },
neo4j: { role: "图关系库", port: "7687", icon: "bus" },
minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" },
// 全文索引不是独立服务,是 mcp-go 进程内的本地 bleve —— 没有端口,落在容器卷上。
: { role: "全文检索 (bleve)", port: "本地卷", icon: "db" },
};
function toolCategory(t: string): string {
@@ -26,23 +30,22 @@ function toolCategory(t: string): string {
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
return "系统";
}
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
// 能力域配色(工具小卡片的圆点 + 角标)。
const CAT_STYLE: Record<string, { dot: string; chip: string }> = {
"知识库 / 检索": { dot: "bg-violet-500", chip: "bg-violet-50 text-violet-600" },
: { dot: "bg-cyan-500", chip: "bg-cyan-50 text-cyan-600" },
: { dot: "bg-amber-500", chip: "bg-amber-50 text-amber-600" },
: { dot: "bg-emerald-500", chip: "bg-emerald-50 text-emerald-600" },
: { dot: "bg-rose-500", chip: "bg-rose-50 text-rose-600" },
"算法 / 沙箱": { dot: "bg-indigo-500", chip: "bg-indigo-50 text-indigo-600" },
: { dot: "bg-gray-400", chip: "bg-gray-100 text-gray-500" },
};
const latTone = (ms?: number) => (ms == null ? "text-gray-400" : ms < 50 ? "text-emerald-600" : ms < 200 ? "text-amber-600" : "text-rose-500");
// 数据流动光点 + 节点辉光的关键帧(注入一次)。
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
const CATEGORY_COLORS: Record<string, string> = {
"知识库 / 检索": "border-violet-200 hover:border-violet-400 text-violet-700 bg-violet-50/30 hover:bg-violet-50/50",
"记忆": "border-cyan-200 hover:border-cyan-400 text-cyan-700 bg-cyan-50/30 hover:bg-cyan-50/50",
"报告": "border-amber-200 hover:border-amber-400 text-amber-700 bg-amber-50/30 hover:bg-amber-50/50",
"会话历史": "border-emerald-200 hover:border-emerald-400 text-emerald-700 bg-emerald-50/30 hover:bg-emerald-50/50",
"外部接入": "border-rose-200 hover:border-rose-400 text-rose-700 bg-rose-50/30 hover:bg-rose-50/50",
"算法 / 沙箱": "border-indigo-200 hover:border-indigo-400 text-indigo-700 bg-indigo-50/30 hover:bg-indigo-50/50",
"系统": "border-blue-200 hover:border-blue-400 text-blue-700 bg-blue-50/30 hover:bg-blue-50/50",
};
const FLOW_CSS = `
@keyframes sdxFlow { 0%{left:-6%;opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{left:106%;opacity:0} }
@keyframes sdxGlow { 0%,100%{opacity:.55} 50%{opacity:1} }
@keyframes sdxFlow { 0%{left:-8%;opacity:0} 15%{opacity:1} 85%{opacity:1} 100%{left:108%;opacity:0} }
@keyframes sdxGlow { 0%,100%{transform: scale(1); opacity: 0.8;} 50%{transform: scale(1.15); opacity: 1;} }
`;
export function StatusPage() {
@@ -52,13 +55,20 @@ export function StatusPage() {
const [busy, setBusy] = useState(false);
const [auto, setAuto] = useState(true);
const [countdown, setCountdown] = useState(REFRESH_SEC);
// MCP 工具浏览器状态
const [activeServer, setActiveServer] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("All");
const autoRef = useRef(auto);
autoRef.current = auto;
const load = useCallback(async () => {
setBusy(true);
try {
setData(await getStatus());
const res = await getStatus();
setData(res);
setErr("");
} catch (e) {
setErr((e as Error).message);
@@ -87,11 +97,53 @@ export function StatusPage() {
return () => window.clearInterval(id);
}, [load]);
// 当 data 加载后,默认选中第一个 server
useEffect(() => {
if (data?.tools?.length && !activeServer) {
const active = data.tools.find((t) => t.up);
if (active) {
setActiveServer(active.server);
} else {
setActiveServer(data.tools[0].server);
}
}
}, [data, activeServer]);
const svc = (n: string) => data?.services.find((s) => s.name === n);
const infra = (n: string) => data?.infra.find((s) => s.name === n);
if (loading && !data) return <div className="text-sm text-gray-400"></div>;
if (!data) return <div className="text-sm text-rose-500">{err}</div>;
// 获取当前选中的 server 及其下的工具
const currentServerGroup = useMemo(() => {
if (!data?.tools) return null;
return data.tools.find((g) => g.server === activeServer) ?? data.tools[0] ?? null;
}, [data, activeServer]);
const serverTools = useMemo(() => currentServerGroup?.tools ?? [], [currentServerGroup]);
// 当前选中的 server 下所有工具的分类以及数量计数
const serverCategories = useMemo(() => {
const counts: Record<string, number> = {};
for (const t of serverTools) {
const cat = toolCategory(t.name);
counts[cat] = (counts[cat] ?? 0) + 1;
}
return counts;
}, [serverTools]);
const filteredTools = useMemo(() => {
return serverTools.filter((t) => {
const cat = toolCategory(t.name);
if (selectedCategory !== "All" && cat !== selectedCategory) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return t.name.toLowerCase().includes(q) || t.cn.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q);
}
return true;
});
}, [serverTools, selectedCategory, searchQuery]);
if (loading && !data) return <div className="text-sm text-gray-400 p-6"></div>;
if (!data) return <div className="text-sm text-rose-500 p-6">{err}</div>;
const servicesUp = data.services.filter((s) => s.up).length;
const infraUp = data.infra.filter((s) => s.up).length;
@@ -102,60 +154,92 @@ export function StatusPage() {
const avgLat = lats.length ? Math.round(lats.reduce((a, b) => a + b, 0) / lats.length) : null;
return (
<div className="space-y-5">
{/* 概览:状态 + 数字层次 + 控制(中性底,绿色只作状态点) */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
<div className="flex flex-wrap items-center gap-4">
<span className={`relative flex h-2.5 w-2.5 ${allUp ? "" : ""}`}>
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`} />
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"}`} />
<div className="space-y-6">
<style>{FLOW_CSS}</style>
{/* 头部控制栏 */}
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
<div>
<h2 className="text-base font-bold text-gray-800 flex items-center gap-2.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${
allUp ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-amber-50 text-amber-700 border border-amber-200"
}`}>
<span className={`h-2 w-2 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"} breath-dot`} style={{ animation: "subtle-breath 2.5s ease-in-out infinite" }} />
{allUp ? "运行正常" : `${downCount} 项异常`}
</span>
<div className="mr-auto">
<div className="text-lg font-semibold tracking-tight text-gray-900">{allUp ? "系统运行正常" : `${downCount} 项异常`}</div>
<div className="text-xs text-gray-400">{allUp ? "所有应用服务与基建均已就绪" : "部分组件未就绪,见下方明细"}</div>
</h2>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-3">
<button
onClick={() => setAuto((a) => !a)}
className={`rounded-full px-3 py-1.5 font-medium transition ${auto ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-400"}`}
className={`flex items-center gap-2 text-xs px-3.5 py-2 rounded-xl border border-gray-200 bg-white shadow-sm transition ${
auto ? "text-emerald-700 border-emerald-200 bg-emerald-50/10 font-bold" : "text-gray-500"
}`}
>
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
<span className={`h-1.5 w-1.5 rounded-full ${auto ? "bg-emerald-500 animate-ping" : "bg-gray-400"}`} />
{auto ? `自动刷新 · ${countdown}s` : "已暂停自动刷新"}
</button>
<button
onClick={() => void load()}
className="flex items-center gap-1.5 rounded-full bg-gray-900 px-3.5 py-1.5 font-medium text-white transition hover:bg-gray-700"
disabled={busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-600 hover:bg-violet-700 text-xs font-semibold text-white transition shadow-sm hover:shadow-violet-200 disabled:opacity-40"
>
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
</button>
</div>
</div>
{/* 数字层次:大号数字 + 小标签,竖线分隔,不再用边框盒子 */}
<div className="mt-6 grid grid-cols-2 divide-x divide-gray-100 sm:grid-cols-5">
<BigStat value={`${servicesUp}/${data.services.length}`} label="应用服务" bad={servicesUp !== data.services.length} />
<BigStat value={`${infraUp}/${data.infra.length}`} label="基建环境" bad={infraUp !== data.infra.length} />
<BigStat value={toolCount} label="注册工具" />
<BigStat value={avgLat != null ? `${avgLat}ms` : "—"} label="平均探针延迟" />
<BigStat value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })} label="最后检查" muted />
{/* KPI Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<BigStatCard
label="应用服务"
value={`${servicesUp}/${data.services.length}`}
subText={servicesUp === data.services.length ? "100% 存活在线" : `${data.services.length - servicesUp} 个节点离线`}
barPercent={(servicesUp / data.services.length) * 100}
barColor={servicesUp === data.services.length ? "bg-emerald-500" : "bg-amber-500"}
/>
<BigStatCard
label="基建依赖"
value={`${infraUp}/${data.infra.length}`}
subText={infraUp === data.infra.length ? "关系/缓存/总线均就绪" : `${data.infra.length - infraUp} 个组件受损`}
barPercent={(infraUp / data.infra.length) * 100}
barColor={infraUp === data.infra.length ? "bg-emerald-500" : "bg-amber-500"}
/>
<BigStatCard
label="MCP注册工具"
value={`${toolCount}`}
subText="大模型挂载能力集合"
/>
<BigStatCard
label="平均探针延迟"
value={avgLat != null ? `${avgLat} ms` : "—"}
subText={avgLat != null && avgLat < 20 ? "通信链路极其通畅" : "存在部分网络阻塞"}
valueColor="text-cyan-600"
/>
<BigStatCard
label="最后检查"
value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })}
subText="轮询频率每 5 秒"
valueColor="text-gray-600 font-mono"
/>
</div>
</section>
{/* 请求链路:实时数据管道(深色科技底 + 节点辉光 + 流动光点) */}
<section
className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 p-6"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgba(148,163,184,0.10) 1px, transparent 0), radial-gradient(60% 120% at 50% 0%, rgba(124,58,237,0.18), transparent 70%)",
backgroundSize: "22px 22px, 100% 100%",
}}
>
<style>{FLOW_CSS}</style>
<div className="flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-slate-100"></h3>
<span className="text-xs text-slate-500"> / 线 · </span>
{/* Topology Pipeline (请求链路) */}
<section className="rounded-2xl border border-gray-200 bg-gradient-to-br from-white to-slate-50/50 p-6">
<div className="border-b border-slate-100 pb-3 mb-6">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 01.553-.894L9 2l6 3 5.447-2.724A1 1 0 0121 3.118v10.764a1 1 0 01-.553.894L15 18l-6 2z" />
</svg>
</h3>
</div>
<div className="mt-5 flex items-center overflow-x-auto pb-1">
<div className="flex flex-col md:flex-row items-center justify-between gap-4 md:gap-2 px-2">
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" up />
<Link up={svc("gateway")?.up} />
<FlowNode icon="gateway" label="网关" sub=":8080" up={svc("gateway")?.up} latency={svc("gateway")?.latency_ms} />
@@ -170,70 +254,307 @@ export function StatusPage() {
sub="mcp-go · mcp-py"
up={Boolean(svc("mcp-go")?.up && svc("mcp-py")?.up)}
partial={Boolean((svc("mcp-go")?.up || svc("mcp-py")?.up) && !(svc("mcp-go")?.up && svc("mcp-py")?.up))}
latency={Math.max(svc("mcp-go")?.latency_ms ?? 0, svc("mcp-py")?.latency_ms ?? 0) || undefined}
/>
</div>
</section>
{/* 应用服务 ‖ 基建环境 */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-5">
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-3">
<SectionHead title="应用服务" hint="进程存活 · 探针耗时" />
<div className="mt-4 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-gray-100 bg-gray-100 sm:grid-cols-2">
{data.services.map((s) => (
<ServiceRow key={s.name} item={s} />
))}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 应用服务 (左) */}
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-3">
<div className="border-b border-slate-100 pb-3.5 mb-4">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-violet-600"></span>
</h3>
<p className="text-[10px] text-slate-400 mt-0.5"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.services.map((s) => {
const meta = SERVICE_META[s.name];
return (
<div key={s.name} className="border border-slate-200 bg-slate-50/30 rounded-2xl p-4 flex flex-col justify-between hover:border-violet-300 transition duration-300">
<div className="flex items-start justify-between">
<div>
<span className="text-xs font-bold text-slate-800 block">{s.name}</span>
<span className="text-[10px] text-slate-400 mt-1 block leading-relaxed">{meta?.role || s.detail}</span>
</div>
<span className={`px-2 py-0.5 rounded-full border text-[9px] font-bold ${
s.up ? "bg-emerald-50 text-emerald-700 border-emerald-100" : "bg-rose-50 text-rose-700 border-rose-100"
}`}>
{s.up ? "在线" : "离线"}
</span>
</div>
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between">
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-semibold">
{s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"}
</span>
<span className="text-xs font-bold text-slate-700 font-mono">
{/* 取实际上报的注册工具数。曾写死 23/4,加减工具后这里一直骗人,
而且服务离线时也照样显示,看不出工具其实一个都没注册上。 */}
{s.name === "mcp-go" || s.name === "mcp-py"
? `${data.tools.find((g) => g.server === s.name)?.tools?.length ?? 0}`
: `${s.latency_ms ?? 0} ms`}
</span>
</div>
</div>
);
})}
</div>
</section>
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-2">
<SectionHead title="基建环境" hint="5 个依赖" />
<div className="mt-4 divide-y divide-gray-100">
{data.infra.map((s) => (
<InfraRow key={s.name} item={s} />
))}
{/* 基建依赖 (右) */}
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-2">
<div className="border-b border-slate-100 pb-3.5 mb-4">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-cyan-500"></span>
</h3>
<p className="text-[10px] text-slate-400 mt-0.5"></p>
</div>
<div className="space-y-3.5">
{data.infra.map((item) => {
const meta = INFRA_META[item.name];
return (
<div key={item.name} className="flex items-center justify-between text-xs pb-3 border-b border-slate-100 last:border-0 last:pb-0">
<div className="flex min-w-0 items-center gap-2">
<span className="font-bold text-slate-800">{item.name}</span>
<span className="text-[10px] text-slate-400">{meta?.role || "后端组件"}</span>
{/* detail 是降级原因/后果的唯一载体(如"内存兜底·重启即清零"),必须露出来 */}
{item.detail && (
<span className={`truncate text-[10px] ${item.up ? "text-slate-400" : "text-amber-600"}`} title={item.detail}>
· {item.detail}
</span>
)}
</div>
<div className="flex items-center gap-3">
{item.up && item.latency_ms != null && item.latency_ms > 0 && (
<span className="font-mono text-[10px] text-slate-400 tabular-nums">{item.latency_ms}ms</span>
)}
<code className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
:{meta?.port || "—"}
</code>
<span className={`font-bold ${item.up ? "text-emerald-600" : "text-rose-500"}`}>
{item.up ? "就绪" : "离线"}
</span>
</div>
</div>
);
})}
</div>
</section>
</div>
{/* MCP 工具注册 */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
<SectionHead title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组" />
<div className="mt-4 space-y-5">
{/* NATS 集群 · JetStream Raft 副本健康 */}
{data.nats && (
<section className="rounded-2xl border border-gray-200 bg-white p-5">
<div className="border-b border-slate-100 pb-3.5 mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-indigo-500"></span>
NATS ·
</h3>
<p className="text-[10px] text-slate-400 mt-0.5">JetStream Raft //</p>
</div>
<div className="flex items-center gap-5 text-xs">
{[
{ k: "节点", v: String(data.nats.known_servers) },
{ k: "RTT", v: `${data.nats.rtt_ms}ms` },
{ k: "连接", v: data.nats.connected_to || "—" },
].map((m) => (
<div key={m.k} className="text-right">
<div className="text-[9px] uppercase text-slate-400 tracking-wider font-semibold">{m.k}</div>
<div className="font-bold text-slate-700 font-mono truncate max-w-[140px]" title={m.v}>{m.v}</div>
</div>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wide text-slate-400">
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium">Leader</th>
<th className="pb-2 font-medium text-right"></th>
<th className="pb-2 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{(data.nats.streams ?? []).map((s) => {
const ok = s.replicas_healthy >= s.replicas_total;
return (
<tr key={s.name} className="border-t border-slate-50">
<td className="py-2 font-mono text-slate-700">{s.name}</td>
<td className="py-2 font-mono text-[10px] text-slate-500 truncate max-w-[160px]" title={s.leader}>{s.leader || "—"}</td>
<td className="py-2 text-right">
<span className={`font-bold font-mono ${ok ? "text-emerald-600" : "text-amber-600"}`}>
{s.replicas_healthy}/{s.replicas_total}
</span>
{!ok && <span className="ml-1 text-[9px] text-amber-600"></span>}
</td>
<td className="py-2 text-right font-mono text-slate-500 tabular-nums">{s.messages.toLocaleString()}</td>
</tr>
);
})}
{(data.nats.streams ?? []).length === 0 && (
<tr>
<td colSpan={4} className="py-4 text-center text-[10px] text-slate-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</section>
)}
{/* MCP 工具注册箱 */}
<section className="rounded-2xl border border-gray-200 bg-white p-6 space-y-4">
<div className="border-b border-slate-100 pb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
MCP
</h3>
<p className="text-[10px] text-slate-400 mt-0.5"> MCP </p>
</div>
{/* Tab & Search */}
<div className="flex flex-wrap items-center gap-3">
{/* Tab Buttons */}
<div className="flex rounded-xl border border-slate-200 p-0.5 bg-slate-100/50 text-xs">
{data.tools.map((g) => (
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
<button
key={g.server}
onClick={() => {
setActiveServer(g.server);
setSelectedCategory("All");
}}
className={`px-4 py-2 rounded-lg font-bold transition-all duration-300 ${
activeServer === g.server
? "text-violet-700 bg-white shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{g.server} ({g.tools?.length ?? 0})
</button>
))}
</div>
{/* Search Input */}
<input
type="text"
placeholder="搜索函数别名 / 物理名称 / 作用..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-violet-200 focus:border-violet-400 w-56 shadow-sm transition"
/>
</div>
</div>
{/* Tag Pills Filter */}
<div className="flex flex-wrap items-center gap-1.5 text-xs text-slate-400 border-b border-slate-100 pb-3.5">
<span className="mr-2 font-bold text-slate-500 uppercase tracking-wider text-[10px]">:</span>
<button
onClick={() => setSelectedCategory("All")}
className={`px-3 py-1 rounded-full font-semibold transition ${
selectedCategory === "All"
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
}`}
>
</button>
{Object.keys(serverCategories).map((c) => (
<button
key={c}
onClick={() => setSelectedCategory(c)}
className={`px-3 py-1 rounded-full font-semibold transition ${
selectedCategory === c
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
}`}
>
{c} ({serverCategories[c]})
</button>
))}
</div>
{/* Tools Grid */}
{filteredTools.length === 0 ? (
<div className="py-12 text-center text-xs text-slate-400"> 🔍</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTools.map((t) => {
const cat = toolCategory(t.name);
const colorClass = CATEGORY_COLORS[cat] || CATEGORY_COLORS["系统"];
return (
<div key={t.name} className={`p-4 rounded-2xl flex flex-col justify-between transition-all duration-300 hover:-translate-y-0.5 border ${colorClass}`}>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-current"></span>
{t.cn}
</span>
<span className="text-[9px] font-semibold px-2 py-0.5 rounded bg-white border border-slate-200 uppercase font-mono">
{cat}
</span>
</div>
<code className="text-[10px] text-slate-400 font-mono block">{t.name}</code>
<p className="text-[11px] text-slate-500 leading-relaxed mt-2 line-clamp-2" title={t.desc}>
{t.desc}
</p>
</div>
</div>
);
})}
</div>
)}
</section>
</div>
);
}
// ---- 子组件 ----
// ---- 子组件 ----
function SectionHead({ title, hint }: { title: string; hint: string }) {
function BigStatCard({
label,
value,
subText,
barPercent,
barColor,
valueColor = "text-slate-800",
}: {
label: string;
value: string;
subText: string;
barPercent?: number;
barColor?: string;
valueColor?: string;
}) {
return (
<div className="flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900">{title}</h3>
<span className="text-xs text-gray-400">{hint}</span>
<div className="premium-card p-5 bg-white border border-gray-200 rounded-2xl shadow-sm hover:shadow-md transition">
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">{label}</span>
<div className="flex items-baseline gap-1 mt-2">
<span className={`text-2xl font-bold ${valueColor}`}>{value}</span>
</div>
{barPercent != null ? (
<div className="w-full bg-slate-100 h-1.5 rounded-full mt-3 overflow-hidden">
<div className={`h-full rounded-full ${barColor || "bg-emerald-500"}`} style={{ width: `${barPercent}%` }} />
</div>
) : (
<span className="text-[10px] text-slate-400 block mt-3 font-medium">{subText}</span>
)}
{barPercent != null && (
<span className="text-[10px] text-slate-400 block mt-1.5 font-medium">{subText}</span>
)}
</div>
);
}
function BigStat({ value, label, bad, muted }: { value: string | number; label: string; bad?: boolean; muted?: boolean }) {
return (
<div className="px-4 first:pl-0">
<div className={`text-2xl font-semibold tracking-tight ${bad ? "text-rose-500" : muted ? "text-gray-500" : "text-gray-900"}`}>{value}</div>
<div className="mt-0.5 text-[11px] text-gray-400">{label}</div>
</div>
);
}
function Dot({ up, partial }: { up?: boolean; partial?: boolean }) {
const c = up ? "bg-emerald-500" : partial ? "bg-amber-500" : "bg-rose-500";
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${c}`} />;
}
function FlowNode({
icon,
label,
@@ -250,159 +571,72 @@ function FlowNode({
latency?: number;
}) {
const down = !up && !partial;
const accent = down ? "text-rose-400" : partial ? "text-amber-300" : "text-emerald-300";
const ring = down ? "ring-rose-500/40" : partial ? "ring-amber-400/40" : "ring-emerald-400/30";
const glow = down
? "shadow-[0_0_18px_-4px_rgba(244,63,94,0.5)]"
: partial
? "shadow-[0_0_18px_-4px_rgba(245,158,11,0.45)]"
: "shadow-[0_0_22px_-6px_rgba(16,185,129,0.55)]";
// 决定 icon 背景和文字颜色配色
const iconColorClass = useMemo(() => {
if (icon === "monitor") return "bg-slate-100 text-slate-600";
if (icon === "gateway") return "bg-violet-50 text-violet-600";
if (icon === "bus") return "bg-cyan-50 text-cyan-600";
if (icon === "cpu") return "bg-amber-50 text-amber-600";
return "bg-rose-50 text-rose-600";
}, [icon]);
return (
<div className={`relative flex min-w-[116px] flex-1 flex-col items-center rounded-xl bg-white/[0.04] px-3 py-3.5 text-center ring-1 ${ring} ${glow} backdrop-blur-sm`}>
<span className="absolute right-2.5 top-2.5 flex h-2 w-2">
<span
className={`absolute inline-flex h-full w-full rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`}
style={{ animation: "sdxGlow 1.6s ease-in-out infinite" }}
/>
<span className={`relative inline-flex h-2 w-2 rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`} />
<div className="flex items-center gap-3 p-4 rounded-2xl bg-white border border-slate-200 shadow-sm w-44 hover:shadow-md transition duration-300">
<div className={`h-9 w-9 rounded-xl flex items-center justify-center ${iconColorClass}`}>
<Icon name={icon} className="h-4.5 w-4.5" />
</div>
<div>
<div className="text-xs font-bold text-slate-800">{label}</div>
<div className="text-[9px] text-slate-400 font-mono">
{sub}
{latency != null && (
<>
{" · "}
<span className={down ? "text-rose-500 font-bold" : "text-emerald-600 font-bold"}>
{latency}ms
</span>
<Icon name={icon} className={`mb-1.5 h-5 w-5 ${accent}`} />
<div className="text-xs font-semibold text-slate-100">{label}</div>
<div className="text-[10px] text-slate-400">{sub}</div>
{latency != null && <div className={`mt-1 font-mono text-[10px] ${down ? "text-rose-300" : "text-emerald-300/90"}`}>{latency}ms</div>}
</>
)}
</div>
</div>
</div>
);
}
// Link:连接线 + 沿线流动的数据光点(up 才流动;down 显示红色静态虚线)。
function Link({ up }: { up?: boolean }) {
if (!up) {
return (
<div className="relative mx-1 h-px min-w-[28px] flex-1 self-center bg-gradient-to-r from-rose-500/30 via-rose-500/40 to-rose-500/30" />
<div className="h-8 w-px md:h-px md:w-full bg-gradient-to-b md:bg-gradient-to-r from-rose-500/20 to-rose-500/20 relative" />
);
}
return (
<div className="relative mx-1 h-5 min-w-[28px] flex-1 self-center">
{/* 基线 */}
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-gradient-to-r from-emerald-400/20 via-cyan-400/30 to-emerald-400/20" />
{/* 流动光点(多个 + 错峰延迟 → 数据流动感) */}
{[0, 0.53, 1.06].map((d, i) => (
<div className="h-8 w-px md:h-px md:w-full bg-slate-200 relative flex items-center justify-center">
{/* 流动光点 */}
{[0, 0.8, 1.6].map((delay, idx) => (
<span
key={i}
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-cyan-300 shadow-[0_0_8px_2px_rgba(34,211,238,0.7)]"
style={{ animation: `sdxFlow 1.6s linear ${d}s infinite` }}
key={idx}
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-violet-500 shadow-[0_0_8px_rgba(139,92,246,0.6)]"
style={{ animation: `sdxFlow 2.4s linear ${delay}s infinite` }}
/>
))}
</div>
);
}
function ServiceRow({ item }: { item: StatusItem }) {
const meta = SERVICE_META[item.name];
return (
<div className="bg-white p-4">
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${item.up ? "bg-gray-50 text-gray-500" : "bg-rose-50 text-rose-500"}`}>
<Icon name={meta?.icon ?? "tool"} className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">{item.name}</span>
<Dot up={item.up} />
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "运行中" : "离线"}</span>
{item.up && item.latency_ms != null && <span className={`ml-auto font-mono text-[11px] ${latTone(item.latency_ms)}`}>{item.latency_ms}ms</span>}
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-400" title={meta?.role}>{meta?.role}</div>
</div>
</div>
<div className={`mt-2 truncate text-[11px] ${item.up ? "text-gray-500" : "text-rose-500"}`} title={item.detail}>{item.detail || (item.up ? "在线" : "无响应")}</div>
</div>
);
}
function InfraRow({ item }: { item: StatusItem }) {
const meta = INFRA_META[item.name];
return (
<div className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 shrink-0 ${item.up ? "text-gray-300" : "text-rose-400"}`} />
<span className="text-sm font-medium text-gray-800">{item.name}</span>
<span className="text-[11px] text-gray-400">{meta?.role}</span>
<code className="ml-auto font-mono text-[10px] text-gray-300">:{meta?.port}</code>
<span className="flex w-12 items-center justify-end gap-1.5">
<Dot up={item.up} />
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "就绪" : "离线"}</span>
</span>
</div>
);
}
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
const groups: Record<string, ToolInfo[]> = {};
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
const cats = Object.keys(groups).sort((a, b) => CAT_ORDER.indexOf(a) - CAT_ORDER.indexOf(b));
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Dot up={up} />
<span className="text-sm font-semibold text-gray-900">{server}</span>
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
{up &&
cats.map((c) => (
<span key={c} className="rounded-md bg-gray-50 px-2 py-0.5 text-[10px] text-gray-400">
{c} <span className="font-semibold text-gray-600">{groups[c].length}</span>
</span>
))}
</div>
{up && cats.length > 0 && (
<div className="space-y-4">
{cats.map((c) => {
const cs = CAT_STYLE[c] ?? CAT_STYLE["系统"];
return (
<div key={c}>
<div className="mb-2 flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${cs.dot}`} />
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{c}</span>
<span className="text-[10px] text-gray-300">{groups[c].length}</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{groups[c].map((t) => (
<div
key={t.name}
className="group rounded-xl border border-gray-200/70 bg-white p-3 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_4px_16px_-6px_rgba(124,58,237,0.25)]"
>
<div className="flex items-center gap-2">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${cs.dot}`} />
<span className="text-xs font-semibold text-gray-800">{t.cn}</span>
<code className="ml-auto truncate font-mono text-[10px] text-gray-400">{t.name}</code>
</div>
<div className="mt-1.5 line-clamp-2 text-[10px] leading-relaxed text-gray-400" title={t.desc}>
{t.desc}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ---- 内联图标 ----
type IconName = "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db";
const PATHS: Record<IconName, string> = {
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
bus: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M8.6 13.5l6.8 4 M15.4 6.5l-6.8 4",
cpu: "M6 6h12v12H6z M9 9h6v6H9z M9 1v3 M15 1v3 M9 20v3 M15 20v3 M1 9h3 M1 15h3 M20 9h3 M20 15h3",
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
gateway: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
bus: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4",
cpu: "M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4",
tool: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
};
function Icon({ name, className }: { name: IconName; className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -410,3 +644,4 @@ function Icon({ name, className }: { name: IconName; className?: string }) {
</svg>
);
}
@@ -0,0 +1,241 @@
import { useEffect, useState } from "react";
import { adminSubPlans, saveSubPlan, adminSubscriptions, type SubPlan, type SubRow } from "../api";
// 支付 · 订阅:套餐配置(价格/时长/发放节奏)+ 全平台订阅观测。
//
// 语义要在界面上讲清楚,否则配错了很难发现:
// - 手动购买制,**不自动续费**(微信 Native 无代扣能力),到期即失效;
// - 期内每 N 天发一次积分,发放是**累加**(不清零用户已有积分);
// - 因此一个周期实际发放次数 = floor(时长 / 间隔),配的时候直接算给人看。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const yuan = (fen: number) => (fen / 100).toFixed(2);
export function SubscriptionPage() {
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400">
·
</p>
</div>
<PlansBlock />
<SubsBlock />
</div>
);
}
function PlansBlock() {
const [rows, setRows] = useState<SubPlan[]>([]);
const [name, setName] = useState("");
const [price, setPrice] = useState("");
const [duration, setDuration] = useState("30");
const [refill, setRefill] = useState("");
const [interval, setIntervalDays] = useState("7");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const load = () => adminSubPlans().then(setRows).catch((e) => setErr((e as Error).message));
useEffect(() => {
void load();
}, []);
const add = async () => {
const p = Number(price), d = Number(duration), r = Number(refill), i = Number(interval);
if (!name.trim() || !p || !d || !r || !i || busy) return;
setBusy(true);
setErr("");
try {
await saveSubPlan({
name: name.trim(), price_fen: Math.round(p * 100), duration_days: d,
refill_credits: r, refill_interval_days: i, active: true, sort: 0,
});
setName(""); setPrice(""); setRefill("");
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
const toggle = async (p: SubPlan) => {
try {
await saveSubPlan({
id: p.id, name: p.name, price_fen: p.price_fen, duration_days: p.duration_days,
refill_credits: p.refill_credits_micro / MICRO, refill_interval_days: p.refill_interval_days,
active: !p.active, sort: p.sort,
});
await load();
} catch (e) {
setErr((e as Error).message);
}
};
// 一个周期发几次:让人在配的时候就看见后果,而不是上线后才发现只发了一次。
const times = (p: { duration_days: number; refill_interval_days: number }) =>
p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0;
const previewTimes = times({ duration_days: Number(duration) || 0, refill_interval_days: Number(interval) || 0 });
const previewTotal = previewTimes * (Number(refill) || 0);
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"></span>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-gray-500">
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:专业版月付"
className="mt-1 block w-36 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
¥
<input value={price} onChange={(e) => setPrice(e.target.value)} inputMode="decimal" placeholder="99"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={duration} onChange={(e) => setDuration(e.target.value)} inputMode="numeric"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={refill} onChange={(e) => setRefill(e.target.value)} inputMode="numeric" placeholder="1000"
className="mt-1 block w-28 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={interval} onChange={(e) => setIntervalDays(e.target.value)} inputMode="numeric"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<button onClick={() => void add()} disabled={busy}
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "…" : "新增"}
</button>
</div>
{/* 配置后果预览:省得上线后才发现节奏配错 */}
{previewTimes > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
<b className="text-violet-600">{previewTimes}</b> {" "}
<b className="text-violet-600">{previewTotal.toLocaleString("zh-CN")}</b>
{Number(duration) % Number(interval) !== 0 && (
<span className="text-amber-600">{Number(duration)} {Number(interval)} </span>
)}
</p>
)}
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
<div className="mt-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{yuan(p.price_fen)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{p.duration_days} </td>
<td className="py-2 pr-3 text-right text-[11px] tabular-nums text-gray-600">
{p.refill_interval_days} · {credits(p.refill_credits_micro)}
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">
{credits(p.refill_credits_micro * times(p))}
<span className="ml-1 text-[10px] text-gray-400">({times(p)} )</span>
</td>
<td className="py-2 text-right">
<button onClick={() => void toggle(p)}
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function SubsBlock() {
const [rows, setRows] = useState<SubRow[]>([]);
const [err, setErr] = useState("");
useEffect(() => {
adminSubscriptions().then(setRows).catch((e) => setErr((e as Error).message));
}, []);
const now = Date.now();
const daysLeft = (iso: string) => Math.ceil((new Date(iso).getTime() - now) / 86400000);
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"></span>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const left = daysLeft(s.expires_at);
return (
<tr key={s.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{s.tenant_name || s.tenant_id}</td>
<td className="py-2 pr-3 text-gray-600">{s.plan_name || "—"}</td>
<td className="py-2 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${s.status === "active" ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-500"}`}>
{s.status === "active" ? "生效中" : "已过期"}
</span>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-600">{s.refill_seq} </td>
<td className="py-2 text-right text-[11px] tabular-nums">
<span className="text-gray-500">{new Date(s.expires_at).toLocaleDateString("zh-CN")}</span>
{s.status === "active" && (
// 快到期的要显眼:到期即失效,没有自动续费兜底
<span className={`ml-1.5 ${left <= 3 ? "font-medium text-rose-600" : left <= 7 ? "text-amber-600" : "text-gray-400"}`}>
{left}
</span>
)}
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useState } from "react";
import { TaskDetailDrawer } from "../components/TaskDetailDrawer";
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
// 含 HITL 待审批(筛 waiting)。数据来自 sundynix_task,后端 GET /admin/tasks。
const STATUS: Record<string, { label: string; badge: string; dot: string }> = {
submitted: { label: "已提交", badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" },
running: { label: "运行中", badge: "bg-cyan-50 text-cyan-600", dot: "bg-cyan-500" },
done: { label: "完成", badge: "bg-emerald-50 text-emerald-600", dot: "bg-emerald-500" },
failed: { label: "失败", badge: "bg-rose-50 text-rose-500", dot: "bg-rose-500" },
timeout: { label: "超时", badge: "bg-amber-50 text-amber-600", dot: "bg-amber-500" },
waiting: { label: "待审批", badge: "bg-yellow-50 text-yellow-700", dot: "bg-yellow-500" },
rejected: { label: "已拒绝", badge: "bg-violet-50 text-violet-600", dot: "bg-violet-500" },
};
const st = (s: string) => STATUS[s] ?? { label: s, badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" };
// 筛选标签顺序(waiting 提前,运维最关心待审批 + 失败)。
const FILTERS = ["", "waiting", "running", "failed", "timeout", "done", "rejected"] as const;
const EVAL_LEVEL: Record<string, string> = {
excellent: "text-emerald-600",
good: "text-emerald-500",
fair: "text-amber-600",
poor: "text-rose-500",
};
export function TasksPage() {
const [tasks, setTasks] = useState<AdminTask[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [filter, setFilter] = useState("");
const [tenantFilter, setTenantFilter] = useState("");
const [tenants, setTenants] = useState<TenantRow[]>([]);
const [drill, setDrill] = useState<AdminTask | null>(null); // 正在下钻的任务
const [copiedId, setCopiedId] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
// 载入租户列表用于筛选
useEffect(() => {
listTenants()
.then(setTenants)
.catch((e) => console.error("Failed to load tenants:", e));
}, []);
const load = useCallback(() => {
setLoading(true);
adminTasks(filter, tenantFilter, 100)
.then((r) => {
setTasks(r.tasks);
setCounts(r.counts);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, [filter, tenantFilter]);
useEffect(load, [load]);
const handleCopy = (id: string) => {
navigator.clipboard.writeText(id)
.then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId(""), 1500);
})
.catch((err) => console.error("Failed to copy:", err));
};
const total = Object.values(counts).reduce((a, b) => a + b, 0);
return (
<div className="space-y-5">
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"> / </h3>
<span className="text-[11px] text-gray-400"> HITL </span>
</div>
{/* 状态计数卡片 */}
<div className="grid grid-cols-3 gap-3 lg:grid-cols-7">
<Stat label="全部" value={total} active={filter === ""} onClick={() => setFilter("")} />
{FILTERS.filter((f) => f).map((f) => (
<Stat key={f} label={st(f).label} value={counts[f] ?? 0} tone={f} active={filter === f} onClick={() => setFilter(f)} />
))}
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">
{loading ? "加载中…" : `${tasks.length}${filter ? `${st(filter).label}` : ""}`}
</span>
{/* 租户过滤下拉 */}
<select
value={tenantFilter}
onChange={(e) => setTenantFilter(e.target.value)}
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 focus:outline-none focus:border-violet-400"
>
<option value=""></option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name || t.slug || t.id}
</option>
))}
</select>
</div>
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">
</button>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<div className="max-h-[32rem] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"> / </th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{tasks.map((t) => (
<tr
key={t.task_id}
onClick={() => setDrill(t)}
className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40"
title="点击查看执行轨迹 / 输出 / 评测"
>
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td>
<td className="py-2 pr-3">
<div className="text-gray-800 flex items-center gap-1.5">
<span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}</code>}</span>
<button
onClick={(e) => {
e.stopPropagation(); // 复制不应顺带打开下钻
handleCopy(t.task_id);
}}
className="text-[10px] text-gray-400 hover:text-violet-600 transition"
title="复制任务 ID"
>
{copiedId === t.task_id ? "✓ 已复制" : "📋"}
</button>
</div>
{t.topic && (
<div className="text-[10px] text-gray-300 font-mono flex items-center gap-1">
<span>{t.task_id}</span>
</div>
)}
</td>
<td className="py-2 pr-3 text-gray-600">{t.tenant_name || <span className="text-gray-300">{t.tenant_id || "—"}</span>}</td>
<td className="py-2 pr-3 text-xs text-gray-500">{t.owner_email || t.owner || "—"}</td>
<td className="py-2 pr-3">
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ${st(t.status).badge}`}>
<span className={`h-1.5 w-1.5 rounded-full ${st(t.status).dot}`} />
{st(t.status).label}
</span>
</td>
<td className="py-2 pr-3 text-xs">
{t.eval_level ? (
<span className={EVAL_LEVEL[t.eval_level] ?? "text-gray-500"}>
{t.eval_level} · {(t.eval_overall * 100).toFixed(0)}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="py-2 max-w-[18rem] text-xs text-gray-500">
<span className="line-clamp-2" title={t.detail}>{t.detail || "—"}</span>
</td>
</tr>
))}
{tasks.length === 0 && !loading && (
<tr>
<td colSpan={7} className="py-8 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{drill && <TaskDetailDrawer task={drill} onClose={() => setDrill(null)} />}
</div>
);
}
function Stat({
label,
value,
tone,
active,
onClick,
}: {
label: string;
value: number;
tone?: string;
active: boolean;
onClick: () => void;
}) {
const dot = tone ? st(tone).dot : "bg-gray-400";
return (
<button
onClick={onClick}
className={`rounded-xl border p-3 text-left transition ${
active ? "border-violet-300 bg-violet-50" : "border-gray-100 bg-white hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
{label}
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-gray-800">{value}</div>
</button>
);
}
+167 -7
View File
@@ -1,5 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, setSharedBilling, type TenantRow, type Member } from "../api";
import {
listTenants,
createTenant,
listMembers,
addMember,
setMemberRole,
removeMember,
setSharedBilling,
setTenantPlan,
setTenantStatus,
type TenantRow,
type Member
} from "../api";
import { GrantCreditsModal } from "../components/GrantCreditsModal";
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
@@ -8,6 +21,9 @@ const ROLES = ["owner", "admin", "member", "viewer", "billing_admin"];
const ROLE_CN: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "计费管理" };
const credits = (m: number) => (m / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const PLANS = ["free", "pro", "enterprise"];
const PLAN_CN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
export function TenantsPage() {
const [tenants, setTenants] = useState<TenantRow[]>([]);
const [selId, setSelId] = useState<string>("");
@@ -19,6 +35,7 @@ export function TenantsPage() {
const [tName, setTName] = useState("");
const [tSlug, setTSlug] = useState("");
const [tOwner, setTOwner] = useState("");
const [tPlan, setTPlan] = useState("free");
const [creating, setCreating] = useState(false);
// 加成员表单
@@ -27,6 +44,11 @@ export function TenantsPage() {
const [adding, setAdding] = useState(false);
const [mErr, setMErr] = useState("");
// Modals state
const [editPlanTenant, setEditPlanTenant] = useState<TenantRow | null>(null);
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
const [statusChanging, setStatusChanging] = useState(false);
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
const loadTenants = async (keepSel = true) => {
@@ -67,10 +89,11 @@ export function TenantsPage() {
if (!tName.trim() || !tSlug.trim()) return;
setCreating(true);
try {
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined);
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined, tPlan);
setTName("");
setTSlug("");
setTOwner("");
setTPlan("free");
await loadTenants();
} catch (e) {
setErr((e as Error).message);
@@ -119,11 +142,89 @@ export function TenantsPage() {
}
};
const onPlanChange = async (plan: string) => {
if (!editPlanTenant) return;
try {
await setTenantPlan(editPlanTenant.id, plan);
setEditPlanTenant(null);
await loadTenants();
} catch (e) {
setErr(`修改 Plan 失败: ${(e as Error).message}`);
}
};
const onStatusToggle = async () => {
if (!selected) return;
const nextStatus = selected.status === "suspended" ? "active" : "suspended";
const actionText = nextStatus === "suspended" ? "暂停" : "恢复";
if (!window.confirm(`确定要 ${actionText} 租户「${selected.name}」吗?`)) return;
setStatusChanging(true);
try {
await setTenantStatus(selected.id, nextStatus);
await loadTenants();
} catch (e) {
setMErr(`${actionText}租户失败: ${(e as Error).message}`);
} finally {
setStatusChanging(false);
}
};
if (loading) return <div className="text-sm text-gray-400"></div>;
return (
<div className="space-y-4">
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
{/* 充值 Modal */}
<GrantCreditsModal
tenant={grantTenant}
onClose={() => setGrantTenant(null)}
onDone={() => void loadTenants()}
/>
{/* 修改方案 Modal */}
{editPlanTenant && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && setEditPlanTenant(null)}
>
<div className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900"></h3>
<p className="mt-0.5 text-xs text-gray-400"> {editPlanTenant.name} </p>
</div>
<button onClick={() => setEditPlanTenant(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="space-y-2">
{PLANS.map((p) => (
<button
key={p}
onClick={() => void onPlanChange(p)}
className={`w-full text-left p-3 rounded-lg border flex items-center justify-between transition-all ${
editPlanTenant.plan === p
? "border-violet-500 bg-violet-50/50 font-semibold text-violet-700"
: "border-gray-100 hover:bg-gray-50 text-gray-700"
}`}
>
<span>{PLAN_CN[p]}</span>
<span className="text-[11px] uppercase text-gray-400">{p}</span>
</button>
))}
</div>
</div>
<div className="bg-gray-50 px-6 py-4 flex justify-end">
<button onClick={() => setEditPlanTenant(null)} className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-xs text-gray-500 hover:bg-gray-50">
</button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 左:租户目录 + 建租户 */}
<div className="space-y-6">
@@ -140,8 +241,22 @@ export function TenantsPage() {
className={`w-full rounded-lg border p-3 text-left transition-all ${selId === t.id ? "border-violet-500 bg-violet-50/40 shadow-sm" : "border-gray-100 hover:bg-gray-50/60"}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">{t.name}</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] font-medium ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}>{t.plan}</span>
<span className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
{t.name}
{t.status === "suspended" && (
<span className="rounded bg-rose-100 text-rose-700 px-1 py-0.2 text-[8px] font-bold"></span>
)}
</span>
<span
onClick={(e) => {
e.stopPropagation();
setEditPlanTenant(t);
}}
title="点击修改 Plan"
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
>
{t.plan}
</span>
</div>
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
<span>{t.members} · <span className="font-mono">{t.slug}</span></span>
@@ -158,6 +273,20 @@ export function TenantsPage() {
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="租户名称(如:华泰投行部)" value={tName} onChange={(e) => setTName(e.target.value)} />
<input className="w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none" placeholder="唯一标识 slug(如 ht-ib" value={tSlug} onChange={(e) => setTSlug(e.target.value)} />
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="owner 邮箱(可选,须已注册)" value={tOwner} onChange={(e) => setTOwner(e.target.value)} />
<label className="block text-xs text-gray-500 space-y-1">
<span></span>
<select
value={tPlan}
onChange={(e) => setTPlan(e.target.value)}
className="w-full rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none"
>
{PLANS.map((p) => (
<option key={p} value={p}>{PLAN_CN[p]} ({p})</option>
))}
</select>
</label>
<button onClick={() => void onCreate()} disabled={creating || !tName.trim() || !tSlug.trim()} className="w-full rounded bg-violet-600 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-40">
{creating ? "创建中…" : "创建租户"}
</button>
@@ -174,19 +303,50 @@ export function TenantsPage() {
<>
<div className="mb-4 flex items-center justify-between border-b pb-3">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · </h4>
<span
onClick={() => setEditPlanTenant(selected)}
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${selected.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
>
{selected.plan}
</span>
</div>
<p className="text-[11px] text-gray-400"> · slug {selected.slug}</p>
</div>
<div className="flex items-center gap-2 text-right">
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} </span>
<button
onClick={() => setGrantTenant(selected)}
className="rounded border border-violet-200 px-2 py-0.5 text-[10px] text-violet-600 hover:bg-violet-50"
>
/
</button>
</div>
</div>
{/* 共享计费开关 */}
<label className="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
{/* 状态控制 & 共享计费开关 */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50/50">
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-600">
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-400">owner </span>
<span className="text-gray-400"></span>
</label>
<button
onClick={() => void onStatusToggle()}
disabled={statusChanging}
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold shadow-sm transition-all ${
selected.status === "suspended"
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100"
}`}
>
{selected.status === "suspended" ? "恢复租户账号" : "暂停租户账号"}
</button>
</div>
{/* 加成员 */}
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-violet-100 bg-violet-50/30 p-3">
<input className="flex-1 rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="按邮箱加入已注册用户" value={mEmail} onChange={(e) => setMEmail(e.target.value)} />
@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { getVoiceConfig, saveVoiceConfig } from "../api";
// 运维 · 语音设置:火山引擎豆包语音(流式 ASR 耳朵 + 双向流式 TTS 嘴)。
// AccessToken 明文回显(单管理员后台,方便核对;密文仍加密入库)。设计见 VOICE_DESIGN.md。
export function VoiceConfigPage() {
const [apiKey, setApiKey] = useState("");
const [asrRes, setAsrRes] = useState("");
const [ttsRes, setTtsRes] = useState("");
const [voice, setVoice] = useState("");
const [asrOn, setAsrOn] = useState(false);
const [ttsOn, setTtsOn] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [ok, setOk] = useState(false);
useEffect(() => {
getVoiceConfig()
.then((c) => {
setApiKey(c.api_key);
setAsrRes(c.asr_resource_id);
setTtsRes(c.tts_resource_id);
setVoice(c.tts_voice_type);
setAsrOn(c.asr_enabled);
setTtsOn(c.tts_enabled);
})
.catch((e) => setErr((e as Error).message));
}, []);
const save = async () => {
if (busy) return;
setBusy(true);
setErr("");
setOk(false);
try {
const r = await saveVoiceConfig({
api_key: apiKey,
asr_resource_id: asrRes.trim(),
tts_resource_id: ttsRes.trim(),
tts_voice_type: voice.trim(),
});
setAsrOn(r.asr_enabled);
setTtsOn(r.tts_enabled);
setOk(true);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
const field = "mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none";
const badge = (on: boolean, label: string) =>
on ? (
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600">{label} </span>
) : (
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">{label} </span>
);
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400">() + ()AccessToken </p>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700"></h4>
{badge(asrOn, "ASR")}
{badge(ttsOn, "TTS")}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="text-xs text-gray-500 md:col-span-2">
API Key(Authorization: Bearer &lt;APIKey&gt;)
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="火山控制台生成的 API Key" className={field} />
</label>
<label className="text-xs text-gray-500">
ASR Resource-Id
<input value={asrRes} onChange={(e) => setAsrRes(e.target.value)} placeholder="流式语音识别的 X-Api-Resource-Id" className={field} />
</label>
<label className="text-xs text-gray-500">
TTS Resource-Id
<input value={ttsRes} onChange={(e) => setTtsRes(e.target.value)} placeholder="双向流式 TTS 的 X-Api-Resource-Id" className={field} />
</label>
<label className="text-xs text-gray-500 md:col-span-2">
voice_type
<input value={voice} onChange={(e) => setVoice(e.target.value)} placeholder="从标准音色里挑一个(如 zh_male_…)" className={field} />
</label>
</div>
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
{ok && <p className="mt-2 text-xs text-emerald-600"></p>}
<div className="mt-3">
<button onClick={() => void save()} disabled={busy} className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "保存中…" : "保存"}
</button>
</div>
</div>
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
<h4 className="mb-2 text-sm font-semibold text-gray-700"> API Key </h4>
<ol className="list-decimal space-y-1.5 pl-4">
<li> <b>API Key</b> Key Authorization appid+token</li>
<li><b>ASR Resource-Id</b> 2.0X-Api-Resource-Id </li>
<li><b>TTS Resource-Id</b></li>
<li><b></b>JARVIS voice_type</li>
<li>/PCM 16k </li>
</ol>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { adminWechatUsers, type WechatUserRow } from "../api";
// 平台 · 微信用户:所有微信扫码登录的用户,集中查看。
// 每个用户仍是独立租户/独立积分(各买各的),这里只做统一观测。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
export function WechatUsersPage() {
const [rows, setRows] = useState<WechatUserRow[]>([]);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState("");
useEffect(() => {
adminWechatUsers()
.then(setRows)
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, []);
const copy = (s: string) => {
void navigator.clipboard?.writeText(s);
setCopied(s);
setTimeout(() => setCopied(""), 1200);
};
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"></h3>
<p className="text-xs text-gray-400">
· {rows.length} ·
</p>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium">OpenID</th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr key={u.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{u.name || "—"}</td>
<td className="py-2 pr-3">
{/* openid 长且要复制,点一下复制全串 */}
<button
onClick={() => copy(u.wechat_openid)}
title="点击复制"
className="font-mono text-[11px] text-gray-500 hover:text-violet-600"
>
{copied === u.wechat_openid ? "已复制 ✓" : `${u.wechat_openid.slice(0, 10)}${u.wechat_openid.slice(-6)}`}
</button>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{credits(u.balance_micro)}</td>
<td className="py-2 text-xs text-gray-500">{new Date(u.created_at).toLocaleString("zh-CN")}</td>
</tr>
))}
{rows.length === 0 && !loading && (
<tr>
<td colSpan={4} className="py-8 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+30 -5
View File
@@ -1,11 +1,15 @@
import { describe, it, expect } from "vitest";
import { routes, navGroups, defaultPath } from "./routes";
import { routes, navGroups, defaultPath, type NavNode, type RouteDef } from "./routes";
// 把嵌套导航拍平成路由数组(顶层项 + 二级子项),顺序即渲染顺序。
function flatten(nodes: NavNode[]): RouteDef[] {
return nodes.flatMap((n) => (n.kind === "item" ? [n.route] : n.items));
}
// routes 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。
describe("navGroups", () => {
it("按注册顺序归并分组,每条路由都落到对应组", () => {
const groups = navGroups();
const flat = groups.flatMap((g) => g.items);
const flat = navGroups().flatMap((g) => flatten(g.nodes));
// 不丢项、不重复
expect(flat).toHaveLength(routes.length);
expect(new Set(flat.map((r) => r.path)).size).toBe(routes.length);
@@ -17,10 +21,31 @@ describe("navGroups", () => {
expect(navGroups().map((g) => g.group)).toEqual(seen);
});
it("同组路由保持注册时的相对顺序", () => {
it("同组路由保持注册时的相对顺序(含二级子项)", () => {
for (const g of navGroups()) {
const expected = routes.filter((r) => r.group === g.group).map((r) => r.path);
expect(g.items.map((r) => r.path)).toEqual(expected);
expect(flatten(g.nodes).map((r) => r.path)).toEqual(expected);
}
});
it("带 parent 的路由聚成一个二级菜单,不散落成顶层项", () => {
for (const g of navGroups()) {
for (const n of g.nodes) {
if (n.kind === "item") {
expect(n.route.parent).toBeUndefined(); // 顶层项不该带 parent
} else {
expect(n.items.length).toBeGreaterThan(0);
// 同一个二级菜单下的子项,parent 必须都等于该菜单名
for (const r of n.items) expect(r.parent).toBe(n.label);
}
}
}
});
it("同 parent 的多条路由只生成一个二级菜单节点", () => {
for (const g of navGroups()) {
const labels = g.nodes.filter((n) => n.kind === "parent").map((n) => (n as Extract<NavNode, { kind: "parent" }>).label);
expect(new Set(labels).size).toBe(labels.length);
}
});
+115 -42
View File
@@ -1,3 +1,7 @@
import { SubscriptionPage } from "./pages/SubscriptionPage";
import { LoginConfigPage } from "./pages/LoginConfigPage";
import { VoiceConfigPage } from "./pages/VoiceConfigPage";
import { WechatUsersPage } from "./pages/WechatUsersPage";
import { lazy, type ReactNode } from "react";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
@@ -5,90 +9,152 @@ import { lazy, type ReactNode } from "react";
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage })));
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
const ModelConfigPage = lazy(() => import("./pages/ModelConfigPage").then((m) => ({ default: m.ModelConfigPage })));
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage })));
const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage })));
const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage })));
const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage })));
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage })));
const PaymentConfigPage = lazy(() => import("./pages/PaymentConfigPage").then((m) => ({ default: m.PaymentConfigPage })));
const PaymentOrdersPage = lazy(() => import("./pages/PaymentOrdersPage").then((m) => ({ default: m.PaymentOrdersPage })));
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage })));
export interface RouteDef {
path: string;
label: string;
group: string;
/** 二级菜单名。同 group 内 parent 相同的若干条会聚成一个可展开子菜单(如 运维 > 支付 > 配置/订单与对账)。 */
parent?: string;
ready?: boolean;
element: ReactNode;
}
/** 侧栏一个节点:要么是单条路由,要么是带子项的可展开二级菜单。 */
export type NavNode =
| { kind: "item"; route: RouteDef }
| { kind: "parent"; label: string; items: RouteDef[] };
export const routes: RouteDef[] = [
{
path: "/dashboard",
label: "概览",
path: "dashboard",
label: "仪表盘",
group: "分析",
ready: true,
element: <DashboardPage />,
},
{
path: "/usage",
label: "计费 & 用量",
group: "分析",
ready: true,
element: <UsagePage />,
},
{
path: "/models",
label: "模型",
group: "配置",
ready: true,
element: <ModelsPage />,
},
{
path: "/datasources",
label: "数据源 & RAG",
group: "配置",
ready: true,
element: <DatasourcesPage />,
},
{
path: "/prompts",
label: "提示词",
group: "配置",
ready: true,
element: <PromptsPage />,
},
{
path: "/status",
path: "status",
label: "服务状态",
group: "运维",
ready: true,
element: <StatusPage />,
},
{
path: "/evals",
path: "tasks",
label: "任务观测",
group: "运维",
ready: true,
element: <TasksPage />,
},
{
path: "evals",
label: "自动评测",
group: "运维",
ready: true,
element: <EvalsPage />,
},
{
path: "/audit",
path: "audit",
label: "审计 & 安全",
group: "运维",
ready: true,
element: <AuditPage />,
},
{
path: "/tenants",
path: "models",
label: "模型配置",
group: "运维",
ready: true,
element: <ModelConfigPage />,
},
{
path: "login-config",
label: "登录设置",
group: "运维",
ready: true,
element: <LoginConfigPage />,
},
{
path: "voice-config",
label: "语音设置",
group: "运维",
ready: true,
element: <VoiceConfigPage />,
},
{
path: "datasources",
label: "数据源 & RAG",
group: "运维",
ready: true,
element: <DatasourcesPage />,
},
{
path: "prompts",
label: "提示词",
group: "运维",
ready: true,
element: <PromptsPage />,
},
{
path: "payment/config",
label: "配置",
group: "运维",
parent: "支付",
ready: true,
element: <PaymentConfigPage />,
},
{
path: "payment/subscription",
label: "订阅",
group: "运维",
parent: "支付",
ready: true,
element: <SubscriptionPage />,
},
{
path: "payment/orders",
label: "订单与对账",
group: "运维",
parent: "支付",
ready: true,
element: <PaymentOrdersPage />,
},
{
path: "tenants",
label: "租户 & 用户",
group: "平台",
ready: true,
element: <TenantsPage />,
},
{
path: "/guardrails",
path: "wechat-users",
label: "微信用户",
group: "平台",
ready: true,
element: <WechatUsersPage />,
},
{
path: "spaces",
label: "空间",
group: "平台",
ready: true,
element: <SpacesPage />,
},
{
path: "guardrails",
label: "安全护栏",
group: "平台",
ready: true,
@@ -96,18 +162,25 @@ export const routes: RouteDef[] = [
},
];
export const defaultPath = "/dashboard";
export const defaultPath = "dashboard";
// 派生分组导航(保持注册顺序)。
export function navGroups(): Array<{ group: string; items: RouteDef[] }> {
const out: Array<{ group: string; items: RouteDef[] }> = [];
export function navGroups(): Array<{ group: string; nodes: NavNode[] }> {
const out: Array<{ group: string; nodes: NavNode[] }> = [];
for (const r of routes) {
let g = out.find((x) => x.group === r.group);
if (!g) {
g = { group: r.group, items: [] };
g = { group: r.group, nodes: [] };
out.push(g);
}
g.items.push(r);
if (!r.parent) {
g.nodes.push({ kind: "item", route: r });
continue;
}
// 同 parent 的归到同一个可展开节点(首次出现决定它在组内的位置)。
const exist = g.nodes.find((n): n is Extract<NavNode, { kind: "parent" }> => n.kind === "parent" && n.label === r.parent);
if (exist) exist.items.push(r);
else g.nodes.push({ kind: "parent", label: r.parent, items: [r] });
}
return out;
}
+266 -39
View File
@@ -1,65 +1,229 @@
import { Suspense, useEffect, useState } from "react";
import React, { Suspense, useEffect, useState } from "react";
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { routes, navGroups, defaultPath } from "../routes";
import { gatewayOnline, type AuthUser } from "../api";
import { routes, navGroups, defaultPath, type RouteDef } from "../routes";
import { getStatus, type AuthUser } from "../api";
const ADMIN_BASE = "/admin";
// 路线路径到 SVG 图标的映射
const ICON_MAP: Record<string, React.ReactNode> = {
dashboard: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v10a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z" />
</svg>
),
models: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
datasources: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
prompts: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
),
status: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z" />
</svg>
),
tasks: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
evals: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
audit: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
),
"login-config": (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
"voice-config": (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
),
"wechat-users": (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
tenants: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
spaces: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
guardrails: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
"payment/config": (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
"payment/orders": (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 14l2 2 4-4M7 21h10a2 2 0 002-2V7l-4-4H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
"payment/subscription": (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
};
// 二级菜单(parent)自己的图标,按 parent 名索引。
const PARENT_ICON: Record<string, React.ReactNode> = {
: (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
),
};
// 控制台外壳:导航与内容均由路由注册表派生(动态路由)。
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const [online, setOnline] = useState(false);
// 健康状态对象
const [health, setHealth] = useState({
gateway: false,
dispatcher: false,
postgres: false,
redis: false,
nats: false,
});
const loc = useLocation();
const current = routes.find((r) => r.path === loc.pathname);
const current = routes.find((r) => `${ADMIN_BASE}/${r.path}` === loc.pathname);
useEffect(() => {
const ping = () => gatewayOnline().then(setOnline);
const ping = () => {
getStatus()
.then((res) => {
const pgUp = res.infra?.find((x) => x.name === "postgres")?.up ?? false;
const redisUp = res.infra?.find((x) => x.name === "redis")?.up ?? false;
const natsUp = res.infra?.find((x) => x.name === "nats")?.up ?? false;
const dispatcherUp = res.services?.find((x) => x.name === "dispatcher")?.up ?? false;
setHealth({
gateway: true, // 能获取到 status 意味着 Gateway 必然在线
dispatcher: dispatcherUp,
postgres: pgUp,
redis: redisUp,
nats: natsUp,
});
})
.catch(() => {
// 获取失败,全部下线
setHealth({
gateway: false,
dispatcher: false,
postgres: false,
redis: false,
nats: false,
});
});
};
ping();
const id = setInterval(ping, 4000);
const id = setInterval(ping, 5000);
return () => clearInterval(id);
}, []);
return (
<div className="flex h-screen w-screen text-gray-900">
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
<div className="border-b p-4">
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
<div className="text-[11px] text-gray-400"></div>
<div className="flex h-screen w-screen text-gray-900 bg-gray-50/20">
<aside className="flex w-56 shrink-0 flex-col border-r border-gray-150 bg-gray-55/40">
<div className="border-b border-gray-100 p-5">
<div className="text-sm font-bold text-gray-800 tracking-tight">sundynix-agentix</div>
<div className="text-[10px] text-gray-400 font-medium"></div>
</div>
<nav className="flex flex-col gap-1 p-2">
<nav className="flex-1 flex flex-col gap-1.5 p-3 overflow-y-auto">
{navGroups().map((g) => (
<div key={g.group}>
<div className="mt-2 px-3 text-[9px] font-semibold tracking-wider text-gray-300">{g.group}</div>
{g.items.map((r) => (
<NavLink
key={r.path}
to={r.path}
className={({ isActive }) =>
`flex items-center justify-between rounded px-3 py-2 text-sm ${
isActive ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
}`
}
>
{r.label}
{!r.ready && <span className="text-[9px] text-gray-300"></span>}
</NavLink>
))}
<div key={g.group} className="space-y-1">
<div className="px-3 py-1 text-[9px] font-bold uppercase tracking-wider text-gray-400">{g.group}</div>
{g.nodes.map((n) =>
n.kind === "item" ? (
<NavItem key={n.route.path} route={n.route} />
) : (
<NavParent key={n.label} label={n.label} items={n.items} />
),
)}
</div>
))}
</nav>
<div className="mt-auto border-t p-4 text-[11px] text-gray-500">
<div className="mb-2 flex items-center justify-between">
<span className="truncate text-gray-600" title={user.email}>{user.name || user.email}</span>
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600"></button>
<div className="border-t border-gray-100 p-4 text-xs text-gray-500 bg-white/50 space-y-3">
<div className="flex items-center justify-between">
<span className="truncate text-gray-700 font-semibold" title={user.email}>{user.name || user.email}</span>
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600 font-semibold transition"></button>
</div>
{/* 健康度指示栏 */}
<div className="pt-2 border-t border-gray-100/50">
<div className="mb-1 text-[10px] font-bold text-gray-400 uppercase tracking-wide"></div>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
Gateway {online ? "在线" : "离线"}
<div className="flex items-center gap-1.5">
{/* Gateway dot */}
<span
title={`Gateway: ${health.gateway ? "在线" : "离线"}`}
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.gateway ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
/>
{/* Dispatcher dot */}
<span
title={`Dispatcher: ${health.dispatcher ? "在线" : "离线"}`}
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.dispatcher ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
/>
{/* Postgres dot */}
<span
title={`Postgres: ${health.postgres ? "在线" : "离线"}`}
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.postgres ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
/>
{/* Redis dot */}
<span
title={`Redis: ${health.redis ? "在线" : "离线"}`}
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.redis ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
/>
{/* NATS dot */}
<span
title={`NATS: ${health.nats ? "在线" : "离线"}`}
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.nats ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
/>
</div>
<span className="text-[10px] text-gray-400 font-medium">
{health.gateway && health.dispatcher && health.postgres && health.redis && health.nats ? "正常" : "部分受损"}
</span>
</div>
</div>
</div>
</aside>
<main className="flex-1 overflow-auto p-6">
<h1 className="mb-4 text-lg font-semibold text-gray-800">{current?.label ?? ""}</h1>
<Suspense fallback={<div className="text-sm text-gray-400"></div>}>
<main className="flex-1 overflow-auto p-6 bg-white/70">
<h1 className="mb-4 text-base font-bold text-gray-800 tracking-tight">{current?.label ?? ""}</h1>
<Suspense fallback={<div className="text-xs text-gray-400 animate-pulse"></div>}>
<Routes>
{routes.map((r) => (
<Route key={r.path} path={r.path} element={r.element} />
@@ -71,3 +235,66 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
</div>
);
}
// 单条导航项(顶层或二级子项共用)。
function NavItem({ route: r }: { route: RouteDef }) {
return (
<NavLink
to={`${ADMIN_BASE}/${r.path}`}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
isActive
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`
}
>
<span className="shrink-0">{ICON_MAP[r.path]}</span>
<span className="flex-1 truncate">{r.label}</span>
{!r.ready && <span className="rounded bg-gray-100 px-1 text-[8px] text-gray-400"></span>}
</NavLink>
);
}
// 可展开的二级菜单(如 运维 > 支付 > 配置 / 订单与对账)。
// 子项命中时自动展开;父项本身不可导航,只负责收起/展开。
function NavParent({ label, items }: { label: string; items: RouteDef[] }) {
const loc = useLocation();
const hasActive = items.some((r) => loc.pathname === `${ADMIN_BASE}/${r.path}`);
const [open, setOpen] = useState(hasActive);
useEffect(() => {
if (hasActive) setOpen(true);
}, [hasActive]);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
hasActive ? "text-violet-700" : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`}
>
<span className="shrink-0">{PARENT_ICON[label]}</span>
<span className="flex-1 truncate text-left">{label}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{open && (
<div className="ml-4 mt-1 space-y-1 border-l border-gray-200 pl-2">
{items.map((r) => (
<NavItem key={r.path} route={r} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,31 @@
import { DOWNLOADS } from '@/content/site'
import { cn } from '@/lib/utils'
export function DownloadGrid() {
return (
<div className="mx-auto grid max-w-[780px] grid-cols-1 gap-4 sm:grid-cols-3">
{DOWNLOADS.map((d) => (
<div
key={d.os}
className="rounded-xl border border-hairline bg-surface p-6 text-center transition-colors hover:border-accent"
>
<div className="mb-1 text-[15.5px] font-semibold">{d.os}</div>
<div className="mb-4 font-mono text-[11.5px] text-ink-3">
{d.meta}
</div>
<a
href={d.href}
className={cn(
'inline-block rounded-[7px] px-5 py-1.5 text-[13.5px] font-medium transition-opacity hover:opacity-90',
d.highlight
? 'bg-accent text-ground'
: 'border border-hairline-strong text-accent-ink',
)}
>
{d.action}
</a>
</div>
))}
</div>
)
}
@@ -0,0 +1,85 @@
import { Link } from 'react-router-dom'
import { SITE } from '@/content/site'
import { LogoMark } from '@/components/logo'
const COLUMNS = [
{
title: 'PRODUCT',
links: [
{ label: '功能', href: '/#features' },
{ label: '架构', href: '/#architecture' },
{ label: '下载', href: '/download', internal: true },
],
},
{
title: 'RESOURCES',
links: [
{ label: '下载桌面版', href: '/download', internal: true },
{ label: '源码仓库', href: SITE.repo },
],
},
] as const
export function SiteFooter() {
return (
<footer className="border-t border-hairline">
<div className="mx-auto grid max-w-6xl gap-10 px-6 py-12 md:grid-cols-[1.5fr_1fr_1fr] lg:px-10">
<div>
<p className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
<LogoMark size={30} />
sundynix <em className="not-italic text-accent">agentix</em>
</p>
<p className="mt-2 text-[14px] text-ink-2">{SITE.tagline}</p>
<p className="mt-4 font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
{SITE.version} · MACOS / WINDOWS / SELF-HOSTED
</p>
</div>
{COLUMNS.map((col) => (
<nav key={col.title} aria-label={col.title}>
<p className="mb-3.5 font-mono text-[11px] tracking-[0.16em] text-ink-3">
{col.title}
</p>
<ul className="space-y-2.5">
{col.links.map((link) => (
<li key={link.label}>
{'internal' in link && link.internal ? (
<Link
to={link.href}
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
>
{link.label}
</Link>
) : (
<a
href={link.href}
target={link.href.startsWith('http') ? '_blank' : undefined}
rel={link.href.startsWith('http') ? 'noreferrer' : undefined}
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
>
{link.label}
</a>
)}
</li>
))}
</ul>
</nav>
))}
</div>
<div className="border-t border-hairline">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-6 py-5 font-mono text-[12px] text-ink-3 lg:px-10">
<span>© 2026 SUNDYNIX AGENTIX</span>
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-accent-ink"
>
{SITE.icp}
</a>
</div>
</div>
</footer>
)
}
@@ -0,0 +1,91 @@
import { useState } from 'react'
import { Link, NavLink } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { LogoMark } from '@/components/logo'
import { cn } from '@/lib/utils'
// 官网默认浅色,不搬深浅切换(ThemeProvider 未挂载)。
const NAV_ITEMS = [
{ label: '产品', to: '/', end: true },
{ label: '功能', to: '/#features' },
{ label: '架构', to: '/#architecture' },
{ label: '定价', to: '/pricing' },
]
export function SiteHeader() {
const [open, setOpen] = useState(false)
const links = NAV_ITEMS.map((item) =>
item.to.includes('#') ? (
<a
key={item.to}
href={item.to}
onClick={() => setOpen(false)}
className="text-sm text-ink-2 transition-colors hover:text-ink"
>
{item.label}
</a>
) : (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={() => setOpen(false)}
className={({ isActive }) =>
cn(
'text-sm transition-colors hover:text-ink',
isActive ? 'text-ink' : 'text-ink-2',
)
}
>
{item.label}
</NavLink>
),
)
return (
<header className="bg-ground-85 sticky top-0 z-40 border-b border-hairline backdrop-blur">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6 lg:px-10">
<Link to="/" className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
<LogoMark size={26} />
sundynix <em className="not-italic text-accent">agentix</em>
</Link>
<nav className="hidden items-center gap-7 md:flex">
{links}
<Link
to="/download"
className="rounded-[7px] bg-accent px-4 py-1.5 text-[13px] font-medium text-ground transition-opacity hover:opacity-90"
>
</Link>
</nav>
<div className="flex items-center gap-3 md:hidden">
<button
type="button"
aria-label={open ? '关闭菜单' : '打开菜单'}
onClick={() => setOpen((v) => !v)}
className="flex size-8 items-center justify-center text-ink-2"
>
{open ? <X className="size-5" /> : <Menu className="size-5" />}
</button>
</div>
</div>
{open && (
<nav className="flex flex-col gap-4 border-t border-hairline px-6 py-5 md:hidden">
{links}
<Link
to="/download"
onClick={() => setOpen(false)}
className="w-fit rounded-[7px] bg-accent px-4 py-1.5 text-[13px] font-medium text-ground"
>
</Link>
</nav>
)}
</header>
)
}
@@ -0,0 +1,22 @@
import { useEffect } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { SiteHeader } from '@/components/layout/site-header'
import { SiteFooter } from '@/components/layout/site-footer'
export function SiteLayout() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo(0, 0)
}, [pathname])
return (
<div className="flex min-h-screen flex-col bg-ground font-sans text-ink antialiased">
<SiteHeader />
<main className="flex-1">
<Outlet />
</main>
<SiteFooter />
</div>
)
}
@@ -0,0 +1,19 @@
interface LogoMarkProps {
size?: number
className?: string
}
/** 品牌 Logo:霓虹数据流 S(用户原版位图裁切,源图在 design-assets/ */
export function LogoMark({ size = 28, className }: LogoMarkProps) {
return (
<img
src="/brand/logo-mark.webp"
width={size}
height={size}
alt=""
aria-hidden="true"
className={className}
style={{ borderRadius: '22%' }}
/>
)
}
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import { createOrder, orderStatus } from '../lib/site-api'
// 官网购买支付弹窗:下单 → 微信 code_url 画二维码 → 轮询单态。
// 复用后端支付链路(下单/回调/查单/掉单补偿全共用),与桌面端/联调台同一套。
const POLL_MS = 2500
type Phase = 'creating' | 'waiting' | 'paid' | 'expired'
export function PayDialog({
item,
onClose,
}: {
item: { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number }
onClose: (paid: boolean) => void
}) {
const [qr, setQr] = useState('')
const [phase, setPhase] = useState<Phase>('creating')
const [err, setErr] = useState('')
const [warn, setWarn] = useState('')
const orderRef = useRef('')
useEffect(() => {
let alive = true
let timer: number | null = null
void (async () => {
try {
const o = await createOrder(item.kind === 'sub' ? { planId: item.id } : { packId: item.id })
if (!alive) return
orderRef.current = o.order_id
setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 }))
setPhase('waiting')
const tick = async () => {
if (!alive) return
try {
const s = await orderStatus(orderRef.current)
if (!alive) return
setWarn(s.warn ?? '')
if (s.status === 'paid') {
setPhase('paid')
window.setTimeout(() => onClose(true), 900)
return
}
if (s.status === 'expired' || s.status === 'failed') {
setPhase('expired')
return
}
} catch {
/* 单次失败忽略 */
}
timer = window.setTimeout(() => void tick(), POLL_MS)
}
timer = window.setTimeout(() => void tick(), POLL_MS)
} catch (e) {
if (alive) setErr((e as Error).message)
}
})()
return () => {
alive = false
if (timer) window.clearTimeout(timer)
}
}, [item, onClose])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => onClose(phase === 'paid')}>
<div className="w-full max-w-sm rounded-2xl border border-hairline bg-surface p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-ink"> · {item.name}</h3>
<button onClick={() => onClose(phase === 'paid')} className="text-sm text-ink-3 hover:text-ink">
</button>
</div>
<div className="mt-5 flex flex-col items-center gap-3">
{err ? (
<p className="py-10 text-center text-sm text-red-500">{err}</p>
) : phase === 'creating' ? (
<p className="py-16 text-sm text-ink-3"></p>
) : (
<>
<div className="relative rounded-xl bg-white p-3">
<img src={qr} alt="微信支付二维码" width={220} height={220} />
{(phase === 'paid' || phase === 'expired') && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-white/85 text-sm font-medium text-ink">
{phase === 'paid' ? '✅ 支付成功' : '二维码已失效'}
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-ink">¥{(item.priceFen / 100).toFixed(2)}</div>
{phase === 'waiting' && <div className="mt-0.5 text-xs text-ink-3"></div>}
</div>
</>
)}
{warn && <p className="w-full rounded-lg bg-amber-50 px-3 py-2 text-center text-xs text-amber-700">{warn}</p>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,45 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface RevealProps {
children: ReactNode
className?: string
/** 进场延迟(毫秒),用于同屏元素错峰 */
delay?: number
}
/** 滚动进场:进入视口后淡入上移一次;prefers-reduced-motion 时直接显示 */
export function Reveal({ children, className, delay = 0 }: RevealProps) {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setVisible(true)
return
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true)
io.disconnect()
}
},
{ threshold: 0.15, rootMargin: '0px 0px -40px 0px' },
)
io.observe(el)
return () => io.disconnect()
}, [])
return (
<div
ref={ref}
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
className={cn('reveal', visible && 'reveal-in', className)}
>
{children}
</div>
)
}
@@ -0,0 +1,7 @@
export function SectionLabel({ children }: { children: string }) {
return (
<span className="mb-2.5 block font-mono text-[11px] tracking-[0.18em] text-accent">
{children}
</span>
)
}
@@ -0,0 +1,41 @@
import { TERMINAL_DEMO } from '@/content/site'
/** Hero 事件流终端窗 — 双主题共用深色 */
export function TerminalDemo() {
return (
<div className="mx-auto mt-13 max-w-[720px] overflow-hidden rounded-t-xl border border-b-0 border-hairline-strong bg-term text-left">
<div className="flex items-center gap-2 border-b border-white/[0.07] px-4 py-2.5">
{[0, 1, 2].map((i) => (
<span key={i} className="size-2.5 rounded-full bg-white/[0.18]" />
))}
<span className="ml-2.5 font-mono text-[11.5px] tracking-[0.1em] text-white/40">
{TERMINAL_DEMO.title}
</span>
</div>
<div className="overflow-x-auto px-5 pb-6 pt-5 font-mono text-[13.5px] leading-[2.05] text-term-ink">
<div className="term-line">
<span className="font-semibold text-[#22d3ee]"> </span>
{TERMINAL_DEMO.prompt}
</div>
<div className="term-line text-white/35" style={{ animationDelay: '0.35s' }}>
</div>
{TERMINAL_DEMO.events.map((ev, i) => (
<div
key={ev.tag}
className="term-line whitespace-nowrap"
style={{ animationDelay: `${0.7 + i * 0.45}s` }}
>
<span className={ev.ok ? 'text-[#a6d96a]' : 'text-[#60a5fa]'}>
[{ev.tag}]
</span>{' '}
{ev.text}
{i === TERMINAL_DEMO.events.length - 1 && (
<span className="caret-blink ml-1 inline-block h-[15px] w-2 translate-y-0.5 bg-[#22d3ee]" />
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react'
import { wxTicket, wxPoll, type SiteUser } from '../lib/site-api'
// 官网微信扫码登录弹窗:建票拿微信二维码图 → 展示 → 轮询登录态。
// 扫码后弹公众号关注页,关注即登录(带参二维码 + 关注/扫码事件)。
export function WechatLoginDialog({ onClose, onLoggedIn }: { onClose: () => void; onLoggedIn: (u: SiteUser) => void }) {
const [qr, setQr] = useState('')
const [err, setErr] = useState('')
const [expired, setExpired] = useState(false)
const [nonce, setNonce] = useState(0)
const ticketRef = useRef('')
useEffect(() => {
let alive = true
let timer: number | null = null
setErr('')
setExpired(false)
setQr('')
void (async () => {
try {
const t = await wxTicket()
if (!alive) return
ticketRef.current = t.ticket
setQr(t.qr_image)
const deadline = Date.now() + t.expires_in * 1000
const tick = async () => {
if (!alive) return
if (Date.now() > deadline) {
setExpired(true)
return
}
try {
const r = await wxPoll(ticketRef.current)
if (!alive) return
if (r.status === 'authorized' && r.user) {
onLoggedIn(r.user)
return
}
if (r.status === 'expired') {
setExpired(true)
return
}
} catch {
/* 单次失败忽略,继续轮询 */
}
timer = window.setTimeout(() => void tick(), 2000)
}
timer = window.setTimeout(() => void tick(), 2000)
} catch (e) {
if (alive) setErr((e as Error).message)
}
})()
return () => {
alive = false
if (timer) window.clearTimeout(timer)
}
}, [nonce, onLoggedIn])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl border border-hairline bg-surface p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-ink"></h3>
<button onClick={onClose} className="text-sm text-ink-3 hover:text-ink">
</button>
</div>
<div className="mt-5 flex flex-col items-center gap-3">
{err ? (
<p className="py-10 text-center text-sm text-red-500">{err}</p>
) : qr ? (
<div className="relative rounded-xl bg-white p-3">
<img src={qr} alt="微信登录二维码" width={220} height={220} />
{expired && (
<button
onClick={() => setNonce((n) => n + 1)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/70 text-sm text-white"
>
<span className="rounded bg-white/20 px-2 py-1 text-xs"></span>
</button>
)}
</div>
) : (
<p className="py-16 text-sm text-ink-3"></p>
)}
<p className="text-center text-xs text-ink-3"></p>
</div>
</div>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
/** 站点文案与数据,改这里即可,不用动组件 */
export const SITE = {
/** 静态回退版本号;线上以 /api/releases/latest 为准 */
version: 'v0.1.2',
tagline: '事件驱动的 AI Agent 工作台',
repo: 'https://git.sundynix.cn/sundynix/sundynix-agentix',
icp: '滇ICP备2025056308号-1',
}
export const TERMINAL_DEMO = {
title: 'SUNDYNIX.STREAMS.RT-7F2A',
prompt: '帮我调研「2026 国产大模型推理成本」,出一份带图表的报告',
events: [
{
tag: 'coordinator',
text: '已拆解为 3 个子任务,派发 researcher × 2 · writer × 1',
},
{
tag: 'knowledge',
text: '三路混合检索 · vector + fulltext + graph · RRF 融合',
},
{ tag: 'harness', text: '输出守卫通过 · 预算 ¥2.00 剩余 ¥1.37' },
{
tag: 'report',
text: '大纲 5 章已确认,并行撰写中 → 推理成本分析.docx',
ok: true,
},
],
}
export const FEATURES = [
{
label: 'STUDIO',
title: '可视化编排画布',
desc: 'React Flow 画布拖出工作流,导出 JSON DSL 直接执行;分支路由、map 并行扇出、⌘K 命令面板。',
},
{
label: 'MULTI-AGENT',
title: '多智能体协作',
desc: 'OrchestratorWorker 模式:主脑撰写任务简报,并行派发专家 Agent,团队视图实时看它们「上班」。',
},
{
label: 'KNOWLEDGE',
title: '三路混合检索知识库',
desc: '向量 + 全文 + 知识图谱三路召回,RRF 融合重排;双链笔记、反向链接与关系图谱,检索过程可调试。',
},
{
label: 'REPORT',
title: '真 · Word 报告生成',
desc: '选题 → 大纲规划 → 分章并行研究撰写,产出原生 .docx(零依赖 OOXML 渲染),另有 PDF / Markdown。',
},
{
label: 'MEMORY',
title: '长期记忆',
desc: '异步批量固化(ADD/UPDATE/DELETE),近因 × 重要性衰减打分,记忆面板随时可查可改。',
},
{
label: 'HARNESS',
title: '可靠性套件',
desc: '提示注入拦截、流式密钥脱敏、熔断器、LLM 评审自动纠偏、成本预算上限——生产级守卫全内置。',
},
{
label: 'SANDBOX',
title: '代码解释器沙箱',
desc: 'AST 静态审查 + Docker 隔离:无网络、非 root、只读根文件系统,让 Agent 放心跑代码。',
},
{
label: 'STREAMING',
title: '一切皆流式',
desc: 'NATS 零拷贝骨干网直推 SSE/WS,节点级执行轨迹实时可见,Prometheus + OTel 全链路观测。',
},
]
export const ARCH_LAYERS = [
{
id: 'L1 · CLIENT',
name: '桌面工作台',
detail: '· Web / 管理控制台',
tech: 'wails3 · react19',
},
{
id: 'L2 · GATEWAY',
name: 'API 网关',
detail: '· 鉴权 / DSL 解析 / 计费 / 守卫',
tech: 'gin · postgres · redis',
},
{
id: 'L4 · DISPATCHER',
name: '图执行引擎',
detail: '· Agent Loop / LLM Pool',
tech: 'eino · openai-compat',
},
{
id: 'L5 · TOOLS',
name: 'MCP 工具层',
detail: '· 检索 / 解析 / 沙箱',
tech: 'milvus · bleve · neo4j · py3.11',
},
]
export const QUICKSTART_STEPS = [
{
title: '下载或克隆。',
desc: '桌面版开箱即用;自部署走 docker compose。',
},
{
title: '接入模型。',
desc: '任何 OpenAI 兼容端点:DeepSeek、百炼……填 Key 即可。',
},
{
title: '喂知识,派任务。',
desc: '拖入文档建库,画布编排或直接对话,看团队开工。',
},
]
export const DOWNLOADS = [
{
os: 'macOS',
meta: 'universal · .app · v0.1.2',
action: '下载 .dmg',
href: `${SITE.repo}/releases`,
highlight: true,
},
{
os: 'Windows',
meta: 'x64 · .exe · v0.1.2',
action: '下载 .exe',
href: `${SITE.repo}/releases`,
highlight: false,
},
{
os: '自托管',
meta: 'docker compose · 全平台',
action: '部署文档',
href: SITE.repo,
highlight: false,
},
]
@@ -0,0 +1,14 @@
import { useEffect } from 'react'
const BASE = 'sundynix agentix'
const DEFAULT = `${BASE} — 事件驱动的 AI Agent 工作台`
/** 设置页面标题;不传参恢复默认 */
export function usePageTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title}${BASE}` : DEFAULT
return () => {
document.title = DEFAULT
}
}, [title])
}
@@ -0,0 +1,22 @@
import { SITE } from '@/content/site'
export interface ReleaseInfo {
version: string
page_url: string
macos_url?: string
windows_url?: string
source: 'gitea' | 'fallback'
}
// 官网融进 admin 后不接 posts/releases 后端(营销落地页 only):直接用静态版本号,
// 去掉原来那次必然失败的 fetch('/api/releases/latest')。
const STATIC: ReleaseInfo = {
version: SITE.version,
page_url: `${SITE.repo}/releases`,
source: 'fallback',
}
/** 最新版本信息(静态,来自 content/site.ts)。 */
export function useRelease(): ReleaseInfo {
return STATIC
}
+102
View File
@@ -0,0 +1,102 @@
// 官网侧的用户 API:微信扫码登录 + 购买(下单/查单)。
//
// 与运维后台的 api.ts 分开:普通用户在官网登录拿到的是「用户 JWT」,
// 存独立 keysdx_site_token),不与管理员令牌(sdx_admin_token)混用。
// 主产品(跑 Agent/知识库/报告)在桌面端,官网只做「登录 + 购买」。
import { GATEWAY } from "../../api";
const SITE_TOKEN = "sdx_site_token";
export function siteToken(): string {
try {
return localStorage.getItem(SITE_TOKEN) ?? "";
} catch {
return "";
}
}
function setSiteToken(t: string) {
try {
localStorage.setItem(SITE_TOKEN, t);
} catch {
/* ignore */
}
}
export function clearSiteToken() {
try {
localStorage.removeItem(SITE_TOKEN);
} catch {
/* ignore */
}
}
function authHeaders(json = false): Record<string, string> {
const t = siteToken();
const h: Record<string, string> = t ? { Authorization: `Bearer ${t}` } : {};
if (json) h["Content-Type"] = "application/json";
return h;
}
export interface SiteUser {
id: string;
name?: string;
}
// me:有 token 时确认登录态;无效则清掉。
export async function siteMe(): Promise<SiteUser | null> {
if (!siteToken()) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() });
if (!res.ok) {
clearSiteToken();
return null;
}
return ((await res.json()) as { user?: SiteUser }).user ?? null;
}
// ---- 微信扫码登录 ----
export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> {
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "创建登录二维码失败");
return res.json();
}
// 轮询:pending | authorized(带 token+user) | expired | consumed。
export async function wxPoll(ticket: string): Promise<{ status: string; user?: SiteUser }> {
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`);
if (!res.ok) return { status: "expired" };
const d = (await res.json()) as { status?: string; token?: string; user?: SiteUser };
if (d.token && d.user) {
setSiteToken(d.token);
return { status: "authorized", user: d.user };
}
return { status: d.status ?? "pending" };
}
// ---- 购买(复用支付链路:下单 → 微信 code_url 二维码 → 轮询单态)----
export interface PayOrder {
order_id: string;
code_url: string;
amount_fen: number;
expires_at: string;
}
// target 二选一:买订阅传 planId,买积分包传 packId。
export async function createOrder(target: { planId: string } | { packId: string }): Promise<PayOrder> {
const body = "planId" in target ? { plan_id: target.planId } : { pack_id: target.packId };
const res = await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify(body),
});
const d = (await res.json().catch(() => ({}))) as Partial<PayOrder> & { error?: string };
if (!res.ok) throw new Error(d.error ?? "下单失败");
return d as PayOrder;
}
// warn:已付但金额不符(服务端挂起人工核对,订单停在 pending)。
export async function orderStatus(orderId: string): Promise<{ status: string; warn?: string }> {
const res = await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() });
if (!res.ok) return { status: "unknown" };
const d = (await res.json()) as { order?: { status?: string }; warn?: string };
return { status: d.order?.status ?? "pending", warn: d.warn };
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,23 @@
import { SectionLabel } from '@/components/section-label'
import { DownloadGrid } from '@/components/download-grid'
import { usePageTitle } from '@/hooks/use-page-title'
export default function DownloadPage() {
usePageTitle('下载')
return (
<section className="px-6 py-16 lg:px-10">
<div className="mx-auto max-w-6xl">
<div className="mx-auto mb-9 max-w-[560px] text-center">
<SectionLabel>DOWNLOAD</SectionLabel>
<h1 className="text-balance text-[28px] font-[650] tracking-[-0.02em]">
sundynix agentix
</h1>
<p className="mt-2.5 text-[15px] text-ink-2">
macOS Windows docker compose
</p>
</div>
<DownloadGrid />
</div>
</section>
)
}
@@ -0,0 +1,54 @@
import { Fragment } from 'react'
import { ARCH_LAYERS } from '@/content/site'
import { SectionLabel } from '@/components/section-label'
import { Reveal } from '@/components/reveal'
export function Architecture() {
return (
<section
id="architecture"
className="border-t border-hairline px-6 py-16 lg:px-10"
>
<div className="mx-auto max-w-6xl">
<Reveal>
<div className="mx-auto mb-9 max-w-[560px] text-center">
<SectionLabel>ARCHITECTURE</SectionLabel>
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
线
</h2>
<p className="mt-2.5 text-[15px] text-ink-2">
Monolith FirstMorph B
</p>
</div>
</Reveal>
<Reveal delay={120} className="mx-auto max-w-[760px]">
{ARCH_LAYERS.map((layer, i) => (
<Fragment key={layer.id}>
{/* NATS 总线插在 L2 和 L4 之间 */}
{i === 2 && (
<div className="mb-2.5 flex items-center justify-center gap-3.5 rounded-[10px] border border-dashed border-accent bg-accent-soft px-5 py-3 font-mono text-xs tracking-[0.1em] text-accent-ink">
NATS JETSTREAM · sundynix.*
</div>
)}
<div className="mb-2.5 grid grid-cols-1 items-center gap-1 rounded-[10px] border border-hairline bg-surface px-5 py-3.5 sm:grid-cols-[120px_1fr_auto] sm:gap-5">
<span className="font-mono text-[11px] tracking-[0.14em] text-ink-3">
{layer.id}
</span>
<span className="text-sm font-medium">
{layer.name}{' '}
<span className="text-[13px] font-normal text-ink-2">
{layer.detail}
</span>
</span>
<span className="font-mono text-[11.5px] text-accent-ink">
{layer.tech}
</span>
</div>
</Fragment>
))}
</Reveal>
</div>
</section>
)
}
@@ -0,0 +1,26 @@
import { SectionLabel } from '@/components/section-label'
import { DownloadGrid } from '@/components/download-grid'
import { Reveal } from '@/components/reveal'
export function DownloadCta() {
return (
<section className="border-t border-hairline px-6 py-16 lg:px-10">
<div className="mx-auto max-w-6xl">
<Reveal>
<div className="mx-auto mb-9 max-w-[560px] text-center">
<SectionLabel>DOWNLOAD</SectionLabel>
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
AI
</h2>
<p className="mt-2.5 text-[15px] text-ink-2">
线
</p>
</div>
</Reveal>
<Reveal delay={120}>
<DownloadGrid />
</Reveal>
</div>
</section>
)
}
@@ -0,0 +1,40 @@
import { FEATURES } from '@/content/site'
import { SectionLabel } from '@/components/section-label'
import { Reveal } from '@/components/reveal'
export function Features() {
return (
<section id="features" className="border-t border-hairline px-6 py-16 lg:px-10">
<div className="mx-auto max-w-6xl">
<Reveal>
<div className="mx-auto mb-9 max-w-[560px] text-center">
<SectionLabel>CAPABILITIES</SectionLabel>
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
AI
</h2>
<p className="mt-2.5 text-[15px] text-ink-2">
</p>
</div>
</Reveal>
<Reveal delay={120}>
<div className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-hairline bg-hairline sm:grid-cols-2 lg:grid-cols-4">
{FEATURES.map((f) => (
<div
key={f.label}
className="group bg-surface p-6 transition-colors hover:bg-ground"
>
<span className="mb-3 block font-mono text-[10.5px] tracking-[0.16em] text-accent-ink transition-colors group-hover:text-accent">
{f.label}
</span>
<h3 className="mb-1.5 text-base font-semibold">{f.title}</h3>
<p className="text-[13.5px] leading-[1.7] text-ink-2">{f.desc}</p>
</div>
))}
</div>
</Reveal>
</div>
</section>
)
}
@@ -0,0 +1,58 @@
import { Link } from 'react-router-dom'
import { Download } from 'lucide-react'
import { SITE } from '@/content/site'
import { TerminalDemo } from '@/components/terminal-demo'
import { Reveal } from '@/components/reveal'
export function Hero() {
return (
<section className="px-6 pt-21 text-center lg:px-10">
<Reveal>
<span className="mb-6.5 inline-flex items-center gap-2 rounded-full border border-hairline-strong px-3.5 py-1 font-mono text-xs tracking-[0.08em] text-accent-ink">
<span className="size-[7px] rounded-full bg-accent" />
{SITE.version} · macOS / Windows
</span>
</Reveal>
<Reveal delay={80}>
<h1 className="mx-auto max-w-[17em] text-balance text-4xl font-[650] leading-[1.18] tracking-[-0.028em] md:text-[52px] lg:text-[58px]">
<br />
<span className="text-brand-gradient">AI Agent </span>
<span className="text-ink-3"></span>
</h1>
</Reveal>
<Reveal delay={160}>
<p className="mx-auto mt-5.5 max-w-[37em] text-[17px] text-ink-2">
Agent
Word
</p>
</Reveal>
<Reveal delay={240}>
<div className="mt-9 flex flex-wrap justify-center gap-3">
<Link
to="/download"
className="inline-flex items-center gap-2 rounded-lg bg-accent px-6 py-2.5 text-[14.5px] font-medium text-ground transition-opacity hover:opacity-90"
>
<Download className="size-4" />
</Link>
<a
href={SITE.repo}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-lg border border-hairline-strong px-6 py-2.5 font-mono text-[14px] transition-colors hover:border-accent"
>
$ make demo
</a>
</div>
</Reveal>
<Reveal delay={320}>
<TerminalDemo />
</Reveal>
</section>
)
}
@@ -0,0 +1,17 @@
import { Hero } from '@/pages/home/hero'
import { Features } from '@/pages/home/features'
import { Architecture } from '@/pages/home/architecture'
import { Quickstart } from '@/pages/home/quickstart'
import { DownloadCta } from '@/pages/home/download-cta'
export default function HomePage() {
return (
<>
<Hero />
<Features />
<Architecture />
<Quickstart />
<DownloadCta />
</>
)
}
@@ -0,0 +1,56 @@
import { QUICKSTART_STEPS } from '@/content/site'
import { SectionLabel } from '@/components/section-label'
import { Reveal } from '@/components/reveal'
export function Quickstart() {
return (
<section className="border-t border-hairline px-6 py-16 lg:px-10">
<div className="mx-auto max-w-6xl">
<Reveal>
<div className="mb-9 max-w-[560px]">
<SectionLabel>QUICKSTART</SectionLabel>
<h2 className="text-[26px] font-[650] tracking-[-0.02em]">
</h2>
</div>
</Reveal>
<Reveal
delay={120}
className="grid grid-cols-1 items-start gap-6 md:grid-cols-2"
>
<pre className="overflow-x-auto rounded-[10px] bg-term px-5.5 py-5 font-mono text-[13.5px] leading-8 text-term-ink">
<code>
<span className="text-white/35"># NATS</span>
{'\n'}
<span className="text-[#22d3ee]">$</span> git clone
https://git.sundynix.cn/sundynix/sundynix-agentix{'\n'}
<span className="text-[#22d3ee]">$</span> make demo{'\n'}
{'\n'}
<span className="text-white/35"># </span>
{'\n'}
<span className="text-[#22d3ee]">$</span> docker compose up -d
</code>
</pre>
<ul>
{QUICKSTART_STEPS.map((step, i) => (
<li
key={step.title}
className="flex gap-4 border-b border-hairline py-3 text-[14.5px] text-ink-2"
>
<span className="pt-[3px] font-mono text-xs tracking-[0.08em] text-accent">
0{i + 1}
</span>
<span>
<b className="font-semibold text-ink">{step.title}</b>
{step.desc}
</span>
</li>
))}
</ul>
</Reveal>
</div>
</section>
)
}
@@ -0,0 +1,25 @@
import { Link } from 'react-router-dom'
import { usePageTitle } from '@/hooks/use-page-title'
export default function NotFoundPage() {
usePageTitle('页面不存在')
return (
<section className="flex flex-col items-center px-6 py-28 text-center">
<p className="font-mono text-[13px] tracking-[0.18em] text-accent">
404 · NOT FOUND
</p>
<h1 className="mt-4 text-[28px] font-[650] tracking-[-0.02em]">
</h1>
<p className="mt-2 text-[15px] text-ink-2">
</p>
<Link
to="/"
className="mt-8 rounded-lg bg-accent px-6 py-2.5 text-[14.5px] font-medium text-ground transition-opacity hover:opacity-90"
>
</Link>
</section>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react'
import { GATEWAY } from '../../api'
import { siteMe, type SiteUser } from '../lib/site-api'
import { WechatLoginDialog } from '../components/wechat-login-dialog'
import { PayDialog } from '../components/pay-dialog'
// 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**——
// 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。
//
// 公开可见(/api/v1/pricing 不挂鉴权):让人在登录前就看得到价格,
// 这是购买转化的前提,也是官网存在的意义。
const MICRO = 1_000_000
const credits = (m: number) => (m / MICRO).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
const yuan = (fen: number) => (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: fen % 100 === 0 ? 0 : 2 })
interface SubPlan {
id: string
name: string
price_fen: number
duration_days: number
refill_credits_micro: number
refill_interval_days: number
}
interface Pack {
id: string
name: string
credits_micro: number
price_fen: number
}
// 一个待购项:点「购买」后,未登录先扫码登录,再弹微信支付。全程在官网完成。
type BuyItem = { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number }
export default function PricingPage() {
const [plans, setPlans] = useState<SubPlan[]>([])
const [packs, setPacks] = useState<Pack[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
const [user, setUser] = useState<SiteUser | null>(null)
const [buying, setBuying] = useState<BuyItem | null>(null) // 已登录,正在支付
const [pendingBuy, setPendingBuy] = useState<BuyItem | null>(null) // 待登录后继续购买
useEffect(() => {
void siteMe().then(setUser) // 有历史登录态则免扫码
}, [])
// 点购买:未登录先记下意图并弹登录,登录后自动继续;已登录直接支付。
const onBuy = (item: BuyItem) => {
if (user) setBuying(item)
else setPendingBuy(item)
}
useEffect(() => {
// GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
fetch(`${GATEWAY}/api/v1/pricing`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d: { plans?: SubPlan[]; packs?: Pack[] }) => {
setPlans(d.plans ?? [])
setPacks(d.packs ?? [])
setState('ready')
})
.catch(() => setState('error'))
}, [])
return (
<main className="mx-auto max-w-6xl px-6 py-16 md:py-24">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">PRICING</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-ink md:text-4xl"></h1>
<p className="mt-4 text-sm leading-relaxed text-ink-2">
</p>
</header>
{state === 'loading' && <p className="mt-12 text-sm text-ink-3"></p>}
{state === 'error' && (
<p className="mt-12 text-sm text-ink-3">
</p>
)}
{state === 'ready' && (
<>
{plans.length > 0 && (
<section className="mt-12">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2">
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((p) => {
const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0
return (
<article
key={p.id}
className="flex flex-col rounded-2xl border border-hairline bg-surface p-6 transition-colors hover:border-hairline-strong"
>
<h3 className="text-base font-semibold text-ink">{p.name}</h3>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-3xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</span>
<span className="text-sm text-ink-3">/ {p.duration_days} </span>
</div>
<ul className="mt-5 space-y-2 text-sm text-ink-2">
<li>
{p.refill_interval_days} {' '}
<b className="text-ink">{credits(p.refill_credits_micro)}</b>
</li>
<li>
{times} {' '}
<b className="text-ink">{credits(p.refill_credits_micro * times)}</b>
</li>
<li className="text-ink-3"></li>
</ul>
<button
onClick={() => onBuy({ kind: 'sub', id: p.id, name: p.name, priceFen: p.price_fen })}
className="mt-6 inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
>
</button>
</article>
)
})}
</div>
</section>
)}
{packs.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2"></p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{packs.map((p) => (
<article key={p.id} className="rounded-2xl border border-hairline bg-surface p-5">
<h3 className="text-sm font-semibold text-ink">{p.name}</h3>
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</div>
<p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} </p>
<button
onClick={() => onBuy({ kind: 'pack', id: p.id, name: p.name, priceFen: p.price_fen })}
className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4"
>
</button>
</article>
))}
</div>
</section>
)}
{plans.length === 0 && packs.length === 0 && (
<p className="mt-12 text-sm text-ink-3">线</p>
)}
<p className="mt-14 text-xs leading-relaxed text-ink-3">
</p>
</>
)}
{/* 未登录点购买 → 扫码登录 → 登录后自动继续之前那笔购买 */}
{pendingBuy && (
<WechatLoginDialog
onClose={() => setPendingBuy(null)}
onLoggedIn={(u) => {
setUser(u)
const item = pendingBuy
setPendingBuy(null)
setBuying(item)
}}
/>
)}
{buying && <PayDialog item={buying} onClose={() => setBuying(null)} />}
</main>
)
}
+33 -1
View File
@@ -1,6 +1,38 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
darkMode: "class",
theme: {
extend: {
// 官网落地页设计 token(跟随 logo:青→蓝→紫)。值引用 CSS 变量(见 src/index.css 的
// :root/.dark),admin 自身页面用默认 gray/violet 调色板、不碰这些 token,故不冲突。
colors: {
ground: "var(--ground)",
surface: "var(--surface)",
ink: "var(--ink)",
"ink-2": "var(--ink-2)",
"ink-3": "var(--ink-3)",
hairline: "var(--hairline)",
"hairline-strong": "var(--hairline-strong)",
accent: "var(--accent)",
"accent-ink": "var(--accent-ink)",
"accent-soft": "var(--accent-soft)",
code: "var(--code-bg)",
term: "var(--term-bg)",
"term-ink": "var(--term-ink)",
},
fontFamily: {
sans: [
"-apple-system",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans SC",
"sans-serif",
],
mono: ["ui-monospace", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
},
},
},
plugins: [],
};
+3 -1
View File
@@ -10,7 +10,9 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
"strict": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/site/*"] }
},
"include": ["src"]
}
+3
View File
@@ -1,9 +1,12 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
// 官网落地页融进 admin,代码在 src/site/ 下且沿用 @ 别名导入(@/content/site 等)。
resolve: { alias: { "@": path.resolve(__dirname, "./src/site") } },
server: { port: 5174 },
// 单元/组件测试:纯逻辑 + 关键控制面(jsdom 环境)。运行:npm test
test: {
+4 -2
View File
@@ -15,9 +15,11 @@ import (
)
// App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力:
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runnerJARVIS 的手)
// v3 中不再需要注入 ctx——对话框经 application.Get().Dialog 获取。
type App struct{}
type App struct {
runner LocalRunner // 本地执行器(localrunner.go):用户显式开启才连接
}
// Ping 供前端探活 Go 桥是否就绪。
func (a *App) Ping() string { return "sundynix-desktop ok" }
@@ -27,6 +27,8 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>JARVIS 语音助手需要麦克风来听你说话。</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
+2
View File
@@ -22,6 +22,8 @@
<string>0.1.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSMicrophoneUsageDescription</key>
<string>JARVIS 语音助手需要麦克风来听你说话。</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
@@ -3,7 +3,7 @@
/**
* App 经 Wails v3 的 Service 绑定暴露给前端,承载只有桌面端能做的原生能力:
* 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
* 文件读写、系统"另存为"框、用系统默认应用打开、原生通知、本地执行 runnerJARVIS 的手)
* v3 中不再需要注入 ctx——对话框经 application.Get().Dialog 获取。
* @module
*/
@@ -12,6 +12,27 @@
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
/**
* DefaultLocalDirs 返回建议授权的常用目录(桌面/下载/文档),供设置界面一键填入。
*/
export function DefaultLocalDirs(): $CancellablePromise<string> {
return $Call.ByID(2376992125);
}
/**
* LocalExecEnabled 供前端显示开关状态。
*/
export function LocalExecEnabled(): $CancellablePromise<boolean> {
return $Call.ByID(2160533474);
}
/**
* LocalRunnerStatus 返回 "offline" / "connecting" / "online:<workdir>"(前端状态显示)。
*/
export function LocalRunnerStatus(): $CancellablePromise<string> {
return $Call.ByID(1389428436);
}
/**
* Notify 弹一条系统通知(best-effortmacOS 用 osascript,其它平台暂静默)。
*/
@@ -42,3 +63,26 @@ export function PrintReportPage(filename: string, html: string): $CancellablePro
export function SaveReportAs(url: string, filename: string): $CancellablePromise<string> {
return $Call.ByID(1437856486, url, filename);
}
/**
* SetLocalExecEnabled 单独开/关"允许 JARVIS 执行命令与写文件"(前端设置项调用)。
* 关闭时同时清掉会话批准记忆——关了就是彻底关,不留后门。
*/
export function SetLocalExecEnabled(on: boolean): $CancellablePromise<void> {
return $Call.ByID(1135777740, on);
}
/**
* StartLocalRunner 开启本地访问:dirs 是换行分隔的授权目录列表(沙箱白名单)。
* 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。
*/
export function StartLocalRunner(gatewayURL: string, token: string, dirs: string): $CancellablePromise<void> {
return $Call.ByID(2808780368, gatewayURL, token, dirs);
}
/**
* StopLocalRunner 关闭本地文件访问(幂等)。
*/
export function StopLocalRunner(): $CancellablePromise<void> {
return $Call.ByID(476635130);
}
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:dev": "vite build --mode development",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
+29 -1
View File
@@ -5,6 +5,7 @@ import { TopBar } from "./shell/TopBar";
import { LeftNav, type ViewKey } from "./shell/LeftNav";
import { ApprovalBar } from "./shell/ApprovalBar";
import { SpaceMembers } from "./shell/SpaceMembers";
import { InviteMembers } from "./shell/InviteMembers";
import { StudioView } from "./studio/StudioView";
import { MemoryView } from "./views/MemoryView";
import { KbView } from "./views/KbView";
@@ -15,6 +16,7 @@ import { Home } from "./views/Home";
import { CommandPalette, type Command } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner";
import { Login } from "./views/Login";
import { VoiceDock } from "./shell/VoiceDock";
import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run";
@@ -46,6 +48,7 @@ export default function App() {
const [run, setRun] = useState<RunState>(emptyRun);
const [cmdOpen, setCmdOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
@@ -281,6 +284,22 @@ export default function App() {
[identity, attachRun],
);
// 语音任务:后端已在 ASR 转写后自行提交(走 SubmitTask 同一关卡),前端只需挂回它的运行流。
// 与 onRun 的区别:不再 submit,直接 attach(task 已在后端跑)——跳「运行·观测」看轨迹/输出。
const onVoiceTask = useCallback(
(taskId: string) => {
closeRef.current?.();
execCloseRef.current?.();
stopPoll();
const t0 = Date.now();
setRun({ phase: "streaming", taskId, output: "", events: [{ t: 0, label: `语音任务 ${taskId}` }], exec: [] });
setFocusRun(null);
setView("runs");
attachRun(taskId, t0, "语音任务已跑完");
},
[attachRun],
);
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
const restoredRef = useRef(false);
@@ -326,7 +345,7 @@ export default function App() {
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
/>
<UpdateBanner />
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
<ApprovalBar run={run} />
<div className="relative flex min-h-0 flex-1">
<LeftNav active={view} onSelect={setView} />
@@ -357,6 +376,15 @@ export default function App() {
canManage={space?.role === "owner" || space?.role === "admin"}
selfUserId={user?.id ?? ""}
/>
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
{/* JARVIS 界面动作(P2):服务端白名单过后,这里再按已知 ViewKey 核一次才执行(双保险)。 */}
<VoiceDock
onTask={onVoiceTask}
onNavigate={(view, taskId) => {
const allowed: ViewKey[] = ["home", "studio", "kb", "report", "runs", "memory", "usage"];
if (allowed.includes(view as ViewKey)) goto(view as ViewKey, taskId);
}}
/>
</div>
</ToastProvider>
);
+151 -9
View File
@@ -1,9 +1,42 @@
// Gateway HTTP/SSE 客户端:提交 DSL 任务、订阅 Token 流、登记偏好记忆。
import type { TaskDsl } from "./dsl";
// 开发期直连 Gateway;Wails 打包后可改为本地后端地址或经 Go 绑定
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
// 服务器地址:**运行时可配**(登录页可填),优先级 localStorage > 构建期 VITE_GATEWAY > 本地默认
// 做成运行时而非只在构建期写死,是因为同一个 App 要能在「本地全栈」和「线上服务器」之间随时切,
// 每换一次环境就重编一次客户端不现实。改完需重载窗口(模块级常量,40+ 处调用点靠它读同一份值)。
const GATEWAY_KEY = "sdx_gateway";
function initialGateway(): string {
try {
const saved = localStorage.getItem(GATEWAY_KEY);
if (saved) return saved.replace(/\/+$/, ""); // 去掉尾斜杠,否则拼出 //api/v1 这种路径
} catch {
/* 隐私模式忽略 */
}
return (import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
}
export const GATEWAY: string = initialGateway();
// setGatewayURL 保存服务器地址(空串=清除,回落构建期默认)。调用方保存后应重载窗口使其生效。
export function setGatewayURL(url: string): void {
try {
const v = url.trim().replace(/\/+$/, "");
if (v) localStorage.setItem(GATEWAY_KEY, v);
else localStorage.removeItem(GATEWAY_KEY);
} catch {
/* ignore */
}
}
// customGatewayURL 返回用户自定义的地址(没设过则空串),供设置界面回显。
export function customGatewayURL(): string {
try {
return localStorage.getItem(GATEWAY_KEY) ?? "";
} catch {
return "";
}
}
export interface Identity {
userId: string;
@@ -150,6 +183,31 @@ export interface MyTenant {
members: number;
}
// ---- 订阅(只读)----
// 桌面端**不做**购买流程:购买入口在 Web 面(一个功能一个入口)。这里只读状态,
// 因为"还剩几天到期"必须在用户干活的地方看得见——到期即失效且没有任何续费通知。
export interface MySubscription {
id: string;
plan_id: string;
status: string;
expires_at: string;
refill_seq: number;
}
export interface MySubPlan {
id: string;
name: string;
refill_credits_micro: number;
refill_interval_days: number;
}
export async function mySubscription(): Promise<{ subscription: MySubscription | null; plan: MySubPlan | null }> {
const res = await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() });
if (!res.ok) return { subscription: null, plan: null };
const d = (await res.json()) as { subscription?: MySubscription | null; plan?: MySubPlan | null };
return { subscription: d.subscription ?? null, plan: d.plan ?? null };
}
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
if (!res.ok) return { tenants: [], active: "" };
@@ -246,6 +304,47 @@ export const setSpaceMemberRole = (spaceId: string, userId: string, role: string
export const removeSpaceMember = (spaceId: string, userId: string) =>
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
// ---- 租户成员「二维码邀请」(可复用团队码;扫码关注即入组)----
export interface TenantInvite {
id: string;
role: string; // member/viewer/admin
qr_image: string; // 微信二维码图 URL
expires_at: string;
max_uses: number; // 0=不限
used_count: number;
status: string;
created_at: string;
}
export async function listInvites(): Promise<TenantInvite[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, { headers: bearer() }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `invites failed: ${res.status}`);
}
const d = (await res.json()) as { invites?: TenantInvite[] };
return d.invites ?? [];
}
export async function createInvite(role: string, expiresDays: number, maxUses: number): Promise<TenantInvite> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ role, expires_days: expiresDays, max_uses: maxUses }),
}));
const d = (await res.json().catch(() => ({}))) as { invite?: TenantInvite; error?: string };
if (!res.ok || !d.invite) throw new Error(d.error ?? `create invite failed: ${res.status}`);
return d.invite;
}
export async function revokeInvite(id: string): Promise<void> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites/${id}`, { method: "DELETE", headers: bearer() }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `revoke failed: ${res.status}`);
}
}
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
export interface UsageDay {
day: string; // YYYYMMDD
@@ -309,15 +408,23 @@ export async function approveTask(taskId: string, approved: boolean, opts?: { no
if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`);
}
// tokenQuery 给 EventSource / 下载链接这类**带不了 Authorization 头**的 URL 附上 JWT
// 后端 AuthFromHeaderOrQuery 从 ?token= 取它做鉴权 + 归属校验(公开 by-id 端点不再裸奔)。
function tokenQuery(url: string): string {
const t = getToken();
if (!t) return url;
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t);
}
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
// 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头
// 返回关闭函数。EventSource 无法带请求头,故 JWT 走 ?token=tokenQuery
export function streamTokens(
taskId: string,
onToken: (t: string) => void,
onDone: () => void,
onError?: (e: unknown) => void,
): () => void {
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/stream`);
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/tasks/${taskId}/stream`));
es.addEventListener("token", (e) => onToken((e as MessageEvent).data));
es.addEventListener("done", () => {
es.close();
@@ -349,7 +456,7 @@ export function streamExec(
onDone: () => void,
onError?: (e: unknown) => void,
): () => void {
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/exec`);
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/tasks/${taskId}/exec`));
es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent));
es.addEventListener("done", () => {
es.close();
@@ -528,7 +635,7 @@ export function streamIngest(
onDone: () => void,
onError?: () => void,
): () => void {
const es = new EventSource(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`);
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`));
es.addEventListener("progress", (e) => onEvent(JSON.parse((e as MessageEvent).data) as IngestEvent));
es.addEventListener("done", () => {
es.close();
@@ -589,7 +696,7 @@ export async function generateReport(id: Identity, topic: string, kb?: string):
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。
export function reportDownloadUrl(taskId: string): string {
return `${GATEWAY}/api/v1/reports/${taskId}/download`;
return tokenQuery(`${GATEWAY}/api/v1/reports/${taskId}/download`);
}
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
@@ -600,7 +707,7 @@ export function reportFilename(topic: string, id: string, ext: string): string {
}
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
return tokenQuery(`${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`);
}
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
@@ -715,3 +822,38 @@ export async function runReplay(taskId: string): Promise<{ output: string; exec:
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
return { output: d.output ?? "", exec: d.exec ?? [] };
}
// ---- 每用户 JARVIS 设置(名字 / 人设 / 自带豆包配置)----
export interface JarvisConfig {
name: string;
persona: string;
asr_resource_id: string;
tts_resource_id: string;
tts_voice_type: string;
api_key: string; // 读取时为脱敏值;保存留空/掩码=沿用已存
has_own_voice: boolean;
}
export async function getMyJarvis(): Promise<JarvisConfig> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/jarvis`, { headers: bearer() }));
if (!res.ok) throw new Error("读取 JARVIS 设置失败");
return res.json() as Promise<JarvisConfig>;
}
export async function saveMyJarvis(cfg: {
name: string;
persona: string;
api_key?: string;
asr_resource_id?: string;
tts_resource_id?: string;
tts_voice_type?: string;
}): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/me/jarvis`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify(cfg),
}),
);
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error || "保存失败");
}
@@ -27,6 +27,44 @@ export function notify(title: string, body: string): void {
if (inWails()) void App.Notify(title, body);
}
// ---- 本地执行 runner(JARVIS「本地的手」,仅桌面壳可用;浏览器一律不可用)----
export function localRunnerAvailable(): boolean {
return inWails();
}
// startLocalRunner:以 dirs(换行分隔的多个目录)为沙箱白名单开启本地访问。
export async function startLocalRunner(gatewayURL: string, token: string, dirs: string): Promise<void> {
if (!inWails()) throw new Error("本地访问仅桌面端可用");
await App.StartLocalRunner(gatewayURL, token, dirs);
}
// defaultLocalDirs:桌面/下载/文档的绝对路径(换行分隔),供一键填入。
export async function defaultLocalDirs(): Promise<string> {
if (!inWails()) return "";
return App.DefaultLocalDirs();
}
export function stopLocalRunner(): void {
if (inWails()) void App.StopLocalRunner();
}
// localRunnerStatus"offline" / "connecting" / "online:<workdir>";浏览器恒 offline。
export async function localRunnerStatus(): Promise<string> {
if (!inWails()) return "offline";
return App.LocalRunnerStatus();
}
// 「允许执行命令与写文件」是独立于只读访问的第二道开关:开了也仍要逐次原生确认框批准。
export function setLocalExecEnabled(on: boolean): void {
if (inWails()) void App.SetLocalExecEnabled(on);
}
export async function localExecEnabled(): Promise<boolean> {
if (!inWails()) return false;
return App.LocalExecEnabled();
}
// printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF"打印→存为 PDF")。
// 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。
// 桌面壳内 WKWebView 会把 window.open 拦成 null(实机验过),改走原生桥:
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { nextAfterDrain, shouldSendMic, type VoiceState } from "./voice";
// 连续对话的纯决策函数:状态门控 / 播完去向 / 点按语义。
// VoiceClient 本体依赖 WebSocket/AudioContextjsdom 难直测),决策逻辑抽纯函数在这测。
describe("shouldSendMic 上行音频门控", () => {
it("只在聆听态上行", () => {
expect(shouldSendMic("listening")).toBe(true);
});
it.each<VoiceState>(["idle", "connecting", "ready", "thinking", "speaking"])(
"%s 态不上行(防回声喂 ASR + 省计费)",
(s) => {
expect(shouldSendMic(s)).toBe(false);
},
);
});
describe("nextAfterDrain 播完去向", () => {
it("对话模式回聆听(自动重听)", () => {
expect(nextAfterDrain(true)).toBe("listening");
});
it("非对话模式回 ready 待命", () => {
expect(nextAfterDrain(false)).toBe("ready");
});
});
+526
View File
@@ -0,0 +1,526 @@
// 语音会话客户端(JARVIS):一条 WebSocket 承载上行麦克风音频 + 下行转写 + 下行 TTS 音频。
// 协议与网关 internal/voice/protocol.go 对齐:
// - 二进制帧:上行=麦克风 PCM 16k;下行=TTS PCM 24k
// - 文本帧(JSON):控制/事件(ClientMsg / ServerMsg
//
// 麦克风采集用 ScriptProcessorNodeWKWebView 通吃,无需单独 worklet 文件),48k→16k 降采样、
// Float32→Int16。下行播放用 AudioBufferSourceNode 排队调度,做无缝连续朗读。
import { GATEWAY, getToken } from "./api";
export type VoiceState = "idle" | "connecting" | "ready" | "listening" | "thinking" | "speaking";
export interface VoiceCallbacks {
onState?: (s: VoiceState) => void;
onTranscript?: (text: string, final: boolean) => void;
onTask?: (taskId: string) => void;
onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token)
onTurnStart?: () => void; // 新一轮开始(含自动重听)——UI 清上一轮转写/回答
onAction?: (action: string, view: string, taskId: string) => void; // JARVIS 界面动作(navigate 等,白名单执行)
onAnnounce?: (text: string) => void; // 主动播报文本(随后照常来 speaking/音频/tts_end
onError?: (msg: string) => void;
}
// ---- 连续对话的纯决策函数(抽出便于单测;VoiceClient/VoiceDock 共用)----
// shouldSendMic:当前状态是否应上行麦克风音频。对话模式麦克风常开,
// 但只在聆听时发——thinking/speaking 不发(防扬声器回声喂给 ASR + 省 ASR 时长计费)。
export function shouldSendMic(state: VoiceState): boolean {
return state === "listening";
}
// nextAfterDrainTTS 排队音频播完后的去向。对话模式回聆听继续多轮,否则回 ready 待命。
export function nextAfterDrain(conversation: boolean): VoiceState {
return conversation ? "listening" : "ready";
}
// 健壮性定时器时长(仅对话模式生效)
const IDLE_EXIT_MS = 30_000; // 聆听空转:30s 无任何转写 → 自动退出对话(防 ASR 长连接白烧计费)
const THINK_WATCHDOG_MS = 90_000; // 思考看门狗:final 后 90s 没等到朗读 → 回聆听继续对话
const UP_SAMPLE_RATE = 16000; // 上行 ASR 采样率(与网关 voice.AudioSampleRate 一致)
const DOWN_SAMPLE_RATE = 24000; // 下行 TTS 采样率(与网关 voice.TTSSampleRate 一致)
function wsURL(): string {
const base = GATEWAY.replace(/^http/, "ws");
const t = getToken();
const q = t ? `?token=${encodeURIComponent(t)}` : "";
return `${base}/api/v1/voice/stream${q}`;
}
// float32 → int16 PCM(小端)。
function floatToPCM16(f32: Float32Array): ArrayBuffer {
const out = new DataView(new ArrayBuffer(f32.length * 2));
for (let i = 0; i < f32.length; i++) {
let s = Math.max(-1, Math.min(1, f32[i]));
out.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return out.buffer;
}
// 线性降采样到 16k(浏览器 AudioContext 常跑 48k,火山 ASR 要 16k)。
function downsampleTo16k(input: Float32Array, inRate: number): Float32Array {
if (inRate === UP_SAMPLE_RATE) return input;
const ratio = inRate / UP_SAMPLE_RATE;
const outLen = Math.floor(input.length / ratio);
const out = new Float32Array(outLen);
for (let i = 0; i < outLen; i++) out[i] = input[Math.floor(i * ratio)];
return out;
}
export class VoiceClient {
private ws: WebSocket | null = null;
private cb: VoiceCallbacks;
private state: VoiceState = "idle";
// 上行采集
private micCtx: AudioContext | null = null;
private micStream: MediaStream | null = null;
private micNode: ScriptProcessorNode | null = null;
private micAnalyser: AnalyserNode | null = null;
// 下行播放
private playCtx: AudioContext | null = null;
private nextStart = 0; // 下一段音频的调度起点(连续朗读用)
private sources: AudioBufferSourceNode[] = [];
private playAnalyser: AnalyserNode | null = null;
private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建)
private drainTimer: number | null = null; // tts_end 后等排队音频放完的定时器
// 对话模式(免按键连续多轮):麦克风常开、VAD 自动断句提交、答完自动重听
private conversation = false;
private idleTimer: number | null = null; // 聆听空转定时器
private idleDeadline = 0; // 空转退出时刻(epoch ms),UI 读它画最后 10s 倒计时环
private thinkTimer: number | null = null; // 思考看门狗定时器
constructor(cb: VoiceCallbacks) {
this.cb = cb;
}
private setState(s: VoiceState) {
this.state = s;
this.cb.onState?.(s);
}
getState(): VoiceState {
return this.state;
}
// connect 建立 WS,等待服务端 ready。
async connect(): Promise<void> {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
this.setState("connecting");
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsURL());
ws.binaryType = "arraybuffer";
this.ws = ws;
ws.onmessage = (e) => this.onMessage(e);
ws.onerror = () => {
this.cb.onError?.("语音连接出错");
reject(new Error("ws error"));
};
ws.onclose = () => {
this.conversation = false; // 连接断了对话就断,别留定时器空转
this.clearIdleTimer();
this.clearThinkTimer();
this.stopMic();
if (this.state !== "idle") this.setState("idle");
};
ws.onopen = () => resolve();
});
}
private onMessage(e: MessageEvent) {
if (typeof e.data === "string") {
let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: string; action?: string; view?: string };
try {
m = JSON.parse(e.data);
} catch {
return;
}
switch (m.type) {
case "ready":
this.setState("ready");
break;
case "transcript":
this.cb.onTranscript?.(m.text ?? "", !!m.final);
if (m.final) {
this.clearIdleTimer();
this.setState("thinking");
this.armThinkTimer(); // 看门狗:后端不回/任务失败/无 TTS 时别永远卡在"思考中"
} else if (this.conversation && this.state === "listening") {
this.armIdleTimer(); // 有声音活动 → 空转计时重来
}
break;
case "task":
if (m.task_id) this.cb.onTask?.(m.task_id);
break;
case "reply":
if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本
break;
case "speaking":
this.clearThinkTimer();
this.setState("speaking");
break;
case "tts_end":
// 别 resetPlayback!服务端"音频发完" ≠ 客户端"播完":音频按 nextStart 预约到未来时刻播,
// 而火山合成远快于真实语速,收到 tts_end 时大半音频还排在队列里没播。stop 掉就只剩前几个字。
// 让排队音频自然放完,最后一段结束再回 ready/聆听。
this.clearThinkTimer();
if (this.state === "speaking") {
// 对话模式:此刻就补发 start——服务端重建 ASR 的握手(几百毫秒)与排队音频播放重叠,
// 音频一播完立刻能听;上行音频门控在 shouldSendMic,回聆听态前不会发。
if (this.conversation) this.send({ type: "start" });
this.drainThenReady();
} else if (this.state === "thinking") {
// 没进朗读就结束(TTS 启动失败/无音频):别干等看门狗,立刻进下一轮/回待命。
if (this.conversation) {
this.send({ type: "start" });
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
} else {
this.setState("ready");
}
}
// 其余态(已打断回聆听等):忽略——这是上一轮 speak 收尾的迟到 tts_end
// 再发 start 会把用户已开口的新一轮重置掉。
break;
case "action":
// JARVIS 界面动作(P2 动作通道):交给 UI 层白名单执行,客户端不盲信。
this.cb.onAction?.(m.action ?? "", m.view ?? "", m.task_id ?? "");
break;
case "announce":
// 主动播报(P3):文本进对话流;音频随后照常走 speaking/二进制帧/tts_end,
// 现有状态机零新分支(speaking 会关上行门控,播完 drain 回聆听)。
if (m.text) this.cb.onAnnounce?.(m.text);
break;
case "error":
this.cb.onError?.(m.msg ?? "语音出错");
break;
}
return;
}
// 二进制帧 = 下行 TTS 音频(PCM 24k int16
this.enqueueAudio(e.data as ArrayBuffer);
}
// ---- 上行:麦克风 ----
// startListening 开一轮:连接(若需)→ 若正在朗读先打断 → 采麦克风 → 发 start。
async startListening(graph?: string): Promise<void> {
await this.connect();
// 关键:在**用户手势**(点麦克风)里就把播放 AudioContext 建好并 resume——否则它在 WS 回调里
// 惰性创建会处于 suspended 态,TTS 音频静默播不出(Chrome/WKWebView 的自动播放策略)。
this.ensurePlayCtx();
if (this.state === "speaking") this.bargeIn();
this.send({ type: "start", graph });
await this.startMic();
this.setState("listening");
}
// ensurePlayCtx 建/复用下行播放 AudioContext,并在 suspended 时 resume(自动播放策略要求手势内唤醒)。
private ensurePlayCtx(): AudioContext {
if (!this.playCtx) this.playCtx = new AudioContext();
if (!this.playAnalyser) {
// 下行电平探针:所有 TTS 音频经它再到扬声器(驱动 HUD 的"说话"频谱)。
const an = this.playCtx.createAnalyser();
an.fftSize = 512;
an.connect(this.playCtx.destination);
this.playAnalyser = an;
}
if (this.playCtx.state === "suspended") void this.playCtx.resume();
return this.playCtx;
}
// level 返回当前"活跃"音频电平 0..1:说话读下行 TTS,其余读上行麦克风;无探针则 0。
// 供 JARVIS HUD 每帧读取,让可视化随真实声音起伏。
level(): number {
const a = this.state === "speaking" ? this.playAnalyser : this.micAnalyser;
if (!a) return 0;
a.getByteTimeDomainData(this.lvlBuf);
let s = 0;
for (let i = 0; i < this.lvlBuf.length; i++) {
const v = (this.lvlBuf[i] - 128) / 128;
s += v * v;
}
return Math.min(1, Math.sqrt(s / this.lvlBuf.length) * 3.2);
}
// stopListening 结束本轮说话:停麦克风 + 发 end(服务端拿最终转写→提交任务)。
stopListening(): void {
this.stopMic();
this.send({ type: "end" });
if (this.state === "listening") this.setState("thinking");
}
// stopPTT 结束本轮说话但保留麦克风上下文:用于按住空格(PTT)松开发送场景。
// 不销毁 micNode/micStream,下次重按 startListening 时 startMic 直接早返回,
// 避免 getUserMedia / AudioContext 重建延迟,实现快速重按。
stopPTT(): void {
this.send({ type: "end" });
if (this.state === "listening") this.setState("thinking");
}
// bargeIn 打断当前朗读(用户又开口)。
bargeIn(): void {
this.send({ type: "barge_in" });
this.resetPlayback();
}
// ---- 对话模式:免按键连续多轮 ----
inConversation(): boolean {
return this.conversation;
}
// startConversation 进入连续对话:麦克风常开,之后 VAD 自动断句提交、答完自动重听,
// 直到 stopConversation(点按退出 / 空转超时)。
async startConversation(graph?: string): Promise<void> {
await this.connect();
this.ensurePlayCtx(); // 用户手势内建好播放上下文(自动播放策略)
if (this.state === "speaking") this.bargeIn();
this.send({ type: "start", graph });
await this.startMic(); // 失败(无麦/无权限)直接抛给 UI,不进对话模式
this.conversation = true;
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
}
// stopConversation 退出连续对话:关麦、清定时器、回待命。幂等。
stopConversation(): void {
this.conversation = false;
this.clearIdleTimer();
this.clearThinkTimer();
this.stopMic();
if (this.state !== "idle") this.setState("ready");
}
// interruptAndListen 对话模式里打断朗读并立即回聆听(点按打断,留在对话)。
interruptAndListen(): void {
this.bargeIn(); // 服务端掐 TTS + 本地清排队音频/drain 定时器
this.clearThinkTimer();
this.send({ type: "start" }); // 服务端重置轮 + 重建 ASR
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
}
// ---- 健壮性定时器(仅对话模式)----
private armIdleTimer(): void {
this.clearIdleTimer();
this.idleDeadline = Date.now() + IDLE_EXIT_MS;
this.idleTimer = window.setTimeout(() => {
this.idleTimer = null;
this.idleDeadline = 0;
if (this.conversation && this.state === "listening") {
this.stopConversation();
this.cb.onError?.("长时间没听到声音,已退出连续对话");
}
}, IDLE_EXIT_MS);
}
private clearIdleTimer(): void {
this.idleDeadline = 0;
if (this.idleTimer !== null) {
window.clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
// idleRemainingMs 距空转自动退出还剩多少毫秒;不在「对话中聆听」则返回 null。
// UI 用它画最后 10s 的倒计时环(开口即 armIdleTimer 重置,环自然消失)。
idleRemainingMs(): number | null {
if (!this.conversation || this.state !== "listening" || this.idleDeadline === 0) return null;
return Math.max(0, this.idleDeadline - Date.now());
}
private armThinkTimer(): void {
this.clearThinkTimer();
this.thinkTimer = window.setTimeout(() => {
this.thinkTimer = null;
if (this.state !== "thinking") return;
if (this.conversation) {
this.cb.onError?.("等回答超时,继续聆听");
this.send({ type: "start" });
this.cb.onTurnStart?.();
this.setState("listening");
this.armIdleTimer();
} else {
// PTT:回待命并说清可能原因,别让用户对着"思考中"干等。
this.cb.onError?.("等了很久没有回答——可能后端任务没跑起来(dispatcher/模型未就绪)。去运行页看看这条任务的状态。");
this.setState("ready");
}
}, THINK_WATCHDOG_MS);
}
private clearThinkTimer(): void {
if (this.thinkTimer !== null) {
window.clearTimeout(this.thinkTimer);
this.thinkTimer = null;
}
}
private async startMic(): Promise<void> {
if (this.micNode) return;
// WKWebViewWails 原生壳)等非安全上下文里 navigator.mediaDevices 可能未暴露——
// 直接调用会抛 "undefined is not an object" 崩掉。先探测,给出可读提示而非崩溃。
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("此窗口暂不支持麦克风(原生壳需授权/安全上下文)。用浏览器打开 localhost:5173 可直接说话。");
}
// 先看有没有麦克风设备:Mac Studio/Mini 等无内置麦克风的机器上,getUserMedia 会以
// OverconstrainedError "Invalid constraint" 报错(误导),提前给准确提示。
let devs: MediaDeviceInfo[] = [];
try {
devs = await navigator.mediaDevices.enumerateDevices();
} catch {
/* 拿不到设备列表就跳过预检,直接试 getUserMedia */
}
if (devs.length > 0 && !devs.some((d) => d.kind === "audioinput")) {
throw new Error("没检测到麦克风设备。这台机器(如 Mac Studio/Mini)可能无内置麦克风——插个麦克风(带麦耳机 / USB 麦 / AirPods)再试。");
}
// 依次尝试多种约束形式:WKWebView(原生壳)对约束挑剔,不同实现接受的形式不同。
// 全失败则把**真实错误名**抛出来(区分是"约束不认"Overconstrained 还是"权限被拒"NotAllowed)。
const tries: MediaStreamConstraints[] = [
{ audio: true },
{ audio: {} },
{ audio: { echoCancellation: true, noiseSuppression: true } },
];
let stream: MediaStream | null = null;
let lastErr: unknown;
for (const c of tries) {
try {
stream = await navigator.mediaDevices.getUserMedia(c);
break;
} catch (e) {
lastErr = e;
}
}
if (!stream) {
const err = lastErr as { name?: string; message?: string };
const detail = `${err?.name ?? "Error"}: ${err?.message ?? String(lastErr)}`;
if (err?.name === "NotAllowedError" || err?.name === "SecurityError") {
throw new Error("麦克风权限被拒。请到 系统设置 → 隐私与安全性 → 麦克风 里允许本应用。");
}
throw new Error("麦克风打开失败:" + detail);
}
this.micStream = stream;
const ctx = new AudioContext();
this.micCtx = ctx;
if (ctx.state === "suspended") await ctx.resume(); // 防采集上下文挂起(无回调=不上行音频)
const src = ctx.createMediaStreamSource(stream);
const an = ctx.createAnalyser(); // 上行电平探针(驱动 JARVIS HUD 的"聆听"反应)
an.fftSize = 512;
src.connect(an);
this.micAnalyser = an;
const node = ctx.createScriptProcessor(4096, 1, 1);
node.onaudioprocess = (ev) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
// 对话模式麦克风常开,但只在聆听态上行——thinking/speaking 不发(防回声 + 省 ASR 计费)。
if (!shouldSendMic(this.state)) return;
const f32 = ev.inputBuffer.getChannelData(0);
const ds = downsampleTo16k(f32, ctx.sampleRate);
this.ws.send(floatToPCM16(ds));
};
src.connect(node);
node.connect(ctx.destination); // ScriptProcessor 需接上目的地才触发回调(下游静音)
this.micNode = node;
}
private stopMic(): void {
this.micNode?.disconnect();
this.micNode = null;
this.micAnalyser = null;
this.micStream?.getTracks().forEach((t) => t.stop());
this.micStream = null;
this.micCtx?.close().catch(() => {});
this.micCtx = null;
}
// ---- 下行:TTS 音频播放(排队调度,无缝连续)----
private enqueueAudio(buf: ArrayBuffer): void {
if (buf.byteLength === 0) return;
const ctx = this.ensurePlayCtx(); // 建/复用并 resume(防 WS 回调里上下文仍 suspended → 静默)
const i16 = new Int16Array(buf);
const f32 = new Float32Array(i16.length);
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000;
const audioBuf = ctx.createBuffer(1, f32.length, DOWN_SAMPLE_RATE);
audioBuf.getChannelData(0).set(f32);
const node = ctx.createBufferSource();
node.buffer = audioBuf;
node.connect(this.playAnalyser ?? ctx.destination); // 经探针再到扬声器(读得到电平)
const now = ctx.currentTime;
if (this.nextStart < now) this.nextStart = now;
node.start(this.nextStart);
this.nextStart += audioBuf.duration;
node.onended = () => {
this.sources = this.sources.filter((s) => s !== node);
};
this.sources.push(node);
}
private resetPlayback(): void {
if (this.drainTimer !== null) {
window.clearTimeout(this.drainTimer);
this.drainTimer = null;
}
this.sources.forEach((s) => {
try {
s.stop();
} catch {
/* 已停忽略 */
}
});
this.sources = [];
this.nextStart = 0;
}
// drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,再去下一站——
// 对话模式回聆听(自动重听,新一轮),否则回 ready 待命。
// speaking 态要维持到真正播完(HUD 频谱靠它读 playAnalyser);播完把队列清干净。
private drainThenReady(): void {
if (this.drainTimer !== null) window.clearTimeout(this.drainTimer);
const ctx = this.playCtx;
const remainMs = ctx ? Math.max(0, (this.nextStart - ctx.currentTime) * 1000) : 0;
this.drainTimer = window.setTimeout(() => {
this.drainTimer = null;
this.sources = [];
this.nextStart = 0;
if (this.state !== "speaking") return; // 期间被打断/新一轮改了态就不覆盖
const next = nextAfterDrain(this.conversation);
if (next === "listening") {
this.cb.onTurnStart?.(); // 新一轮:UI 清上一轮转写/回答
this.setState("listening"); // start 已在 tts_end 时提前发,ASR 握手与播放重叠
this.armIdleTimer();
} else {
this.setState("ready");
}
}, remainMs + 120); // +120ms 余量,等末尾 source 真正 onended
}
private send(m: { type: string; graph?: string }): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m));
}
// close 彻底关闭会话(发 bye、停采集/播放、断 WS)。
close(): void {
this.conversation = false;
this.clearIdleTimer();
this.clearThinkTimer();
this.send({ type: "bye" });
this.stopMic();
this.resetPlayback();
this.playCtx?.close().catch(() => {});
this.playCtx = null;
this.playAnalyser = null;
this.ws?.close();
this.ws = null;
this.setState("idle");
}
}
+3
View File
@@ -3,11 +3,14 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { applyInitialTheme } from "./lib/theme";
import { ErrorBoundary } from "./ui/ErrorBoundary";
applyInitialTheme(); // 先于渲染设好主题类,避免首屏闪烁
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from "react";
import { QrCode, Trash2, Loader2 } from "lucide-react";
import { Dialog, Button, Select, Badge, useToast } from "../ui";
import { listInvites, createInvite, revokeInvite, type TenantInvite } from "../lib/api";
const ROLE_LABEL: Record<string, string> = { admin: "管理员", member: "成员", viewer: "只读" };
// InviteMembers 租户成员「二维码邀请」弹窗:owner/admin 生成可复用团队码,
// 成员用微信扫码关注即自动入组(后端 WxMPEvent inv_ 分支)。三道闸:有效期 + 人数 + 撤销。
// 作用于当前活跃租户(后端取 ctx 租户,前端不传 id)。
export function InviteMembers({ open, onClose, tenantName }: { open: boolean; onClose: () => void; tenantName: string }) {
const toast = useToast();
const [rows, setRows] = useState<TenantInvite[]>([]);
const [loading, setLoading] = useState(false);
const [role, setRole] = useState("member");
const [days, setDays] = useState(7);
const [maxUses, setMaxUses] = useState(0);
const [busy, setBusy] = useState(false);
const [fresh, setFresh] = useState<TenantInvite | null>(null); // 刚生成的,置顶大图展示
const refresh = useCallback(async () => {
setLoading(true);
try {
setRows(await listInvites());
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
if (open) {
setFresh(null);
void refresh();
}
}, [open, refresh]);
const generate = async () => {
setBusy(true);
try {
const inv = await createInvite(role, days, maxUses);
setFresh(inv);
toast.push("success", "已生成邀请二维码");
await refresh();
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
const revoke = async (inv: TenantInvite) => {
if (!window.confirm("撤销这张邀请码?已加入的成员不受影响,但此码将无法再扫码加入。")) return;
try {
await revokeInvite(inv.id);
if (fresh?.id === inv.id) setFresh(null);
toast.push("success", "已撤销");
await refresh();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const usesLabel = (inv: TenantInvite) => (inv.max_uses > 0 ? `${inv.used_count}/${inv.max_uses}` : `${inv.used_count} 人(不限)`);
const expLabel = (iso: string) => new Date(iso).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 到期";
return (
<Dialog open={open} onClose={onClose} title={`邀请成员 · ${tenantName}`}>
{/* 生成表单 */}
<div className="mb-3 flex items-end gap-2">
<label className="text-[11px] text-slate-500">
<Select className="mt-1 h-8 w-20 text-xs" value={role} onChange={(e) => setRole(e.target.value)}>
<option value="member"></option>
<option value="viewer"></option>
<option value="admin"></option>
</Select>
</label>
<label className="text-[11px] text-slate-500">
<Select className="mt-1 h-8 w-20 text-xs" value={days} onChange={(e) => setDays(Number(e.target.value))}>
<option value={1}>1 </option>
<option value={7}>7 </option>
<option value={30}>30 </option>
</Select>
</label>
<label className="text-[11px] text-slate-500">
<Select className="mt-1 h-8 w-24 text-xs" value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))}>
<option value={0}></option>
<option value={1}>1 </option>
<option value={5}>5 </option>
<option value={20}>20 </option>
</Select>
</label>
<Button variant="primary" size="sm" icon={busy ? Loader2 : QrCode} onClick={generate} disabled={busy}>
</Button>
</div>
{/* 刚生成的码:大图展示,便于当场扫 */}
{fresh && (
<div className="mb-3 flex flex-col items-center rounded-lg border border-brand/40 bg-ink-800/60 p-3">
{fresh.qr_image ? (
<img src={fresh.qr_image} alt="邀请二维码" className="h-44 w-44 rounded bg-white p-1" />
) : (
<div className="flex h-44 w-44 items-center justify-center text-xs text-slate-500"></div>
)}
<p className="mt-2 text-center text-[11px] text-slate-400">
<b className="text-slate-200"></b>
<br />
{ROLE_LABEL[fresh.role] ?? fresh.role} · {usesLabel(fresh)} · {expLabel(fresh.expires_at)}
</p>
</div>
)}
{/* 有效邀请码列表 */}
<div className="mb-1 text-[11px] text-slate-500"></div>
<ul className="max-h-56 space-y-1 overflow-auto">
{loading && <li className="px-1 py-2 text-[11px] text-slate-500"></li>}
{!loading && rows.length === 0 && <li className="px-1 py-2 text-[11px] text-slate-600"></li>}
{rows.map((inv) => (
<li key={inv.id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
<button onClick={() => setFresh(inv)} title="查看二维码" className="shrink-0 text-slate-500 transition hover:text-brand-400">
<QrCode className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-xs text-slate-200">
<Badge tone={inv.role === "admin" ? "accent" : inv.role === "viewer" ? "warn" : "neutral"}>{ROLE_LABEL[inv.role] ?? inv.role}</Badge>
<span className="text-[11px] text-slate-400">{usesLabel(inv)}</span>
</div>
<div className="truncate text-[10px] text-slate-500">{expLabel(inv.expires_at)}</div>
</div>
<button onClick={() => revoke(inv)} className="text-slate-600 transition hover:text-danger" title="撤销">
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More