feat: 全链路优化 — 死锁修复、MD3主题、上下文持久化、群聊自然化、打字状态、知识库

**死锁根因修复**
- periodicThinkLoop:1015 orphaned lock → 删除(字段已原子化)
- RecordUserMessage 隔离为 recordMu
- atomic.Int64 替换 lastUserMessage/lastThinkTime 等

**MD3 / Android 17 主题**
- 毛玻璃卡片 (backdrop-filter)
- MD3 色彩令牌 (pink primary #f472b6)
- icons.js 独立矢量图标库 + 运行时 emoji 替换
- 无边框卡片、圆角按钮、阴影层次

**上下文持久化**
- AddMessage → saveToDB 异步写 PostgreSQL
- LoadFromDB 恢复 (admin-session-main + 懒加载)
- LLMMessage.Timestamp 字段

**群聊与适配器**
- group_ambient 模式: 非@消息让 LLM 自己判断是否插话
- 戳一戳动作消息总是回复
- NapCat 打字状态 (set_input_status, 最小3秒显示)
- HTTP API 配置 (http_url/http_token)

**知识库 & 防编造**
- knowledge.CanHandle 对 chat 意图也触发
- 关键词预筛选避免无关 embedding 调用
- persona + synthesizer 三重诚实规则
- 工具结果持久化到会话历史

**平台桥接器**
- detached:true Go进程独立存活
- ethend 重启自动接管已运行服务
- stop() 接管模式 taskkill/F/ PID
- Windows netstat 替代 fuser 获取 PID
- 重复适配器种子逻辑修复
- 失败转发日志 Direction: error

**崩溃诊断**
- crashlog 包 (Recover + WrapHTTP + LLMCall)
- /api/v1/debug/goroutines 端点
- thinker 操作日志 + 30s stats
- 日志写入 logs/ 目录持久化

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-28 11:29:29 +08:00
parent 0d6970a2d3
commit fd44b15d81
23 changed files with 1447 additions and 254 deletions
+146
View File
@@ -0,0 +1,146 @@
// Cyrene ethend — Material Design 3 Vector Icon Library
// Usage: icon('home', 18) → '<svg class="md-icon" .../>'
(function() {
const PATHS = {
// Navigation
home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
menu: 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z',
apps: 'M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z',
monitor: 'M20 3H4c-1.1 0-2 .9-2 2v11c0 1.1.9 2 2 2h3l-1 1v2h12v-2l-1-1h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 13H4V5h16v11z',
bar_chart: 'M5 9.2h3V19H5zM10.6 5h2.8v14h-2.8zm5.6 8H19v6h-2.8z',
database: 'M12 3C7.58 3 4 4.79 4 7v10c0 2.21 3.58 4 8 4s8-1.79 8-4V7c0-2.21-3.58-4-8-4zm0 2c3.87 0 6 1.5 6 2s-2.13 2-6 2-6-1.5-6-2 2.13-2 6-2zM6 12c0 .5 2.13 2 6 2s6-1.5 6-2v2.5c0 .5-2.13 2-6 2s-6-1.5-6-2V12zm0 5c0 .5 2.13 2 6 2s6-1.5 6-2v2.5c0 .5-2.13 2-6 2s-6-1.5-6-2V17z',
link: 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z',
build: 'M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z',
settings: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z',
memory: 'M15 9H9v6h6V9zm-2 4h-2v-2h2v2zm8-2V9h-2V7c0-1.1-.9-2-2-2h-2V3h-2v2h-2V3H9v2H7c-1.1 0-2 .9-2 2v2H3v2h2v2H3v2h2v2c0 1.1.9 2 2 2h2v2h2v-2h2v2h2v-2h2c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2zm-4 6H7V7h10v10z',
psychiatry: 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0013 21a9 9 0 000-18zm-1 5v5h2v-5h-2zm0 7v2h2v-2h-2z',
smart_toy: 'M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3zM7.5 11.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5S9.83 13 9 13s-1.5-.67-1.5-1.5zM16 17H8v-2h8v2zm-1-4c-.83 0-1.5-.67-1.5-1.5S14.17 10 15 10s1.5.67 1.5 1.5S15.83 13 15 13z',
// Status
schedule: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z',
check_circle: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
error_outline: 'M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z',
warning_amber: 'M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-4h-2v4h2v-4z',
hourglass_bottom:'M18 7V4h2V2H4v2h2v3c0 2.09 1.07 3.93 2.69 5A5.98 5.98 0 006 17v3H4v2h16v-2h-2v-3c0-2.09-1.07-3.93-2.69-5A5.98 5.98 0 0018 7z',
circle: 'M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2z',
// Actions
refresh: 'M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
play_arrow: 'M8 5v14l11-7z',
stop: 'M6 6h12v12H6z',
restart_alt: 'M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z',
favorite: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
delete: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z',
search: 'M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z',
add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a.996.996 0 000-1.41l-2.34-2.34a.996.996 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
save: 'M17 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z',
power_settings_new:'M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.938 6.938 0 0119 12c0 3.87-3.13 7-7 7A6.995 6.995 0 017.58 6.58L6.17 5.17A8.959 8.959 0 003 12a9 9 0 0018 0c0-2.74-1.23-5.18-3.17-6.83z',
download: 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z',
upload: 'M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z',
// IoT
ac_unit: 'M22 11h-4.17l3.24-3.24-1.41-1.42L15 11h-2V9l4.66-4.66-1.42-1.41L13 6.17V2h-2v4.17L7.76 2.93 6.34 4.34 11 9v2H9L4.34 6.34 2.93 7.76 6.17 11H2v2h4.17l-3.24 3.24 1.41 1.42L9 13h2v2l-4.66 4.66 1.42 1.41L11 17.83V22h2v-4.17l3.24 3.24 1.42-1.41L13 15v-2h2l4.66 4.66 1.41-1.42L17.83 13H22v-2z',
lightbulb: 'M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z',
lock: 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z',
sensors: 'M7.76 16.24C6.67 15.16 6 13.66 6 12s.67-3.16 1.76-4.24l1.42 1.42C8.45 9.9 8 10.9 8 12c0 1.1.45 2.1 1.17 2.83l-1.41 1.41zm8.48 0C17.33 15.16 18 13.66 18 12s-.67-3.16-1.76-4.24l-1.42 1.42C15.55 9.9 16 10.9 16 12c0 1.1-.45 2.1-1.17 2.83l1.41 1.41zM12 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8 2c0 2.21-.9 4.21-2.35 5.65l1.42 1.42C20.88 17.26 22 14.76 22 12s-1.12-5.26-2.93-7.07l-1.42 1.42A7.94 7.94 0 0120 12zM6.35 6.35L4.93 4.93C3.12 6.74 2 9.24 2 12s1.12 5.26 2.93 7.07l1.42-1.42A7.94 7.94 0 014 12c0-2.21.9-4.21 2.35-5.65z',
thermostat: 'M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-4-8c0-.55.45-1 1-1s1 .45 1 1h-1v1h1v2h-1v1h1v2h-2V5z',
// Communication
person: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
chat: 'M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z',
mic: 'M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15a.998.998 0 00-.98-.85c-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08a6.993 6.993 0 005.91-5.78c.1-.6-.39-1.14-1-1.14z',
devices_other: 'M3 6h18V4H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4v-2H3V6zm10 6H9v1.78c-.61.55-1 1.33-1 2.22 0 .89.39 1.67 1 2.22V20h4v-1.78c.61-.55 1-1.34 1-2.22s-.39-1.67-1-2.22V12zm-2 5.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM22 8h-6c-.5 0-1 .5-1 1v10c0 .5.5 1 1 1h6c.5 0 1-.5 1-1V9c0-.5-.5-1-1-1zm-1 10h-4v-8h4v8z',
extension: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5a2.5 2.5 0 00-5 0V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5a2.5 2.5 0 000-5z',
deployed_code: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM9.5 16.5v-9l7 4.5-7 4.5z',
// Misc
star: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
push_pin: 'M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z',
list_alt: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 8h10v2H7V8zm0 4h10v2H7v-2zm0 4h7v2H7v-2z',
inventory_2: 'M20 2H4c-1 0-2 .9-2 2v3.01c0 .72.43 1.34 1 1.69V20c0 1.1 1.1 2 2 2h14c.9 0 2-.9 2-2V8.7c.57-.35 1-.97 1-1.69V4c0-1.1-1-2-2-2zm-1 18H5V9h14v11zm1-13H4V4h16v3z',
task_alt: 'M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z',
edit_note: 'M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm14-1v-2h-2v2h-2v2h2v2h2v-2h2v-2h-2z',
mail_outline: 'M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8l8 5 8-5v10zm-8-7L4 6h16l-8 5z',
bolt: 'M14 2H6L2 14h8l-2 8 12-12h-8l4-8z',
calendar_month: 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20a2 2 0 002 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm-8 4H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z',
menu_book: 'M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1zm0 13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5V8c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5v11.5z',
trending_flat: 'M22 12l-4-4v3H3v2h15v3z',
trending_up: 'M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z',
trending_down: 'M16 18l2.29-2.29-4.88-4.88-4 4L2 7.41 3.41 6l6 6 4-4 6.3 6.29L22 12v6z',
diamond: 'M19 3H5L2 9l10 12L22 9l-3-6zM9.62 8L12 5.67 14.38 8H9.62z',
timer: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z',
volume_up: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z',
};
window.icon = function(name, size) {
var path = PATHS[name];
if (!path) return '<!--icon:'+name+'-->';
var s = size || 20;
return '<svg class="md-icon" width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="currentColor"><path d="'+path+'"/></svg>';
};
})();
// Runtime emoji→icon replacement on page load
document.addEventListener('DOMContentLoaded', function() {
const MAP = {
'🏠': 'home', '🧠': 'memory', '💬': 'chat', '💭': 'psychiatry',
'⏱️': 'schedule', '⏱': 'schedule', '🖥': 'monitor', '📊': 'bar_chart',
'🗄️': 'database', '🔗': 'link', '🔧': 'build', '🎤': 'mic',
'🔌': 'extension', '📱': 'devices_other', '🤖': 'smart_toy',
'📡': 'sensors', '⚡': 'bolt', '💻': 'monitor', '🛠️': 'settings',
'▶': 'play_arrow', '⏹': 'stop', '🔄': 'refresh', '🔁': 'restart_alt',
'🔍': 'search', '🗑': 'delete', '✏': 'edit', '💾': 'save', '': 'add', '❌': 'close',
'✅': 'check_circle', '⚠️': 'warning_amber', '⚠': 'warning_amber', '📭': 'mail_outline', '⏳': 'hourglass_bottom',
'💡': 'lightbulb', '🎯': 'task_alt', '📥': 'download', '📤': 'upload', '👤': 'person',
'📝': 'edit_note', '📋': 'list_alt', '📄': 'menu_book', '⏰': 'schedule', '📅': 'calendar_month',
'🔔': 'notifications', '⭐': 'star', '❤️': 'favorite', '❤': 'favorite', '🎵': 'volume_up',
'🌡️': 'thermostat', '💧': 'ac_unit', '🔒': 'lock', '🔓': 'lock', '📌': 'push_pin', '🐳': 'deployed_code',
'📚': 'menu_book', '🔴': 'circle', '🔀': 'refresh', '⚙': 'settings', '📁': 'inventory_2',
'📦': 'inventory_2', '🔨': 'build', '💜': 'favorite', '✕': 'close',
'🏷': 'list_alt', '🤔': 'psychiatry', '○': 'circle', '💕': 'favorite',
'★': 'star', '☆': 'star', '✓': 'check_circle', '✗': 'close', '🎙': 'mic',
'🕐': 'schedule', '↓': 'trending_down', '↑': 'trending_up', '→': 'trending_flat',
'📭': 'mail_outline'
};
function replaceIn(el) {
if (!el) return;
// Text nodes
for (var i = 0; i < (el.childNodes || []).length; i++) {
var node = el.childNodes[i];
if (node.nodeType === 3 && node.textContent) {
var text = node.textContent;
var changed = false;
for (var emoji in MAP) {
if (text.indexOf(emoji) >= 0) {
text = text.split(emoji).join('');
changed = true;
// Insert icon before the text node
var span = document.createElement('span');
span.innerHTML = icon(MAP[emoji], 16);
span.style.cssText = 'vertical-align:middle;display:inline-flex;align-items:center';
node.parentNode.insertBefore(span, node);
}
}
if (changed && text.trim()) {
node.textContent = text;
} else if (changed) {
node.textContent = '';
}
} else if (node.nodeType === 1) {
// Skip script/style/icon elements
if (!/SCRIPT|STYLE|SVG|PATH/i.test(node.tagName)) {
replaceIn(node);
}
}
}
}
// Debounce for dynamic content
var timer;
var observer = new MutationObserver(function() {
clearTimeout(timer);
timer = setTimeout(function() { replaceIn(document.body); }, 200);
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
// Initial run
replaceIn(document.body);
});
+25 -15
View File
@@ -5,32 +5,37 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cyrene ethend</title>
<style>
/* ========== CSS Variables (深色主题) ========== */
/* ========== Android 17 / Material You Design Tokens ========== */
:root {
--bg: #0f1117; --bg2: #1a1d27; --bg3: #252833; --bg4: #2d3140;
--border: #2d3140; --border2: #383d4a;
--text: #c9d1d9; --text2: #8b949e; --text3: #5d6470;
--accent: #f472b6; --accent2: #ec4899; --accent-bg: rgba(244,114,182,.12);
--green: #22c55e; --green-bg: rgba(34,197,94,.12);
--red: #ef4444; --red-bg: rgba(239,68,68,.12);
--yellow: #eab308; --yellow-bg: rgba(234,179,8,.12);
--blue: #3b82f6; --blue-bg: rgba(59,130,246,.12);
--orange: #f97316; --orange-bg: rgba(249,115,22,.12);
--bg: #111118; --bg2: #1b1b26; --bg3: #262434; --bg4: #312f40;
--border: #312f40; --border2: #3e3b4d;
--text: #e3e2e8; --text2: #c4c3cd; --text3: #8e8d97;
--accent: #f472b6; --accent2: #ff80c4; --accent-bg: rgba(244,114,182,.16);
--blue: #8ab4f8; --blue-bg: rgba(138,180,248,.14);
--green: #34d399; --green-bg: rgba(52,211,153,.14);
--red: #f87171; --red-bg: rgba(248,113,113,.14);
--yellow: #fbbf24; --yellow-bg: rgba(251,191,36,.14);
--orange: #fb923c; --orange-bg: rgba(251,146,60,.14);
--sidebar-w: 220px; --sidebar-collapsed: 52px;
--radius: 10px; --radius-sm: 6px;
--transition: 0.2s ease;
--radius: 12px; --radius-sm: 8px;
--transition: 0.25s cubic-bezier(0.4,0,0.2,1);
--glass-bg: rgba(27,27,38,0.72);
--glass-blur: blur(16px);
--elevation-1: 0 1px 2px rgba(0,0,0,.3), 0 1px 3px rgba(0,0,0,.15);
--elevation-2: 0 2px 4px rgba(0,0,0,.3), 0 4px 8px rgba(0,0,0,.15);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Google Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.5;
display: flex; height: 100vh; overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ========== 侧边栏 ========== */
#sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-collapsed);
background: var(--bg2); border-right: 1px solid var(--border);
background: var(--bg2); box-shadow: 2px 0 12px rgba(0,0,0,.2);
display: flex; flex-direction: column; transition: width var(--transition);
overflow: hidden; z-index: 10;
}
@@ -127,8 +132,10 @@ body {
/* ========== 通用组件 ========== */
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
background: var(--glass-bg); border: none; border-radius: var(--radius);
padding: 16px; margin-bottom: 16px;
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
box-shadow: var(--elevation-1);
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
@@ -4234,6 +4241,8 @@ var PLATFORM_FIELDS = { obv11: [
]},
{ key: 'remote_url', label: 'NapCat OneBot WS 地址', placeholder: '如: ws://127.0.0.1:10311', showIf: { key: 'mode', value: 'client' } },
{ key: 'access_token', label: 'Access Token (可选)', placeholder: '留空则不验证' },
{ key: 'http_url', label: 'NapCat HTTP API 地址 (可选)', placeholder: '如: http://127.0.0.1:6099, 留空则从WS地址推导', showIf: { key: 'mode', value: 'client' } },
{ key: 'http_token', label: 'HTTP API Token (可选)', placeholder: '留空则使用 Access Token', showIf: { key: 'mode', value: 'client' } },
{ key: 'bot_port', label: '本地监听端口', placeholder: '8096', showIf: { key: 'mode', value: 'server' } },
{ key: 'send_interval_ms', label: '消息发送间隔 (毫秒, 防封控)', placeholder: '2000' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
@@ -5959,6 +5968,7 @@ function formatTokens(n) {
}
</script>
<script src="icons.js"></script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
+25 -26
View File
@@ -20,8 +20,8 @@ async function fetchIoTDevices() {
}
var IOT_DEVICE_TYPES = {
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
'ac': 'ac_unit', 'light': 'lightbulb', 'curtain': 'blinds', 'door_lock': 'lock',
'camera': 'camera', 'sensor': 'sensors', 'speaker': 'volume_up', 'thermostat': 'thermostat',
};
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
@@ -40,15 +40,15 @@ async function renderIoTPanel() {
// 更新操作栏 (只更新时间戳文本)
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
'<span class="iot-last-update"> 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">'+icon('refresh',14)+' 刷新</button>' +
'<span class="iot-last-update">'+icon('timer',12)+' 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
if (result.error) {
var hint = '';
if (result.error.errorType === 'iot_not_running') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
hint = '<br><span style="font-size:11px">'+icon('lightbulb',12)+' 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
}
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
panel.innerHTML = '<div class="empty-state"><div class="icon">'+icon('warning_amber',24)+'</div>' + escHtml(result.error.error) + hint + '</div>';
STATE.iotInitialized = false;
return;
}
@@ -81,7 +81,7 @@ async function renderIoTPanel() {
if (firstRender || !grid) {
// 首次渲染: 创建完整结构
var html = '<div class="iot-refresh-bar">' +
'<span style="font-weight:600;font-size:14px" id="iot-device-count">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
'<span style="font-weight:600;font-size:14px" id="iot-device-count">'+icon('sensors',16)+' 模拟 IoT 设备 (' + devices.length + ')</span>' +
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
'</div><div class="iot-device-grid" id="iot-device-grid">' +
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
@@ -91,7 +91,7 @@ async function renderIoTPanel() {
} else {
// 增量更新: 只更新设备数量、最后更新时间
var countEl = document.getElementById('iot-device-count');
if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')';
if (countEl) countEl.innerHTML = icon('sensors',16)+' 模拟 IoT 设备 (' + devices.length + ')';
// 对每个已有设备卡片做增量更新
devices.forEach(function(device) {
@@ -123,7 +123,7 @@ function updateIoTDeviceCardInPlace(device) {
var toggleBtn = card.querySelector('.iot-toggle-btn');
if (toggleBtn) {
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启';
toggleBtn.innerHTML = isOn ? icon('power_settings_new',14)+' 关闭' : icon('power_settings_new',14)+' 开启';
}
// 更新设备属性值 (温度、亮度、位置等)
@@ -182,8 +182,7 @@ function updateIoTDeviceCardInPlace(device) {
// 更新电量
var batteryEls = card.querySelectorAll('.iot-prop-value');
batteryEls.forEach(function(el) {
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label[data-prop="battery"]')) {
if (device.battery != null) el.textContent = device.battery + '%';
}
});
@@ -194,9 +193,9 @@ function updateIoTDeviceCardInPlace(device) {
acBtns.forEach(function(btn) {
var text = btn.textContent.trim();
var currentTemp = device.temperature || 26;
if (text === ' -2°C') {
if (text === ''+icon('arrow_downward',12)+' -2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
} else if (text === ' +2°C') {
} else if (text === ''+icon('arrow_upward',12)+' +2°C') {
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
}
});
@@ -205,13 +204,13 @@ function updateIoTDeviceCardInPlace(device) {
function renderIoTDeviceCard(device) {
var isOn = device.status === 'on';
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
var iconName = IOT_DEVICE_TYPES[device.type] || 'inventory_2';
var propsHtml = '';
if (device.type === 'ac') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<span class="iot-prop-label">'+icon('thermostat',16)+' 温度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
@@ -219,7 +218,7 @@ function renderIoTDeviceCard(device) {
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔄 模式</span>' +
'<span class="iot-prop-label">'+icon('refresh',14)+' 模式</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_MODE_OPTIONS.map(function(m) {
var active = (device.mode || 'cool') === m ? ' active' : '';
@@ -230,7 +229,7 @@ function renderIoTDeviceCard(device) {
} else if (device.type === 'light') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">💡 亮度</span>' +
'<span class="iot-prop-label">'+icon('lightbulb',16)+' 亮度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
@@ -238,7 +237,7 @@ function renderIoTDeviceCard(device) {
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🎨 颜色</span>' +
'<span class="iot-prop-label">'+icon('palette',16)+' 颜色</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_COLOR_OPTIONS.map(function(c) {
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
@@ -250,7 +249,7 @@ function renderIoTDeviceCard(device) {
} else if (device.type === 'curtain') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🪟 位置</span>' +
'<span class="iot-prop-label">'+icon('blinds',16)+' 位置</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
@@ -260,7 +259,7 @@ function renderIoTDeviceCard(device) {
} else if (device.temperature != null) {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<span class="iot-prop-label">'+icon('thermostat',16)+' 温度</span>' +
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
'</div>';
}
@@ -268,32 +267,32 @@ function renderIoTDeviceCard(device) {
if (device.battery != null) {
propsHtml +=
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔋 电量</span>' +
'<span class="iot-prop-label" data-prop="battery">'+icon('battery_full',16)+' 电量</span>' +
'<span class="iot-prop-value">' + device.battery + '%</span>' +
'</div>';
}
var actionsHtml = '<div class="iot-device-actions">' +
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
(isOn ? '⏻ 关闭' : '⏻ 开启') +
(isOn ? ''+icon('power_settings_new',14)+' 关闭' : ''+icon('power_settings_new',14)+' 开启') +
'</button>';
if (device.type === 'ac') {
var currentTemp = device.temperature || 26;
actionsHtml +=
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')"> -2°C</button>' +
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')"> +2°C</button>';
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">'+icon('arrow_downward',12)+' -2°C</button>' +
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">'+icon('arrow_upward',12)+' +2°C</button>';
}
actionsHtml +=
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">📋 历史</button>' +
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">'+icon('list_alt',13)+' 历史</button>' +
'</div>' +
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
'<div class="iot-device-header">' +
'<div class="iot-device-name">' +
'<span style="font-size:24px">' + icon + '</span>' +
'<span style="font-size:24px">' + icon(iconName, 24) + '</span>' +
'<div>' +
'<div>' + escHtml(device.name) + '</div>' +
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
+67 -17
View File
@@ -42,8 +42,14 @@ app.use((req, res, next) => {
next();
});
// 静态文件 - Web控制台
app.use(express.static(path.join(__dirname, '../public')));
// 静态文件 - Web控制台(禁用缓存,开发阶段每次刷新拿最新)
app.use(express.static(path.join(__dirname, '../public'), {
setHeaders: (res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
}));
// ========== WebSocket ==========
const server = http.createServer(app);
@@ -276,22 +282,31 @@ app.get('/api/health', (_req, res) => {
});
});
// ---- ethend 自重启 ----
// ---- ethend 自重启 (Windows-safe) ----
app.post('/api/ethend/restart', (_req, res) => {
res.json({ success: true, message: 'ethend 正在重启...' });
// 延迟 500ms 确保响应已发送,然后 spawn 新进程并退出
// 步骤1: 立即关闭 HTTP 服务器,释放端口 9090
server.close(() => {
console.log('ethend HTTP 服务已关闭');
});
// 步骤2: 延迟 2 秒确保端口完全释放,然后启动新进程
setTimeout(() => {
const scriptPath = path.join(__dirname, 'index.js');
const child = spawn(process.execPath, [scriptPath, ...process.argv.slice(2)], {
cwd: ROOT,
detached: true,
// 注意: 不使用 detached:true
// - detached 在 Windows 上会创建新进程组→弹出 cmd 窗口
// - Windows 默认子进程不随父进程退出而终止,所以不需要 detached
// - windowsHide:true 确保即使有窗口也被隐藏
const child = spawn(process.execPath, [scriptPath], {
cwd: path.join(ROOT, 'ethend'),
detached: false,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
process.exit(0);
}, 500);
}, 2000);
});
// ---- 仪表盘数据 (必须在 /api/services/:id 之前以避免路由冲突) ----
@@ -1281,20 +1296,17 @@ app.get('/api/trace/recent', async (req, res) => {
proxyToAICore(`/api/v1/llm-calls?limit=${limit}`).catch(() => ({ status: 502, body: [] })),
proxyToAICore(`/api/v1/tools/calls?limit=${limit}`).catch(() => ({ status: 502, body: { calls: [] } })),
proxyToGateway('/api/v1/admin/sessions/active').catch(() => ({ status: 502, body: { users: {} } })),
// platform-bridge 最近的消息日志
Promise.all((['obv11']).map(platform =>
proxyToPlatformBridge(`/api/v1/logs/${platform}?limit=50`).catch(() => ({ status: 502, body: { logs: [] } }))
)),
// 直接从磁盘读取 platform-bridge 日志文件
Promise.resolve(readBridgeLogs(50)),
// 拉 ai-core 追踪事件
proxyToAICore(`/api/v1/trace/events?limit=${limit}`).catch(() => ({ status: 502, body: { events: [] } })),
]);
const traces = [];
// === 消息收发 (platform-bridge 日志) ===
for (const logResult of bridgeLogsResult) {
const entries = logResult.body?.logs || logResult.body?.entries || logResult.body || [];
for (const entry of entries) {
// === 消息收发 (platform-bridge 日志文件) ===
const entries = bridgeLogsResult || [];
for (const entry of entries) {
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
const isIncoming = entry.direction === 'incoming';
const hop = isIncoming ? 'msg_received' : 'msg_sent';
@@ -1314,7 +1326,6 @@ app.get('/api/trace/recent', async (req, res) => {
data: { platform: entry.platform, sender, channel, content: entry.content, direction: entry.direction, contentType: entry.content_type },
});
}
}
// === LLM 调用 ===
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []);
@@ -1905,8 +1916,47 @@ server.listen(ETHEND_PORT, () => {
for (const [id, svc] of Object.entries(SERVICES)) {
console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`);
}
// ethend 重启后自动接管已运行的服务(延迟 2s 等待健康检查就绪)
setTimeout(async () => {
console.log('[ethend] 扫描已运行的服务...');
for (const [id] of Object.entries(SERVICES)) {
if (id === 'frontend') continue; // 前端不需要接管
try {
const adopted = await processManager.tryAdopt(id);
if (adopted) {
console.log(`${id} 已接管`);
}
} catch { /* skip */ }
}
console.log('[ethend] 服务扫描完成');
}, 2000);
});
// readBridgeLogs 从磁盘读取 platform-bridge 的 JSONL 日志文件
function readBridgeLogs(limit) {
const bridgeLogDir = path.join(ROOT, 'backend', 'platform-bridge', 'logs');
const logFiles = ['obv11-main.log', 'obv11.log']; // 新旧平台名都试试
const entries = [];
for (const name of logFiles) {
const logPath = path.join(bridgeLogDir, name);
if (!fs.existsSync(logPath)) continue;
try {
const content = fs.readFileSync(logPath, 'utf-8');
const lines = content.trim().split('\n');
// 取最后 limit 行
const recent = lines.slice(-limit);
for (const line of recent) {
try {
entries.push(JSON.parse(line));
} catch { /* skip bad lines */ }
}
break; // 优先用第一个找到的文件
} catch { /* skip */ }
}
return entries.slice(-limit);
}
// 时间格式化:使用系统本地时区
function formatLocalTime(ts) {
var d = new Date(ts);
+200 -8
View File
@@ -196,10 +196,23 @@ async function ensureDBOnline(serviceId, emitter) {
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
}
// ── Watchdog 配置 ──
// 自动重启策略:进程异常退出时自动重启,指数退避,超过上限后停止
const WATCHDOG = {
MAX_RETRIES: 5, // 滑动窗口内最大重试次数
RETRY_WINDOW_MS: 5 * 60 * 1000, // 重试计数滑动窗口 (5 分钟)
BASE_DELAY_MS: 1000, // 首次重试延迟
MAX_DELAY_MS: 60 * 1000, // 最大重试延迟 (指数退避上限)
STABLE_RESET_MS: 2 * 60 * 1000, // 稳定运行此时间后重置重试计数
// 可以为每个服务单独覆盖配置,按 serviceId 索引
OVERRIDES: {},
};
class ProcessManager extends EventEmitter {
constructor() {
super();
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[], retryCount: number, retryHistory: number[], stableTimer: NodeJS.Timeout|null}>} */
this.processes = new Map();
for (const id of Object.keys(SERVICES)) {
@@ -209,10 +222,54 @@ class ProcessManager extends EventEmitter {
startTime: null,
pid: null,
buildLog: [],
retryCount: 0,
retryHistory: [], // crash timestamps in current window
stableTimer: null,
});
}
}
/**
* 获取服务的 watchdog 配置合并默认值和覆盖
*/
_watchdogConfig(serviceId) {
const defaults = {
maxRetries: WATCHDOG.MAX_RETRIES,
retryWindowMs: WATCHDOG.RETRY_WINDOW_MS,
baseDelayMs: WATCHDOG.BASE_DELAY_MS,
maxDelayMs: WATCHDOG.MAX_DELAY_MS,
stableResetMs: WATCHDOG.STABLE_RESET_MS,
};
const overrides = WATCHDOG.OVERRIDES[serviceId] || {};
return { ...defaults, ...overrides };
}
/**
* 清理滑动窗口外的旧崩溃记录
*/
_pruneRetryHistory(procInfo, windowMs) {
const cutoff = Date.now() - windowMs;
procInfo.retryHistory = procInfo.retryHistory.filter(ts => ts > cutoff);
procInfo.retryCount = procInfo.retryHistory.length;
}
/**
* 启动稳定运行计时器服务稳定运行一段时间后重置重试计数
*/
_startStableTimer(serviceId, procInfo) {
const cfg = this._watchdogConfig(serviceId);
if (procInfo.stableTimer) clearTimeout(procInfo.stableTimer);
procInfo.stableTimer = setTimeout(() => {
if (procInfo.retryCount > 0) {
this.emit('log', serviceId, 'system',
`✅ Watchdog: 服务已稳定运行 ${Math.round(cfg.stableResetMs / 1000)}s,重置崩溃计数 (之前 ${procInfo.retryCount} 次)`);
}
procInfo.retryCount = 0;
procInfo.retryHistory = [];
procInfo.stableTimer = null;
}, cfg.stableResetMs);
}
/**
* 启动服务
*/
@@ -230,6 +287,39 @@ class ProcessManager extends EventEmitter {
throw new Error(`${svc.name} 已在运行中`);
}
// 如果服务已在运行(例如手动启动),通过健康检查自动接管,避免杀进程
if (svc.healthUrl) {
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
let pid = null;
try {
if (isWin) {
// Windows: use netstat -ano to find PID by port
const out = execSync(`netstat -ano | findstr ":${svc.port} " | findstr "LISTENING"`, { timeout: 3000, stdio: 'pipe' }).toString().trim();
const lines = out.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const last = parts[parts.length - 1];
if (/^\d+$/.test(last)) { pid = parseInt(last); break; }
}
} else {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
}
} catch { /* ignore */ }
procInfo.pid = pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = null;
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管(无需重启)`);
this._startStableTimer(serviceId, procInfo);
return { success: true, message: `${svc.name} 已接管 (无需重启)` };
}
} catch { /* 不可达,继续启动新进程 */ }
}
// 对需要数据库的服务做前置检查
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
@@ -297,13 +387,20 @@ class ProcessManager extends EventEmitter {
// .cmd/.bat on Windows needs shell:true
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
// Go 后端进程使用 detached 确保 ethend 崩溃后独立存活
// npx/node 进程不使用 detached 避免 Windows 弹窗
const isGoSvc = svc.command === './main';
const child = spawn(command, args, {
cwd: svc.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: needsShell,
windowsHide: true,
detached: isGoSvc,
});
if (isGoSvc) {
child.unref(); // 解除父子关系,ethen 退出不影响子进程
}
child.stdout.on('data', (data) => {
const text = data.toString();
@@ -335,15 +432,74 @@ class ProcessManager extends EventEmitter {
});
child.on('close', (code) => {
const msg = `进程退出,退出码: ${code}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'system', msg);
const exitMsg = `进程退出,退出码: ${code}`;
logStream.write(exitMsg + '\n');
this.emit('log', serviceId, 'system', exitMsg);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
logStream.end();
// 清除稳定运行计时器
if (procInfo.stableTimer) {
clearTimeout(procInfo.stableTimer);
procInfo.stableTimer = null;
}
// ── Watchdog 自动重启判断 ──
const isNormalExit = code === 0 || code === null; // null = signal kill (e.g. SIGTERM from manual stop)
// 检查是否是用户手动停止 (通过 _manualStop 标记)
const wasManualStop = procInfo._manualStop === true;
procInfo._manualStop = false; // 重置标记
if (isNormalExit || wasManualStop) {
// 正常退出或手动停止:不触发 watchdog
procInfo.retryCount = 0;
procInfo.retryHistory = [];
return;
}
// 异常退出 (code !== 0):尝试自动重启
const wdCfg = this._watchdogConfig(serviceId);
this._pruneRetryHistory(procInfo, wdCfg.retryWindowMs);
procInfo.retryHistory.push(Date.now());
procInfo.retryCount = procInfo.retryHistory.length;
if (procInfo.retryCount > wdCfg.maxRetries) {
const windowSec = Math.round(wdCfg.retryWindowMs / 1000);
this.emit('log', serviceId, 'error',
`🚨 Watchdog: ${svc.name}${windowSec}s 内崩溃 ${procInfo.retryCount} 次,已达上限 (${wdCfg.maxRetries}),停止自动重启。请手动排查问题后重启。`);
procInfo.status = 'crashed';
return;
}
// 计算指数退避延迟
const delay = Math.min(
wdCfg.baseDelayMs * Math.pow(2, procInfo.retryCount - 1),
wdCfg.maxDelayMs
);
const delaySec = Math.round(delay / 1000);
this.emit('log', serviceId, 'system',
`🔄 Watchdog: ${svc.name} 异常退出 (第 ${procInfo.retryCount}/${wdCfg.maxRetries} 次)${delaySec}s 后自动重启...`);
setTimeout(async () => {
try {
this.emit('log', serviceId, 'system', `🔄 Watchdog: 正在自动重启 ${svc.name}...`);
const result = await this.start(serviceId);
if (result.success) {
this.emit('log', serviceId, 'system', `✅ Watchdog: ${svc.name} 自动重启成功`);
} else {
this.emit('log', serviceId, 'error', `❌ Watchdog: ${svc.name} 自动重启失败: ${result.message}`);
}
} catch (err) {
this.emit('log', serviceId, 'error', `❌ Watchdog: ${svc.name} 自动重启异常: ${err.message}`);
}
}, delay);
});
// 启动稳定运行计时器
this._startStableTimer(serviceId, procInfo);
return { success: true, message: `${svc.name} 启动中...` };
}
@@ -360,12 +516,36 @@ class ProcessManager extends EventEmitter {
const procInfo = this.processes.get(serviceId);
if (!procInfo.process) {
// 接管模式(未持有进程句柄):通过端口反查 PID 并强制杀死
if (svc.port && procInfo.pid) {
try {
if (isWin) {
execSync(`taskkill /F /PID ${procInfo.pid}`, { timeout: 5000, stdio: 'pipe' });
} else {
execSync(`kill -9 ${procInfo.pid}`, { timeout: 5000, stdio: 'pipe' });
}
this.emit('log', serviceId, 'system', `${svc.name} 已通过 PID 强制停止 (PID: ${procInfo.pid})`);
} catch (e) {
this.emit('log', serviceId, 'system', `${svc.name} PID ${procInfo.pid} 已不存在或无法杀死`);
}
} else if (svc.port) {
// 没有 PID 但有端口:用 fuser 释放端口
try {
execSync(`fuser -k ${svc.port}/tcp 2>/dev/null || true`, { timeout: 5000, stdio: 'pipe' });
this.emit('log', serviceId, 'system', `${svc.name} 端口 ${svc.port} 已释放`);
} catch { /* fuser not available */ }
}
procInfo.status = 'stopped';
procInfo.pid = null;
procInfo.startTime = null;
// 等待 1 秒确保端口释放
await new Promise(r => setTimeout(r, 1000));
return { success: true, message: `${svc.name} 已停止` };
}
// 标记为手动停止,防止 watchdog 自动重启
procInfo._manualStop = true;
return new Promise((resolve) => {
const timeout = setTimeout(() => {
// 强制杀死
@@ -488,6 +668,7 @@ class ProcessManager extends EventEmitter {
port: svc.port,
healthUrl: svc.healthUrl,
source,
retryCount: info.retryCount || 0,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
@@ -518,6 +699,7 @@ class ProcessManager extends EventEmitter {
port: svc.port,
healthUrl: svc.healthUrl,
source,
retryCount: info.retryCount || 0,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
@@ -556,12 +738,22 @@ class ProcessManager extends EventEmitter {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const procInfo = this.processes.get(serviceId);
// 尝试通过 fuser 获取 PID
// 通过端口反查 PID
let pid = null;
try {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
if (isWin) {
const out = execSync(`netstat -ano | findstr ":${svc.port} " | findstr "LISTENING"`, { timeout: 3000, stdio: 'pipe' }).toString().trim();
const lines = out.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const last = parts[parts.length - 1];
if (/^\d+$/.test(last)) { pid = parseInt(last); break; }
}
} else {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
}
} catch { /* ignore */ }
procInfo.pid = pid;