122 Commits

Author SHA1 Message Date
AskaEth 88755e131a fix: 工具名过滤、消息拆分提示词优化
- filterActions: 过滤DeepSeek直接输出工具名(如vision_analyze)的怪话
- 系统提示词: 明确告知用双换行(空行)拆分多条消息
- 群聊规则: 加入独立想法用双换行分隔的说明
2026-06-29 12:57:21 +08:00
AskaEth 44048b333a fix: 死锁修复、管理员权限集中管控、群聊频率控制、表情映射修复
thinker.go: 移除所有 defer t.muUnlock() 持锁跨阻塞调用的模式,消除8处死锁点
- performThink: defer→立即解锁,LLM调用不再持锁
- lightThinkLoop: defer在for循环内→第2次迭代自死锁
- resetSilenceTimer: defer持锁调performThink
- UpdatePresence: defer持锁调time.Sleep+performThink
- storeThought: defer+panic→锁泄露; 移除extractProactiveMessage嵌套锁

is_admin三层防御:
- synthesizer: 系统提示词注入管理员/非管理员身份标签
- iot_provider: 非管理员直接拒绝IoT操作
- plugin-manager: ToolDefinition.AdminOnly自动拦截,集中管控

群聊优化:
- group_ambient: 强化审查指令,【不发送】自审查标签
- 群聊间隔4s→3s,最多2条/轮
- 工具失败也推跟进消息,避免沉默

平台桥接:
- 日志文件名使用适配器唯一标识符(ConfigName)
- QQ表情映射替换为官方116条目数据
- CQ表情保留名称/ID
2026-06-28 20:22:09 +08:00
AskaEth 236dbfcc92 fix: 删除performThink中孤儿muUnlock 2026-06-28 17:22:30 +08:00
AskaEth d63322244c fix: performThink defer unlock改回立即unlock——LLM调用期间不能持锁 2026-06-28 17:19:03 +08:00
AskaEth d4c246fa0c fix: 移除有问题的runtime.SetFinalizer 2026-06-28 17:07:24 +08:00
AskaEth 76e648aac3 fix: 回退thinker.go到6490c34稳定版 2026-06-28 16:58:55 +08:00
AskaEth 32160d9453 fix: 回退muLock为简单版本,保留deadlock监控 2026-06-28 16:57:36 +08:00
AskaEth 3b8b35069a fix: ethend.bat 启动时统一走端口检测+Y/n交互 2026-06-28 16:53:02 +08:00
AskaEth 6cbb3e3e80 fix: ethend.bat 端口检测改用变量避免for循环内交互闪退 2026-06-28 16:50:26 +08:00
AskaEth f8d3900eaa chore: ethend.bat 启动写时间戳到日志 2026-06-28 16:49:10 +08:00
AskaEth 350cd105bf fix: ethend.bat 端口占用时询问用户是否Kill旧进程 2026-06-28 16:46:35 +08:00
AskaEth f95f7d67e3 chore: .env.example 同步 PROACTIVE_MSG_MIN_GAP_SEC=0 2026-06-28 16:38:52 +08:00
AskaEth 1d4560e676 fix: PROACTIVE_MSG_MIN_GAP_SEC=0 写入.env 2026-06-28 16:37:25 +08:00
AskaEth 1f52b424e1 fix: 主动消息默认频控1分钟 2026-06-28 16:36:07 +08:00
AskaEth 2835d62d41 feat: 主动消息频控默认关闭 (0=disabled, 设秒数开启) 2026-06-28 16:35:39 +08:00
AskaEth 6490c341e5 fix: 主动消息频控从30分钟降到5分钟 + 拦截后清除标记防止LLM幻觉 2026-06-28 16:34:47 +08:00
AskaEth d196a22f42 fix: 所有muLock改为defer unlock防止panic孤儿锁 2026-06-28 16:27:40 +08:00
AskaEth 24d7722ef9 fix: storeThought orphaned lock → defer unlock 2026-06-28 16:16:35 +08:00
AskaEth dc410c1cff debug: 死锁监控+DebugMutex+ChSend+GoStart 调试工具 2026-06-28 13:01:54 +08:00
AskaEth 537e4ed550 feat: 群聊非@消息走审查模式—值得插话才回 2026-06-28 12:17:03 +08:00
AskaEth 9224633986 fix: 管理员群聊消息不再静默,始终正常处理 2026-06-28 12:15:44 +08:00
AskaEth bebacf805b fix: 使用Data字段返回权限拒绝提示给LLM 2026-06-28 12:13:13 +08:00
AskaEth ca43604eba fix: IoT权限拒绝改为LLM生成自然回复 2026-06-28 12:12:33 +08:00
AskaEth 56b105a1dc fix: IoT控制双层权限 — LLM先行判断 + 代码兜底拦截 2026-06-28 12:10:53 +08:00
AskaEth 310a8733c8 fix: IoT设备控制仅限管理员 2026-06-28 12:06:09 +08:00
AskaEth 1e652b5f32 fix: 主动消息禁止编造场景 + 同步推送到QQ 2026-06-28 11:53:46 +08:00
AskaEth a47c8be77b fix: 主动消息同步推送到平台适配器(QQ) 2026-06-28 11:52:43 +08:00
AskaEth 2b3aa45547 fix: saveToDB 改为同步写,防止进程被杀时消息丢失 2026-06-28 11:44:42 +08:00
AskaEth dfd6ccbd41 fix: 移除 DuckDuckGo 回退,web_search 仅用 SearXNG 2026-06-28 11:42:14 +08:00
AskaEth 6553656c9a fix: web_search 走本地 SearXNG 而非 DuckDuckGo 2026-06-28 11:41:12 +08:00
AskaEth 9b20f14ab9 fix: emitToolProgress recover on closed channel 2026-06-28 11:36:27 +08:00
AskaEth 5725075a31 fix: LoadFromDB Timestamp 赋值 + restoreContext 查20条消息 2026-06-28 11:32:17 +08:00
AskaEth 29f24b3036 fix: restoreContext 从会话历史取最近活动时间,不再用记忆条目时间 2026-06-28 11:30:15 +08:00
AskaEth fd44b15d81 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>
2026-06-28 11:29:29 +08:00
AskaEth 0d6970a2d3 fix: halo_publish 插件 — runtime.Caller 解析config.json绝对路径 2026-06-26 12:57:50 +08:00
AskaEth 839522e1d4 fix: web_search nil pointer崩溃 — 零值struct→构造函数
- WebSearchPlugin/HTTPPlugin/WebFetchPlugin/FilePlugin
  全部改用构造函数创建,避免内部http.Client为nil
- 根因: executeAsyncAndStore异步执行时零值struct导致panic
- ai-core频繁崩溃的直接原因

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 12:51:33 +08:00
AskaEth 2d79126d36 docs: 插件README+开发文档更新 — 反映自动发现流程(扫描go.mod+plugin.json) 2026-06-25 21:55:44 +08:00
AskaEth 4424c09759 docs: 插件README修正 — 写明三步注册流程 + go:generate指令 2026-06-25 20:48:15 +08:00
AskaEth 27e7bd0649 docs: ToolProgress 补全 Error + Result 字段 2026-06-24 19:39:10 +08:00
AskaEth 1f633608db docs: 插件开发文档补全 — Stop/Health/Complexity/ComplexTool/ToolDefinition全字段 2026-06-24 19:37:10 +08:00
AskaEth 65ff530ce4 docs: 插件注册方式更新 — plugins.json代码生成替代硬编码 2026-06-24 19:31:49 +08:00
AskaEth cfebf5480e feat: 插件自动发现 — plugins.json + 代码生成器
- plugins.json 配置文件列出所有插件及其import路径
- gen_plugins.go 读取配置生成 plugins_gen.go
- main.go 调用 registerPlugins() 替代硬编码列表
- 新增 backend/plugins/ 第三方插件目录
- 插件开发文档: docs/api/plugin-development.md

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 19:30:52 +08:00
AskaEth 2e94e7b0bf docs: 插件开发指南 + README清理dev_must_read和pg-backup引用 2026-06-24 19:14:51 +08:00
AskaEth ebd6f40425 feat: 后台思考可被前台消息中断 — 用户发消息时cancel运行中的思考 2026-06-24 18:21:36 +08:00
AskaEth fab934fb12 feat: performThink 10s休闲检查 + IsUserRecentlyActive — 后台让步前台 2026-06-23 21:52:02 +08:00
AskaEth e83bd6febb feat: 后台思考让步前台 — 用户10s内有活动则跳过非post_chat思考 2026-06-23 21:44:31 +08:00
AskaEth cdfe440044 feat: send-proactive 消息记入平台日志 — ethend可见 2026-06-23 21:42:23 +08:00
AskaEth 3a5591833f fix: INSERT VALUES 列数不匹配 — 补齐platform字段 2026-06-23 21:29:41 +08:00
AskaEth 7e0268a884 fix: reminder platform 改用 AdapterName(唯一标识符),不再硬编码 obv11
- ProcessParams/SynthesizeParams 新增 AdapterName
- main.go 传递 req.Source.AdapterName
- reminder_create 自动注入使用 adapter 名而非平台类型

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 21:27:02 +08:00
AskaEth c75ddc13ea fix: SynthesizeParams 加 ChannelID 2026-06-23 21:24:19 +08:00
AskaEth f7c41f3e23 fix: LLM提醒触发加2次重试 — 避免ai-core繁忙时超时 2026-06-23 21:21:46 +08:00
AskaEth 049332312b fix: reminder LLM触发超时 15s→30s 2026-06-23 21:18:37 +08:00
AskaEth 27a497e397 feat: 群聊提醒 — 提醒关联平台/频道/适配器,到期发回原群聊
- reminder_create 新增 platform/channel_type/channel_id/adapter_name 参数
- Gateway Reminder 模型 + DB 迁移 + CRUD 全部支持新字段
- 提醒到期时:群聊提醒 → platform-bridge 发回原群聊
- 非群聊提醒 → 走 Web 端推送
- Thinker 新增 PushPlatformMessage 方法

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:57:58 +08:00
AskaEth 680696e2c4 fix: 工具结果推送 userID为空→回退adminUserID 2026-06-23 20:50:33 +08:00
AskaEth 01feb16262 feat: 异步工具结果主动推送 — 通用回调机制
- Synthesizer 新增 resultPusher 回调字段
- executeAsyncAndStore 完成后自动推送结果
- Orchestrator.SetToolResultPusher 透传
- 回调通用设计,不绑定特定工具

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:42:55 +08:00
AskaEth 30d5b9b464 fix: 重新应用工具异步执行 (被git checkout丢失) 2026-06-23 20:39:51 +08:00
AskaEth a3bdeba63a feat: 工具调用全部异步,不再阻塞主会话
- 去掉8s超时同步执行,工具一律走 executeAsyncAndStore
- LLM立即收到[后台执行中]提示,继续生成回复
- 工具结果在下一轮对话中返回

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:35:11 +08:00
AskaEth 2ec49f0779 fix: ListReminders JSON解析 — Gateway返{reminders:[...]}而非裸数组 2026-06-23 20:32:10 +08:00
AskaEth edc2e77d2f fix: internal reminder路由+user_id支持 — 修复401/404
- Gateway新增 POST/GET/DELETE /api/v1/internal/reminders
- CreateReminderRequest新增UserID字段,JWT为空时回退
- GatewayClient URL改为 /api/v1/internal/reminders

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:24:09 +08:00
AskaEth fea67d05eb fix: reminder工具401认证 — 路由从JWT组迁到internal组
- Gateway新增 /api/v1/internal/reminders (GET/POST/DELETE)
- 使用 X-Internal-Token 认证,不再需要JWT
- GatewayClient URL改为 /api/v1/internal/reminders

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:19:23 +08:00
AskaEth d38b92568b feat: 自主思考可按需查看群聊上下文 — 【查看群聊:适配器名:群号】
- 思者可在反思中输出【查看群聊:obv11-main:531082168】主动获取群聊记录
- 查找平台频道表确认adapter名称对应的platform类型
- 通过 memory-service 拉取最近5条频道记忆注入思考
- 自动追加【继续思考】以处理获取到的上下文
- 分析结果保留在思考链中

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:12:20 +08:00
AskaEth a2078b6662 feat: 主动消息写回对话历史 — 防止短期重复提问
- storeThought 推送主动消息后写入 convStore
- 轻量思考话题发起后也写入
- 配合提示词规则: 两小时内不重复问同类话题

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 20:01:43 +08:00
AskaEth 1eb22f5d06 fix: blocklist提前到消息处理最前端 — 拦截消息不浪费任何资源
- 移到 logging/enrichment/routing 之前
- 被拦截消息只记一条 [blocked] 日志,不调ai-core
- 时间戳/群名/昵称等富化处理不再对blocked消息执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:53:23 +08:00
AskaEth 4733f9c02a fix: 前端追踪时间 — 三处toISOString()全部替换为本地时间
- traceHopHtml 直接用后端格式化好的 timestamp
- llmCallToTrace 用前端 fmtLocal()
- 实时流事件用本地时间格式化
2026-06-23 19:45:16 +08:00
AskaEth f7df659c85 fix: 还原系统时区方案 — 时间戳已自带+08:00 2026-06-23 19:43:43 +08:00
AskaEth 109d427971 fix: 追踪时间强制UTC+8 — 不依赖系统时区 2026-06-23 19:41:43 +08:00
AskaEth f3b588d848 fix: 上下文恢复加24h合理性检查 — 避免用远古记忆当最近活动 2026-06-23 19:38:54 +08:00
AskaEth e0900bdd1b feat: 重启后恢复上下文 — 从记忆服务查询最近活动时间
- 新增 restoreContext() 在 Start() 时查询持久化记忆
- 取最近记忆的 CreatedAt 设为 lastUserMessage
- 避免重启后思考出现"已经一天没说话了"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:38:08 +08:00
AskaEth 71539b5520 fix: 追踪页面回退到平铺视图,管线聚合待后续优化 2026-06-23 19:34:39 +08:00
AskaEth 87e8f97c1f fix: ethend OBv11配置界面字段丢失 — PLATFORM_FIELDS key qq→obv11
- 配置platform迁移后 PLATFORM_FIELDS['obv11'] 查不到返回空
- 新增 getPlatformFields() 归一化查找,兼容旧qq key
- PLATFORM_ICONS/LABELS/REAL 同步增加 obv11 key

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:28:12 +08:00
AskaEth 222fc5768f fix: send-proactive adapter查找 — 先按名再按平台类型回退 2026-06-23 19:26:12 +08:00
AskaEth 4326f1fc02 fix: seedIdentities 硬编码配置名导致admin身份丢失
- 迁移后配置名 qq→obv11-main,但seed用["obv11"]查不到
- 改为遍历store.List()所有配置,不再硬编码名字
- admin消息不再被当普通人处理而丢失回复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:23:10 +08:00
AskaEth e433e94c89 fix: 全链路追踪时间显示 — UTC→UTC+8 本地时间
- 新增 formatLocalTime() 替换 toISOString()
- 追踪面板时间显示为北京时间

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:17:32 +08:00
AskaEth a47eeab808 feat: 全链路追踪系统 — ai-core追踪事件 + ethend管道视图
ai-core:
- 新增 TraceEvent 内存环形缓冲区(200条)
- GET /api/v1/trace/events 端点(支持session_id过滤)
- 预置 trace.go 追踪事件定义

ethend:
- trace/recent 聚合 platform-bridge消息日志 + ai-core追踪事件
- 消息按管线分组: msg_received→llm_call→tool_call→msg_sent
- 前端管线卡片视图: 每条消息显示完整处理步骤

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:11:55 +08:00
AskaEth a8fa64325d feat: 全链路追踪增强 + 超时修复
- ethend /api/trace/recent: 新增消息收发事件(platform-bridge日志)
- 消息按管线聚合: msg_received → llm_call → tool_call → msg_sent
- platform-bridge→ai-core超时 120s→180s

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 19:08:16 +08:00
AskaEth 3081ac38e0 fix: seed去重按platform type而非config name — 消除重复obv11 stub 2026-06-23 18:56:28 +08:00
AskaEth 0f180f17c3 fix: 旧配置自动迁移 — 补ID + qq→obv11 + platform_configs.json同步
- load() 自动为无ID旧配置生成唯一标识符
- 自动迁移 platform: qq→obv11, config名 qq→obv11
- platform_configs.json 已迁移: qq→obv11-main
- OBv11 adapter 已连接 (NapCat client模式)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 18:55:36 +08:00
AskaEth 1e2b2525f4 fix: 私聊主动消息路由修复 + 多账号自动适配
- 正则兼容 private_ 前缀: 【OBv11私聊:private_1487720750】
- prompt 显示频道时自动剥离 private_ 前缀
- extractProactiveMessage 自动查 PlatformChannel 表,
  将 target.Platform 从格式key改为adapter实例名
  多账号场景下自动路由到正确adapter

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 18:43:42 +08:00
AskaEth 73f0bf7b3e chore: 清除最后5处硬编码qq引用(注释+配置白名单) 2026-06-23 18:20:33 +08:00
AskaEth 8a3236b8bb refactor: 彻底消除硬编码 qq — 平台标识统一为 obv11
- PlatformName() 返回 "obv11"
- platformFormats key: "obv11"
- 所有 platform == "qq" 检查 → "obv11"
- session prefix: platform_qq_ → platform_obv11_
- env var PLATFORM_CHANNELS: obv11:group:xxx
- ethend 平台标识同步更新
- 注释和测试用例同步

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 18:19:52 +08:00
AskaEth c96bd23f83 feat: PlatformChannel 支持 AdapterID/AdapterName — 多账号全链路区分
- PlatformChannel 新增 AdapterID + AdapterName 字段
- AddOrUpdatePlatformChannel 接受 adapter 信息参数
- channel_sync 传递 adapter_id/adapter_name
- 消息处理路径传递 adapter_name

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 18:01:27 +08:00
AskaEth 45fac267fe feat: 平台配置唯一ID — PlatformConfig.ID + adapter全链路传播
- PlatformConfig 新增 ID 字段 (8字节hex,创建时自动生成)
- QQ adapter 新增 configID + ConfigID() 访问器
- ChannelInfo 新增 AdapterID + AdapterName
- createSingleAdapter 接受 configID 参数
- 同一个平台类型的多个配置实例通过 ID 唯一区分

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 17:59:37 +08:00
AskaEth 6bf59f7eee feat: 频道自动发现 — platform-bridge定时刷新频道路由 + ai-core周期同步
platform-bridge:
- QQ adapter: FetchGroups/FetchFriends 从NapCat API获取
- ChannelLister接口 + ChannelInfo结构体
- GET /api/v1/channels 端点 + cachedChannels缓存
- StartChannelRefresh 每10分钟自动刷新

ai-core:
- channel_sync.go: 每5分钟从platform-bridge拉取频道
- 自动更新thinker的platformChannels + botUIDs
- 不再依赖PLATFORM_CHANNELS环境变量手动配置

修复:
- SendMessage显式设AutoEscape:false
- PLATFORM_BRIDGE_URL默认端口8082→8095
- OBv11Params.AutoEscape去掉omitempty

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 13:12:54 +08:00
AskaEth c9bf839945 fix: QQ发送消息中文乱码 — SendMessage 显式设置 auto_escape=false
NapCat 在 auto_escape 未传时默认走 escape 模式,导致 UTF-8 中文被当 CQ 码处理

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 13:01:21 +08:00
AskaEth caa9bfced5 docs: 工具调用系统介绍文档
- 17个工具完整列表(共享插件 + AI-Core专属)
- 注册机制 + ToolExecutor接口
- 对话中完整调用流程(含LLM API请求/响应示例)
- 工具调用循环(最多5轮)
- 后台思考白名单机制
- 超时异步处理

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:53:23 +08:00
AskaEth 9767abae89 feat: 2.2 主动话题发起 — 轻量思考扩展【话题发起】输出
- 轻量思考 prompt 新增话题发起指引
- 检测【话题发起】标记 → 提取内容 → 通过 messagePusher 推送
- 遵循 ProactiveGuard 频率控制

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:46:51 +08:00
AskaEth bbf06da00f feat: 1.3 记忆自然引用 — 记忆标签从'数据库检索'改为'自己的回忆'
- 记忆头: '以下是关于开拓者的重要记忆' → '你记得关于开拓者的这些事'
- 分类: '★核心记忆/●常用记忆' → '你很确定的事/最近发生的事/还有点印象的事'
- 增加规则: 禁止用「根据记忆」「检索到」等说法

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:45:44 +08:00
AskaEth f9ee067674 feat: 1.2 情绪共振 — mood→行为映射替代情绪陈述
- 新增 buildMoodGuidance() 函数,5种心情各有行为描述
- 开心→话多♪多主动 | 调皮→撒娇逗人 | 沉思→安静温柔
- 担心→话少多关心 | 怀旧→提过去的故事
- 绝不陈述"你现在很开心",而是用行为让LLM自然表达

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:44:27 +08:00
AskaEth b8fd08b57c chore: WS断连日志增强 — 显示adapter名称和模式 2026-06-23 12:27:41 +08:00
AskaEth f0e8ffa2bb feat: extractProactiveMessage 单元测试 + 修复 log.Printf 格式化 bug
- 7 个测试覆盖:无标记/Web推送/OBv11群聊/私聊/@提及/否定语境/继续思考
- 修复 thinker.go:454 log.Printf %%v → %v 格式化错误

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:26:39 +08:00
AskaEth b3ce0b4f52 feat: LLM 降级策略 — 全部模型不可用时返回自然语言预设文案
- getFallbackMessage() 随机选择3条预设降级回复
- ProcessInput 失败和流式事件错误均使用降级回复
- 用户看到"开拓者…我现在好像有点恍惚…"而非错误信息

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:25:13 +08:00
AskaEth 0cd75c4dbc feat: 人性化改进 Phase 1 — 不完美感 + 日周期状态机 + 小脾气
- 1.7.7 不完美感: system prompt 追加停顿/改口/翻找回忆规则
- 1.7.8 日周期: 新增 timePeriod() 函数,时段注入思考提示词
  早晨/白天/傍晚/深夜/凌晨 五段式,自动调整语气和主动消息策略
- 1.7.9 小脾气: YAML 追加个性边界 — 深夜催睡/咖啡管控/撒娇/兴趣偏好

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 12:23:30 +08:00
AskaEth 39d62559ea fix: AdapterName JSON tag 写成了 group_name → 修正为 adapter_name 2026-06-22 21:33:31 +08:00
AskaEth 42793f9bba chore: ethend QQ→OBv11 + config.js环境变量同步 + curQQ变量重命名
- ethend: QQ_BOT_PORT→OBV11_BOT_PORT, QQ_ADMIN_UID→OBV11_ADMIN_UID
- ethend UI: QQ→OBv11 标签, 企鹅图标→🔷, 对话框文案
- platform-bridge: curQQ→curAdapter 变量名

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 21:32:10 +08:00
AskaEth 992d9a3515 chore: 移除已废弃的 tool-engine API 文档
tool-engine 服务已在 Phase 3 移除,工具调用整合到 pkg/plugins

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 21:17:46 +08:00
AskaEth c55201a03a chore: 清理不应跟踪的文档文件
gitignore 规范: 仅 docs/api/ 和 docs/deploy/ 纳入版本管理
移除: 架构分析/开发规划/备份迁移等规划类文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 21:17:14 +08:00
AskaEth 6faef5b6e5 feat: 群聊并发 — 主会话繁忙时创建协会议话
- Orchestrator 新增 per-session 处理锁 + co-session 跟踪
- 主会话处理中时,新消息自动创建协会议话(并发 LLM 调用)
- 协会议话上限 3 个,超出排队等待 500ms
- 主会话结束后自动释放锁

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 21:07:16 +08:00
AskaEth 489657ec08 feat: 提醒到期 → LLM生成自然提醒语 → 推送
- Gateway ReminderScheduler 到期时调用 ai-core /api/v1/internal/reminder-trigger
- ai-core 用 LLM 生成温柔俏皮的提醒语,通过 messagePusher 推送
- Thinker 新增 TriggerReminderMessage 方法
- 环境变量: AI_CORE_URL (Gateway 已有)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 21:04:21 +08:00
AskaEth 2b17bb5b03 feat: 同平台多账号支持 — adapter_name 贯穿全链路
- UnifiedMessage 新增 AdapterName 字段
- router 从适配器 ConfigName() 捕获 adapter 实例名
- forwardToAICore source 新增 adapter_name
- ai-core SetBotUID key 优先用 adapter_name,回退 platform
- 更新优化大纲加入 P4.1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:53:40 +08:00
AskaEth 41f653b672 refactor: QQ → OBv11 重命名 + 平台格式统一抽象
- 所有对外称呼从 QQ 改为 OBv11(注释/提示词/日志/配置项)
- 新增 PlatformFormat 结构体,统一管理平台消息标记格式
- defaultPlatformFormats() 注册表替代硬编码 qqTargetRe
- extractProactiveMessage 改为 Thinker 方法,遍历格式注册表匹配
- 配置项重命名: QQ_BOT_PORT → OBV11_BOT_PORT, QQBotPort → OBv11BotPort
- 标记格式: 【QQ群聊】→【OBv11群聊】、【QQ私聊】→【OBv11私聊】

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:48:07 +08:00
AskaEth 70dbb23234 chore: 同步 .env.example — 补全所有代码引用的环境变量
- 新增: 后台思考分层 (LIGHT_THINK_*), 平台桥接, 知识库/人格路径, Gateway URL 等
- 移除: TOOL_ENGINE_URL (已废弃), 过时的 VECTOR_DB 相关变量
- 按分类重新组织: 服务/数据库/LLM/思考/平台/OS环境等

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:41:36 +08:00
AskaEth a46b5f6238 feat: 自主思考分层 — 轻量思考(简单)+深度思考(复杂)双模型
- 新增轻量思考循环 lightThinkLoop (默认60s间隔,快速模型)
- 轻量思考用极简 prompt 做快速状态检查
- 检测到「需要深思」时自动唤醒深度思考 performThink("light_wake")
- 环境变量: LIGHT_THINK_ENABLED / LIGHT_THINK_INTERVAL_SEC
- 轻量思考跳过活跃用户 (30s内有消息) 和重复触发 (10s内)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:39:20 +08:00
AskaEth 485d845f3e feat: LLM可自主操作提醒 — reminder_create/list/delete 工具
- 新增 GatewayClient HTTP 客户端,调用 Gateway /api/v1/reminders API
- 新增 3 个提醒工具:reminder_create / reminder_list / reminder_delete
- main.go 注册提醒工具,需 GATEWAY_URL + INTERNAL_SERVICE_TOKEN 环境变量
- AutonomousToolPolicy 白名单加入提醒工具,允许自主思考中创建提醒

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:37:27 +08:00
AskaEth 7e475bdfa6 feat: 子会话结果增加创建/结束时间戳
- SubSessionResult 新增 CreatedAt / FinishedAt 字段
- manager.Dispatch 在各路径统一设置时间戳:成功/失败/超时

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:31:48 +08:00
AskaEth 8783cadf9b feat: 思考追加机制 — 所有思考类型支持【继续思考】标记循环
- performThink 改为循环执行,最多3轮
- LLM输出含【继续思考】时自动追加一轮
- post_chat/silence/default三种提示词均告知可用此标记
- 存储前剥离控制标记,不污染思考内容

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:29:17 +08:00
AskaEth cadd5f2233 feat: QQ主动消息管道 + 戳一戳事件 + thinker提示词优化
- ProactiveTarget 结构体 + QQ目标正则解析(extractProactiveMessage 返回双值)
- storeThought 双路推送:platform消息 → platformMessagePusher,web消息 → messagePusher
- platform-bridge: SendProactive接口 + ProactiveSender + /api/v1/internal/send-proactive端点
- QQ戳一戳/notice事件 → noticeToUnified → ContentType "action"
- thinker提示词注入QQ频道上下文:群名称(群号)、活跃时间、trigger-aware引导
- SetBotUID/SetPlatformMessagePusher/AddOrUpdatePlatformChannel 方法
- ai-core source 增加 BotUID/GroupName,platform-bridge source 增加 bot_uid/group_name
- 支持 [CQ:at,qq=xxx] @提及标签自动拼接

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:26:36 +08:00
AskaEth cd053194a9 fix: RAG IngestDirectory 跳过 persona.md 和 _index.md
避免人格文档在RAG检索中重复出现,persona.md已通过系统提示注入。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:26:28 +08:00
AskaEth dbd89f39ab feat: 人格文档双层架构 — persona.md全量注入system prompt + RAG黑名单过滤
- PersonaConfig 新增 NarrativePersona 字段,在 system prompt 的「你的身份」和「你的性格」之间插入「## 你的叙事人格」
- Loader.NewLoader 自动读取同目录 persona.md 注入所有 config;Reload 同步热更新
- RAG IngestDirectory 跳过 persona.md 和 _index.md,避免重复检索浪费token
- 新增 docs/dev-plan/12-next-phase-optimization.md 下一阶段优化大纲

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 20:25:45 +08:00
AskaEth 6ef9e082a6 feat: 语音流式输入管线 + VAD前端集成 + 插件-工具合并清理
- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST)
- Gateway: VoiceStreamManager代理WS流式STT到voice-service
- Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码
- 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端)
- 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并
- 文档: 完善gateway-api.md和voice-service.md语音API文档
- 工具: scripts/voice/ 语音转换脚本集

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 11:50:40 +08:00
AskaEth 258cf81b25 chore: 添加昔涟语音模型目录到 .gitignore
data/cyrene_voice/ + models/cyrene_voice/ + voice-service 模型路径
关联仓库: Cyrene-Voice-Model

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:58:36 +08:00
AskaEth 4954c1e58b feat: 消息并行处理 + QQ卡片完整解析 + 视觉OCR融合格式修复
- platform-bridge: 8-worker per-session 并行分发,同会话保序跨会话并行
- platform-bridge: 静默消息 fire-and-forget,不阻塞同用户后续消息
- QQ卡片: html.UnescapeString 解码 NapCat HTML实体,正确解析卡片JSON
- QQ卡片: 输出含应用名/简介/来源/封面URL,封面注入图片管线走视觉
- ai-core: 视觉+OCR结果融合为单句,单图不编号,避免LLM误解为多张图

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 09:08:03 +08:00
AskaEth 67b204b23c fix: 对话历史上限增至100条 + filterActions 兼容 <app> 动作标签
- 会话历史存储上限从 50 条增至 100 条
- filterActions 同时处理 <action> 和 <app> 标签(DeepSeek 偶用 <app> 替代 <action>)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:31:48 +08:00
AskaEth b085e58031 fix: 后台思考身份混淆 + 静默模式视觉理解 + QQ卡片解析 + 仪表盘状态修复
- 后台思考对话历史增加标签说明,严格区分群聊中不同发送者
- 静默观察模式传入图片URL并预处理,供后台思考参考
- 视觉+OCR双模型结果合并格式优化,避免LLM误认为多张图片
- QQ卡片消息(CQ:json)正确解析标题/类型,不再丢失为[JSON]
- 进程管理器stop()在进程为null时重置pid/startTime,消除矛盾状态

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:07:25 +08:00
AskaEth a9c79d7887 feat: ASR语音转写管线 + 群聊身份混淆修复
- 新增ASR语音识别管线: QQ语音→下载音频→qwen3-asr-flash转录→注入用户消息
- 模型名称全部从models.json路由获取,无硬编码
- 修复群聊中AI将非管理员用户误称为管理员昵称(叶酱)的问题
  - 助手回复缓存时标注[回复 昵称 (UID)],防止对话历史中身份混淆
  - 群聊上下文指令改为肯定性表述,移除具体名称提及
- trace面板时间戳改为YYYY-MM-DD HH:MM:SS格式,耗时统一显示为秒
- 修复Go time.Duration纳秒值在前端显示问题(Duration/1e6转毫秒)
- 新增video_tool插件模板
- 优化OpenAI adapter reasoning_content处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:46:47 +08:00
AskaEth d112fdd540 fix: 思考对话历史中识别管理员 — 含【管理员】标记的消息标签为'管理员'而非'用户'
之前所有非昔涟的消息都标为[用户],管理员在群聊中的发言也不例外,
导致模型将管理员误认为普通群友。现在检测内容中的【管理员】前缀。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:11:10 +08:00
AskaEth e5f8e42a78 fix: removeCodeBlocks 给每行末尾追加 \n 导致发送消息多一个换行
改为用 strings.Join 拼接,只在行间加 \n,末行不追加。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:05:52 +08:00
AskaEth 7e29be8ae3 fix: 后台思考记忆来源感知 — 不再将所有记忆标为'关于开拓者的事'
两个改动:
1. 提示词中将「你记得的关于开拓者的事」改为「你近期收集到的信息」,
   并注明记忆可能来自不同对话和群聊,不都与开拓者有关
2. 思考时除管理员个人记忆外,额外检索各平台频道的群聊记忆,
   以 [群聊123] 前缀标注来源,让模型能区分信息来源

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:58:39 +08:00
AskaEth eef21fc91a fix: 热重载时 nil pointer panic — ReadMessages 中 Disconnect 与错误处理竞态
热重载流程:ReplaceAdapter → old.Disconnect() 将 a.conn 置 nil →
旧的 ReadMessages goroutine 中 conn.ReadMessage() 因连接关闭返回错误 →
错误处理中 a.conn.Close() 对 nil 指针调用导致 panic。

修复:Close 前检查 a.conn != nil。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:46:05 +08:00
AskaEth 465fa4307f fix: QQ发送消息时剥离Markdown格式 — 防止**粗体**等标记泄露到聊天
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:35:29 +08:00
AskaEth 3ad728406e fix: 消息日志增强 + 历史消息抑制 + SSE实时追踪 + 群聊上下文优化
- 日志:收/发消息均显示群名称,管理员显示真实QQ昵称而非"开拓者"
- 历史消息:服务重启后NapCat回放的历史消息不再触发回复,静默注入上下文
- 消息时间戳:转发给AI时附带【消息时间: HH:MM:SS (XmXs前)】标记
- ♪ 分割符:QQ消息支持♪作为句子断点
- AI-Core SSE端点:全链路追踪实时推送,ethend不再5秒轮询
- 群聊上下文:AI-Core明确被告知消息来自群聊,以实际发送者为主语

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 11:49:36 +08:00
AskaEth 677385ec17 fix: 平台消息身份传递 — AI-Core 收到正确昵称而非永远 fallback 到管理员
- forwardToAICore 新增 nickname 字段,格式 "昵称 (QQ号)" 明确标识发送者
- 解决非管理员用户 @昔涟 时 AI 仍认为是管理员的身份污染问题
- 同时包含:管理员群聊插入抑制、markdown 粗体剥离、SearXNG 容器、ethend 窗口隐藏

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 10:17:27 +08:00
AskaEth 47dce276a4 fix: platform_silent记忆提取 + 群聊上下文整合 + 多QQ实例支持
- platform_silent模式接入Orchestrator记忆提取:被动观察群聊时提取值得记住的信息到对应命名空间
- post_chat后台思考注入平台观察:对话后思考也能看到群聊摘要
- QQ适配器:OneBot v11 self_id动态捕获、CQ图片URL提取、视觉+OCR并行处理
- Router解耦:ConfigName/PlatformName分离,支持多QQ实例独立连接
- 黑白名单功能:后端API + Ethend代理 + UI面板
- \n\n双换行断句:AI回复按双换行分割为多条消息按间隔发送
- @提及修复:bot自感知UID进行@检测
- 群聊上下文共享:channel-based userID避免记忆碎片化
- 消息日志显示处理后内容而非原始SSE数据
- platform-bridge Dockerfile + docker-compose.yml更新

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:37:18 +08:00
182 changed files with 14722 additions and 6890 deletions
+58 -45
View File
@@ -8,11 +8,7 @@ POSTGRES_PORT=5432
POSTGRES_USER=cyrene POSTGRES_USER=cyrene
POSTGRES_PASSWORD=cyrene_pass POSTGRES_PASSWORD=cyrene_pass
POSTGRES_DB=cyrene_ai POSTGRES_DB=cyrene_ai
POSTGRES_SSLMODE=disable
# ========== Redis ==========
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# ========== LLM API ========== # ========== LLM API ==========
LLM_API_URL=https://api.openai.com/v1 LLM_API_URL=https://api.openai.com/v1
@@ -20,6 +16,49 @@ LLM_API_KEY=sk-xxxxx
LLM_MODEL=gpt-4o LLM_MODEL=gpt-4o
LLM_FALLBACK_MODEL=gpt-4o-mini LLM_FALLBACK_MODEL=gpt-4o-mini
# ========== LLM 模型路由配置 (可选,优先于 LLM_* 环境变量) ==========
# MODELS_CONFIG_PATH=./models.json
# ========== 后台思考 ==========
ENABLE_BACKGROUND_THINKING=true
# --- 深度思考(复杂) ---
THINK_INTERVAL_SEC=300
THINK_MIN_GAP_SEC=30
THINK_OFFLINE_GAP_SEC=600
THINK_SILENCE_TIMEOUT_SEC=120
THINK_POST_CHAT_DELAY_SEC=5
# --- 轻量思考(简单) ---
LIGHT_THINK_ENABLED=true
LIGHT_THINK_INTERVAL_SEC=60
# --- 主动消息 ---
PROACTIVE_MSG_MIN_GAP_SEC=0
# --- 思考调度 (可选) ---
# THINKING_SCHEDULE_PATH=./thinking_schedule.json
# ========== 平台接入 ==========
# --- 平台观察 ---
PLATFORM_CHANNELS=obv11:group:群号
PLATFORM_THINK_INTERVAL_SEC=600
# --- 平台桥接 ---
PLATFORM_BRIDGE_URL=http://localhost:8095
# ========== 后端微服务地址 ==========
GATEWAY_URL=http://localhost:8080
MEMORY_SERVICE_URL=http://localhost:8091
IOT_SERVICE_URL=http://localhost:8083
# IOT_DEBUG_SERVICE_URL=http://localhost:8083
AI_CORE_PORT=8081
# ========== 知识库 & 人格 ==========
PERSONA_DIR=./internal/persona
KNOWLEDGE_DIR=./data/knowledge
# ========== 工作目录 ==========
DATA_DIR=./data
# ========== 搜索 (SearXNG) ==========
# SEARXNG_URL=http://localhost:8888
# ========== DashScope STT (语音识别) ========== # ========== DashScope STT (语音识别) ==========
DASHSCOPE_API_KEY=sk-xxxxx DASHSCOPE_API_KEY=sk-xxxxx
DASHSCOPE_STT_MODEL=qwen3-asr-flash-2026-02-10 DASHSCOPE_STT_MODEL=qwen3-asr-flash-2026-02-10
@@ -31,20 +70,12 @@ TTS_VOICE=zh-CN-XiaoxiaoNeural
ASR_PROVIDER=faster-whisper ASR_PROVIDER=faster-whisper
ASR_MODEL=medium ASR_MODEL=medium
# ========== 文件存储 ========== # ========== 管理员账户 ==========
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=cyrene-assets
# ========== 管理员账户 (开发阶段使用) ==========
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-admin-password ADMIN_PASSWORD=your-admin-password
# ========== 管理员昵称 (昔涟对用户的基本称呼) ==========
ADMIN_NICKNAME=管理员 ADMIN_NICKNAME=管理员
# ========== 注册开关 (开发环境建议开启) ========== # ========== 注册开关 ==========
REGISTRATION_ENABLED=true REGISTRATION_ENABLED=true
# ========== JWT ========== # ========== JWT ==========
@@ -54,51 +85,33 @@ JWT_EXPIRY_HOURS=720
# ========== 内部服务认证 ========== # ========== 内部服务认证 ==========
INTERNAL_SERVICE_TOKEN=your-internal-token-change-in-production INTERNAL_SERVICE_TOKEN=your-internal-token-change-in-production
# ========== IoT 调试服务 ==========
IOT_SERVICE_URL=http://localhost:8083
# ========== 后端微服务地址 ==========
MEMORY_SERVICE_URL=http://localhost:8091
TOOL_ENGINE_URL=http://localhost:8092
VOICE_SERVICE_URL=http://localhost:8093
# ========== 后台思考 ==========
ENABLE_BACKGROUND_THINKING=true
THINK_OFFLINE_GAP_SEC=600
# ========== Webhook (第三方平台接入) ==========
WEBHOOK_API_KEY=your-webhook-api-key
# ========== CORS 跨域白名单 (逗号分隔) ==========
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5199,http://localhost:3000
# ========== 记忆系统 ==========
MEMORY_FILE_PATH=./data/memory
VECTOR_DB_URL=http://localhost:6333
VECTOR_DB_COLLECTION=cyrene_memories
# ========== 完整 OS 环境 (供 os_exec/os_file/os_system 工具) ========== # ========== 完整 OS 环境 (供 os_exec/os_file/os_system 工具) ==========
# 后端选择: direct (默认,仅沙箱), wsl (WSL2 完整Linux), docker (Docker容器)
HOST_EXEC_BACKEND=wsl HOST_EXEC_BACKEND=wsl
WSL_DISTRO=Ubuntu-22.04 WSL_DISTRO=Ubuntu-22.04
# WSL 内自动创建的用户 (首次调用时自动创建,已存在则跳过)
WSL_USER=cyrene WSL_USER=cyrene
WSL_USER_PASSWORD=cyrene WSL_USER_PASSWORD=cyrene
SANDBOX_CONTAINER=cyrene-sandbox SANDBOX_CONTAINER=cyrene-sandbox
SANDBOX_IMAGE=ubuntu:22.04 SANDBOX_IMAGE=ubuntu:22.04
HOST_EXEC_MAX_TIMEOUT=300 HOST_EXEC_MAX_TIMEOUT=300
# ========== Docker 反向代理端口 ========== # ========== CORS 跨域白名单 (逗号分隔) ==========
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5199,http://localhost:3000
# ========== Webhook (第三方平台接入) ==========
WEBHOOK_API_KEY=your-webhook-api-key
# ========== 时区 ==========
TZ=Asia/Shanghai
# ========== Docker 反向代理 ==========
CADDY_HTTP_PORT=80 CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443 CADDY_HTTPS_PORT=443
# ========== 域名与 HTTPSDocker 生产环境有域名时填写) ==========
DOMAIN= DOMAIN=
ACME_EMAIL=admin@example.com ACME_EMAIL=admin@example.com
# ========== 管理控制台端口 (ethend) ========== # ========== 管理控制台 (ethend) ==========
ETHEND_PORT=9090 ETHEND_PORT=9090
# ========== WebSocket 最大连接数 ========== # ========== WebSocket ==========
WS_MAX_CONNECTIONS=1000 WS_MAX_CONNECTIONS=1000
SESSION_IDLE_TIMEOUT_MIN=30 SESSION_IDLE_TIMEOUT_MIN=30
+16
View File
@@ -1,10 +1,19 @@
# ========== 依赖 ========== # ========== 依赖 ==========
node_modules/ node_modules/
# ========== 测试 ==========
test/
# ========== 构建产物 ========== # ========== 构建产物 ==========
dist/ dist/
*.exe *.exe
# ========== 子仓库 ==========
backend/cyrene-plugins/
# ========== 用户插件(独立项目,不进主仓库) ==========
backend/plugins/
# ========== Go 编译二进制 ========== # ========== Go 编译二进制 ==========
backend/ai-core/main backend/ai-core/main
backend/ai-core/cmd/main backend/ai-core/cmd/main
@@ -48,6 +57,8 @@ backend/.env
models.json models.json
thinking_schedule.json thinking_schedule.json
platform_configs.json platform_configs.json
platform_blocklist.json
*.exe~
.claude/ .claude/
# ========== 文档 (项目规范:docs/ 不纳入版本管理,docs/api/ 为例外) ========== # ========== 文档 (项目规范:docs/ 不纳入版本管理,docs/api/ 为例外) ==========
@@ -68,6 +79,11 @@ ethend/package-lock.json
backend/voice-service/whisper.cpp/ backend/voice-service/whisper.cpp/
backend/voice-service/models/ backend/voice-service/models/
# ========== 昔涟语音模型 (独立仓库 Cyrene-Voice-Model) ==========
data/cyrene_voice/
models/cyrene_voice/
backend/voice-service/models/cyrene/
# ========== 打包归档 ========== # ========== 打包归档 ==========
*.tar.gz *.tar.gz
*.zip *.zip
+1
View File
@@ -0,0 +1 @@
3.14
+1 -1
View File
@@ -139,7 +139,7 @@ Cyrene/
│ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、自动提取) │ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、自动提取)
│ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS) │ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS)
│ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备) │ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备)
│ └── pkg/ # 共享包 (logger, plugins — 15 个通用插件/工具) │ └── pkg/ # 共享包 (logger)
├── ethend/ # ethend 管理面板 (Express + WebSocket) ├── ethend/ # ethend 管理面板 (Express + WebSocket)
├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup / pg-backup) ├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup / pg-backup)
├── searxng/ # SearXNG 搜索引擎配置 ├── searxng/ # SearXNG 搜索引擎配置
+9 -4
View File
@@ -67,6 +67,10 @@
- Node.js 20 LTS - Node.js 20 LTS
- Docker & Docker Compose - Docker & Docker Compose
- Git BashWindows 用户) - Git BashWindows 用户)
- [cyrene-plugins](https://git.yeij.top/AskaEth/Cyrene-Plugins) — 克隆到 `backend/` 目录内:
```bash
git clone https://git.yeij.top/AskaEth/Cyrene-Plugins.git backend/cyrene-plugins
```
### 1. 配置环境变量 ### 1. 配置环境变量
@@ -132,9 +136,8 @@ Cyrene/
│ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、LLM 提取) │ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、LLM 提取)
│ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS) │ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS)
│ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备) │ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备)
│ ├── plugin-manager/ # 插件管理器 (管理 API,插件逻辑在 pkg/plugins)
│ ├── platform-bridge/ # 多平台桥接 (QQ / Telegram / Discord / Webhook) │ ├── platform-bridge/ # 多平台桥接 (QQ / Telegram / Discord / Webhook)
│ └── pkg/ # 共享包 (logger, plugins — 15 个通用插件/工具) │ └── pkg/ # 共享包 (logger)
├── ethend/ # ethend 管理面板 (Express + WebSocket) ├── ethend/ # ethend 管理面板 (Express + WebSocket)
├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup / pg-backup) ├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup / pg-backup)
├── backups/ # 数据库备份文件 (.gitignore) ├── backups/ # 数据库备份文件 (.gitignore)
@@ -150,6 +153,8 @@ Cyrene/
└── Caddyfile # 反向代理配置 └── Caddyfile # 反向代理配置
``` ```
> **关联仓库**[cyrene-plugins](https://git.yeij.top/AskaEth/Cyrene-Plugins) — 插件 SDK + 15 个内置插件 + Plugin Manager 服务。克隆到 `backend/cyrene-plugins/`ai-core 通过 go.mod replace 引用。
--- ---
## 服务端口 ## 服务端口
@@ -202,8 +207,8 @@ Cyrene/
| [docs/api/gateway-api.md](docs/api/gateway-api.md) | 客户端 API 文档 | | [docs/api/gateway-api.md](docs/api/gateway-api.md) | 客户端 API 文档 |
| [docs/api/ethend.md](docs/api/ethend.md) | ethend CLI + Web 控制台文档 | | [docs/api/ethend.md](docs/api/ethend.md) | ethend CLI + Web 控制台文档 |
| [docs/api/backend-services/](docs/api/backend-services/) | 后端服务 API 文档 | | [docs/api/backend-services/](docs/api/backend-services/) | 后端服务 API 文档 |
| [docs/dev_must_read.md](docs/dev_must_read.md) | 开发者必读 | | [docs/api/backend-services/tool-system.md](docs/api/backend-services/tool-system.md) | 工具调用系统 |
| [docs/pg-backup-migration.md](docs/pg-backup-migration.md) | PG 备份与迁移指南 | | [docs/api/plugin-development.md](docs/api/plugin-development.md) | 插件开发指南 |
--- ---
+1 -1
View File
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /ai-core ./cmd/main.go
# ========== 运行阶段 ========== # ========== 运行阶段 ==========
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \ RUN apk add --no-cache ca-certificates tzdata ffmpeg && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone echo "Asia/Shanghai" > /etc/timezone
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
)
// syncPlatformChannels periodically fetches channel info from platform-bridge
// and updates the thinker's platform channel list and bot UIDs.
func syncPlatformChannels(thinker *background.Thinker, platformBridgeURL, internalToken string) {
client := &http.Client{Timeout: 15 * time.Second}
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
// Run once immediately.
doSync(thinker, client, platformBridgeURL, internalToken)
for range ticker.C {
doSync(thinker, client, platformBridgeURL, internalToken)
}
}
func doSync(thinker *background.Thinker, client *http.Client, baseURL, token string) {
req, err := http.NewRequest("GET", baseURL+"/api/v1/channels", nil)
if err != nil {
return
}
req.Header.Set("X-Internal-Token", token)
resp, err := client.Do(req)
if err != nil {
log.Printf("[channel-sync] 请求失败: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[channel-sync] 返回 %d", resp.StatusCode)
return
}
var result struct {
Channels []struct {
Platform string `json:"platform"`
ChannelType string `json:"channel_type"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name,omitempty"`
AdapterID string `json:"adapter_id,omitempty"`
AdapterName string `json:"adapter_name,omitempty"`
} `json:"channels"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Printf("[channel-sync] 解析失败: %v", err)
return
}
for _, ch := range result.Channels {
thinker.AddOrUpdatePlatformChannel(ch.Platform, ch.ChannelType, ch.ChannelID, ch.ChannelName, ch.AdapterID, ch.AdapterName)
}
// Also fetch platforms to get bot UIDs.
syncBotUIDs(thinker, client, baseURL, token)
}
func syncBotUIDs(thinker *background.Thinker, client *http.Client, baseURL, token string) {
req, err := http.NewRequest("GET", baseURL+"/api/v1/platforms", nil)
if err != nil {
return
}
req.Header.Set("X-Internal-Token", token)
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var result []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
// Bot UIDs are set by incoming messages via SetBotUID.
// This sync just ensures channels are populated.
_ = result
}
+249
View File
@@ -0,0 +1,249 @@
//go:build ignore
// gen_plugins generates plugins_gen.go from:
// 1. plugins.json — built-in plugins (in cyrene-plugins)
// 2. ../plugins/*/plugin.json — user plugins (auto-discovered)
//
// Usage: go run gen_plugins.go
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
type PluginEntry struct {
Name string `json:"name"`
Import string `json:"import,omitempty"` // built-in: explicit import
Struct string `json:"struct"`
Constructor string `json:"constructor,omitempty"`
}
type PluginManifest struct {
Name string `json:"name"`
Struct string `json:"struct"`
}
// ── load built-in plugins from plugins.json ──
func loadBuiltinPlugins() ([]PluginEntry, error) {
data, err := os.ReadFile("../plugins.json")
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read plugins.json: %w", err)
}
var cfg struct {
Version string `json:"version"`
Plugins []PluginEntry `json:"plugins"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse plugins.json: %w", err)
}
return cfg.Plugins, nil
}
// ── auto-discover user plugins from ../plugins/ ──
func discoverUserPlugins() ([]PluginEntry, error) {
pluginsDir := filepath.Join("..", "..", "plugins")
entries, err := os.ReadDir(pluginsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read plugins dir: %w", err)
}
var result []PluginEntry
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(pluginsDir, e.Name())
// 必须有 plugin.json
manifestPath := filepath.Join(dir, "plugin.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
continue
}
var m PluginManifest
if err := json.Unmarshal(manifestData, &m); err != nil {
fmt.Fprintf(os.Stderr, "⚠ skip %s: bad plugin.json: %v\n", e.Name(), err)
continue
}
if m.Name == "" {
m.Name = e.Name()
}
// 从 go.mod 读取模块路径作为 import
modPath := filepath.Join(dir, "go.mod")
modData, err := os.ReadFile(modPath)
if err != nil {
fmt.Fprintf(os.Stderr, "⚠ skip %s: no go.mod: %v\n", e.Name(), err)
continue
}
importPath := parseModule(modData)
if importPath == "" {
fmt.Fprintf(os.Stderr, "⚠ skip %s: cannot parse module from go.mod\n", e.Name())
continue
}
result = append(result, PluginEntry{
Name: m.Name,
Import: importPath,
Struct: m.Struct,
})
fmt.Printf(" ✓ discovered %s → %s\n", m.Name, importPath)
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result, nil
}
func parseModule(data []byte) string {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
return strings.TrimSpace(strings.TrimPrefix(line, "module "))
}
}
return ""
}
// ── generate plugins_gen.go ──
func generate(plugins []PluginEntry) error {
var sb strings.Builder
sb.WriteString("// Code generated by gen_plugins.go; DO NOT EDIT.\n\n")
sb.WriteString("package main\n\n")
sb.WriteString("import (\n")
sb.WriteString("\tplgSDK \"git.yeij.top/AskaEth/Cyrene-Plugins/sdk\"\n")
aliases := make(map[string]string)
imported := make(map[string]bool)
for _, p := range plugins {
pkgAlias := p.Name + "pkg"
aliases[p.Name] = pkgAlias
if !imported[p.Import] {
sb.WriteString(fmt.Sprintf("\t%s \"%s\"\n", pkgAlias, p.Import))
imported[p.Import] = true
}
}
sb.WriteString(")\n\n")
sb.WriteString("func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) {\n")
for _, p := range plugins {
alias := aliases[p.Name]
if p.Constructor != "" {
if p.Name == "file_ops" || p.Name == "http_request" {
sb.WriteString(fmt.Sprintf("\tfor _, t := range %s.%s(nil).Tools() {\n", alias, p.Constructor))
} else {
sb.WriteString(fmt.Sprintf("\tfor _, t := range %s.%s().Tools() {\n", alias, p.Constructor))
}
} else {
sb.WriteString(fmt.Sprintf("\tfor _, t := range (&%s.%s{}).Tools() {\n", alias, p.Struct))
}
sb.WriteString("\t\tregistry.Register(t)\n")
sb.WriteString("\t}\n")
}
sb.WriteString("}\n")
outPath := "plugins_gen.go"
if err := os.WriteFile(outPath, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("write %s: %w", outPath, err)
}
fmt.Printf("Generated %s with %d plugins\n", outPath, len(plugins))
return nil
}
// ── auto-add require + replace directives to go.mod ──
func ensureGoMod(userPlugins []PluginEntry) error {
modPath := filepath.Join("..", "go.mod")
data, err := os.ReadFile(modPath)
if err != nil {
return fmt.Errorf("read go.mod: %w", err)
}
content := string(data)
pluginsDir := filepath.Join("..", "..", "plugins")
entries, _ := os.ReadDir(pluginsDir)
for _, e := range entries {
if !e.IsDir() { continue }
dir := filepath.Join(pluginsDir, e.Name())
modData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil { continue }
mod := parseModule(modData)
if mod == "" { continue }
relPath, _ := filepath.Rel(filepath.Join(".."), dir)
relPath = strings.ReplaceAll(relPath, "\\", "/")
// ensure require
reqLine := fmt.Sprintf("\t%s v0.0.0\n", mod)
if !strings.Contains(content, reqLine) {
reqBlock := strings.Index(content, "require (")
if reqBlock < 0 { continue }
closeIdx := strings.Index(content[reqBlock:], ")")
if closeIdx < 0 { continue }
insertAt := reqBlock + closeIdx
content = content[:insertAt] + reqLine + content[insertAt:]
}
// ensure replace
replaceLine := fmt.Sprintf("\t%s => %s\n", mod, relPath)
if !strings.Contains(content, replaceLine) {
replaceBlock := strings.Index(content, "replace (")
if replaceBlock < 0 {
content += fmt.Sprintf("\nreplace (\n%s)\n", replaceLine)
} else {
closeIdx := strings.Index(content[replaceBlock:], ")")
if closeIdx < 0 { continue }
insertAt := replaceBlock + closeIdx
content = content[:insertAt] + replaceLine + content[insertAt:]
}
}
}
return os.WriteFile(modPath, []byte(content), 0644)
}
func main() {
builtins, err := loadBuiltinPlugins()
if err != nil {
fmt.Fprintf(os.Stderr, "load builtins: %v\n", err)
os.Exit(1)
}
fmt.Printf("Built-in plugins: %d\n", len(builtins))
fmt.Println("Discovering user plugins...")
userPlugins, err := discoverUserPlugins()
if err != nil {
fmt.Fprintf(os.Stderr, "discover user plugins: %v\n", err)
os.Exit(1)
}
// 自动补 go.mod replace 指令
if len(userPlugins) > 0 {
if err := ensureGoMod(userPlugins); err != nil {
fmt.Fprintf(os.Stderr, "ensure go.mod: %v\n", err)
}
}
all := append(builtins, userPlugins...)
if err := generate(all); err != nil {
fmt.Fprintf(os.Stderr, "generate: %v\n", err)
os.Exit(1)
}
}
+498 -50
View File
@@ -1,3 +1,5 @@
//go:generate go run gen_plugins.go
package main package main
import ( import (
@@ -7,7 +9,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"runtime"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -17,6 +21,7 @@ import (
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
aiConfig "git.yeij.top/AskaEth/Cyrene/ai-core/internal/config" aiConfig "git.yeij.top/AskaEth/Cyrene/ai-core/internal/config"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/crashlog"
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context" ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/host" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/host"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
@@ -28,26 +33,23 @@ import (
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/subsession" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/subsession"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager" plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
plgSDK "git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk" plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
pluginCalc "git.yeij.top/AskaEth/Cyrene/pkg/plugins/calculator" pluginFile "git.yeij.top/AskaEth/Cyrene-Plugins/file"
pluginCrypto "git.yeij.top/AskaEth/Cyrene/pkg/plugins/crypto" pluginHTTP "git.yeij.top/AskaEth/Cyrene-Plugins/http"
pluginDate "git.yeij.top/AskaEth/Cyrene/pkg/plugins/datetime" pluginWF "git.yeij.top/AskaEth/Cyrene-Plugins/web_fetch"
pluginFile "git.yeij.top/AskaEth/Cyrene/pkg/plugins/file"
pluginHTTP "git.yeij.top/AskaEth/Cyrene/pkg/plugins/http"
pluginJSON "git.yeij.top/AskaEth/Cyrene/pkg/plugins/json"
pluginMD "git.yeij.top/AskaEth/Cyrene/pkg/plugins/markdown"
pluginRand "git.yeij.top/AskaEth/Cyrene/pkg/plugins/random"
pluginText "git.yeij.top/AskaEth/Cyrene/pkg/plugins/text"
pluginWF "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_fetch"
pluginWS "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_search"
) )
var cfg Config var cfg Config
func main() { func main() {
// 自动加载 .env 文件(来自仓库根目录) // 自动加载 .env 文件(优先从可执行文件位置反推仓库根目录)
if err := godotenv.Load("../../.env"); err != nil { _ = godotenv.Load() // 先尝试当前目录
if exe, err := os.Executable(); err == nil {
_ = godotenv.Load(filepath.Join(filepath.Dir(exe), "..", "..", ".env"))
}
// 兜底:如果 LLM_MODEL 仍未设置,打印提示
if os.Getenv("LLM_MODEL") == "" {
log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值") log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
} }
@@ -127,7 +129,7 @@ func main() {
} }
// 初始化会话历史存储 // 初始化会话历史存储
convStore := ctxbuild.NewConversationStore(50) convStore := ctxbuild.NewConversationStore(100)
// 从数据库恢复主会话历史(避免重启丢失上下文) // 从数据库恢复主会话历史(避免重启丢失上下文)
adminUserID := "admin" adminUserID := "admin"
@@ -152,6 +154,12 @@ func main() {
log.Println("IoT 客户端未配置 (IOT_SERVICE_URL 和 IOT_DEBUG_SERVICE_URL 均为空)") log.Println("IoT 客户端未配置 (IOT_SERVICE_URL 和 IOT_DEBUG_SERVICE_URL 均为空)")
} }
// 初始化 Gateway HTTP 客户端(提醒/日程等工具用)
gwBaseURL := getEnv("GATEWAY_URL", "http://localhost:8080")
gwToken := os.Getenv("INTERNAL_SERVICE_TOKEN")
gwClient := tools.NewGatewayClient(gwBaseURL, gwToken)
log.Printf("Gateway 客户端已就绪: %s", gwBaseURL)
// 初始化主机操控管理器 (沙箱执行 + 文件系统隔离) // 初始化主机操控管理器 (沙箱执行 + 文件系统隔离)
hostSandbox := host.NewSandbox(host.DefaultSandboxConfig()) hostSandbox := host.NewSandbox(host.DefaultSandboxConfig())
directBackend := host.NewDirectBackend(hostSandbox) directBackend := host.NewDirectBackend(hostSandbox)
@@ -179,25 +187,19 @@ func main() {
toolRegistry := plgManager.NewToolRegistry() toolRegistry := plgManager.NewToolRegistry()
var visionProvider llm.LLMProvider var visionProvider llm.LLMProvider
var ocrProvider llm.LLMProvider var ocrProvider llm.LLMProvider
var videoProvider llm.LLMProvider
var asrProvider llm.ASRProvider
if getEnvBool("ENABLE_TOOLS", true) { if getEnvBool("ENABLE_TOOLS", true) {
// 11 个共享通用插件 — 注册其工具到统一注册中心 // 11 个共享通用插件 — 注册其工具到统一注册中心
registerPluginTools(toolRegistry, &pluginCalc.CalculatorPlugin{}) registerPlugins(toolRegistry)
registerPluginTools(toolRegistry, &pluginDate.DatetimePlugin{}) for _, t := range pluginFile.NewFilePlugin(dataDir).Tools() { toolRegistry.Register(t) }
registerPluginTools(toolRegistry, &pluginText.TextPlugin{}) for _, t := range pluginHTTP.NewHTTPPlugin().Tools() { toolRegistry.Register(t) }
registerPluginTools(toolRegistry, &pluginCrypto.CryptoPlugin{}) // 仅使用本地 SearXNG(国内 DuckDuckGo 不可用)
registerPluginTools(toolRegistry, &pluginRand.RandomPlugin{})
registerPluginTools(toolRegistry, &pluginMD.MarkdownPlugin{})
registerPluginTools(toolRegistry, &pluginJSON.JSONPlugin{})
registerPluginTools(toolRegistry, pluginFile.NewFilePlugin(dataDir))
registerPluginTools(toolRegistry, pluginHTTP.NewHTTPPlugin())
searxngURL := getEnv("SEARXNG_URL", "") searxngURL := getEnv("SEARXNG_URL", "")
if searxngURL != "" { if searxngURL != "" {
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPluginWithURL(searxngURL)) toolRegistry.Register(wrapTool(tools.NewWebSearchToolWithURL(searxngURL), "web_search", "Search the web via SearXNG", "search"))
} else {
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
} }
registerPluginTools(toolRegistry, pluginWF.NewWebFetchPlugin()) for _, t := range pluginWF.NewWebFetchPlugin().Tools() { toolRegistry.Register(t) }
// ai-core 专属工具 — 通过 sdk.Tool 适配器注册 // ai-core 专属工具 — 通过 sdk.Tool 适配器注册
if iotClient != nil { if iotClient != nil {
toolRegistry.Register(wrapTool(tools.NewIoTQueryTool(iotClient), "iot_query", "Query IoT Devices", "iot")) toolRegistry.Register(wrapTool(tools.NewIoTQueryTool(iotClient), "iot_query", "Query IoT Devices", "iot"))
@@ -216,6 +218,14 @@ func main() {
toolRegistry.Register(wrapTool(tools.NewOSSystemTool(osManager), "os_system", "OS System Info", "system")) toolRegistry.Register(wrapTool(tools.NewOSSystemTool(osManager), "os_system", "OS System Info", "system"))
} }
// 提醒/日程工具(需要 Gateway 客户端)
if gwClient != nil && gwToken != "" {
toolRegistry.Register(wrapTool(tools.NewReminderCreateTool(gwClient, adminUserID), "reminder_create", "Create Reminder", "reminder"))
toolRegistry.Register(wrapTool(tools.NewReminderListTool(gwClient, adminUserID), "reminder_list", "List Reminders", "reminder"))
toolRegistry.Register(wrapTool(tools.NewReminderDeleteTool(gwClient, adminUserID), "reminder_delete", "Delete Reminder", "reminder"))
log.Println("[工具] 提醒工具已注册 (reminder_create, reminder_list, reminder_delete)")
}
visionProvider = nil visionProvider = nil
if configLoader != nil && configLoader.HasConfig() { if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig() cfg := configLoader.GetConfig()
@@ -246,12 +256,52 @@ func main() {
} }
} }
} }
} }
if ocrProvider == nil { if ocrProvider == nil {
log.Println("OCR模型未配置,图片文字提取将复用视觉模型") log.Println("OCR模型未配置,图片文字提取将复用视觉模型")
} }
// 初始化视频理解模型
videoProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["video"]; ok && len(route.FallbackChain) > 0 {
for _, mid := range route.FallbackChain {
if _, ok := cfg.Models[mid]; ok {
videoProvider, _ = modelSelector.Select(context.Background(), llm.PurposeVideo)
log.Printf("视频理解模型已启用: %s", videoProvider.ModelName())
break
}
}
}
}
if videoProvider == nil {
log.Println("视频理解模型未配置")
}
// 初始化 ASR 语音识别模型
asrProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["speech_recognition"]; ok && len(route.FallbackChain) > 0 {
for _, mid := range route.FallbackChain {
if m, ok := cfg.Models[mid]; ok {
if p, ok := cfg.Providers[m.Provider]; ok {
asrProvider = llm.NewDashScopeASRProvider(p.BaseURL, p.APIKey, m.Name)
log.Printf("ASR语音识别模型已启用: %s", m.Name)
break
}
}
}
}
}
if asrProvider == nil {
log.Println("ASR语音识别模型未配置")
}
toolRegistry.Register(wrapTool(tools.NewVisionTool(visionProvider), "vision_analyze", "Image Vision Analysis & OCR", "multimodal")) toolRegistry.Register(wrapTool(tools.NewVisionTool(visionProvider), "vision_analyze", "Image Vision Analysis & OCR", "multimodal"))
toolRegistry.Register(wrapTool(tools.NewVideoTool(videoProvider), "video_analyze", "Video Understanding & Analysis", "multimodal"))
if knowledgeRetriever != nil { if knowledgeRetriever != nil {
toolRegistry.Register(wrapTool(tools.NewKnowledgeSearchTool(knowledgeRetriever), "knowledge_search", "Search Knowledge Base", "knowledge")) toolRegistry.Register(wrapTool(tools.NewKnowledgeSearchTool(knowledgeRetriever), "knowledge_search", "Search Knowledge Base", "knowledge"))
@@ -280,6 +330,7 @@ func main() {
convStore, convStore,
adminUserID, adminUserID,
adminSessionID, adminSessionID,
cfg.AdminNickname,
memClient, memClient,
) )
@@ -324,6 +375,38 @@ func main() {
} }
}) })
log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL) log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL)
// 设置平台主动消息推送回调(调用 Platform Bridge 内部 API
platformBridgeURL := getEnv("PLATFORM_BRIDGE_URL", "http://localhost:8095")
thinker.SetPlatformMessagePusher(func(target background.ProactiveTarget, message string) {
reqBody, _ := json.Marshal(map[string]string{
"platform": target.Platform,
"chat_type": target.ChatType,
"user_id": target.UserID,
"group_id": target.GroupID,
"at_user_id": target.AtUserID,
"content": message,
})
req, _ := http.NewRequest("POST",
platformBridgeURL+"/api/v1/internal/send-proactive",
strings.NewReader(string(reqBody)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", internalToken)
resp, err := proactiveClient.Do(req)
if err != nil {
log.Printf("[主动消息] 平台推送失败: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
log.Printf("[主动消息] 已推送到 Platform Bridge: target=%s/%s user=%s group=%s len=%d",
target.Platform, target.ChatType, target.UserID, target.GroupID, len(message))
} else {
log.Printf("[主动消息] Platform Bridge 返回 %d", resp.StatusCode)
}
})
log.Printf("[主动消息] 平台推送已启用 (PlatformBridge=%s)", platformBridgeURL)
go syncPlatformChannels(thinker, platformBridgeURL, internalToken)
} else { } else {
log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用") log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用")
} }
@@ -331,6 +414,9 @@ func main() {
// 健康检查与对话API的HTTP mux // 健康检查与对话API的HTTP mux
mux := http.NewServeMux() mux := http.NewServeMux()
// 注册全链路追踪事件端点
registerTraceEndpoint(mux)
// 初始化子会话管理器 // 初始化子会话管理器
subManager := subsession.NewManager(chatAdapter) subManager := subsession.NewManager(chatAdapter)
@@ -361,6 +447,81 @@ func main() {
memExtractor, memExtractor,
) )
orch.SetToolRegistry(toolRegistry) orch.SetToolRegistry(toolRegistry)
// 设置工具结果主动推送回调 — 通用,不绑定特定工具
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string, params orchestrator.SynthesizeParams) {
if thinker == nil || orch == nil {
return
}
if userID == "" {
userID = adminUserID
}
// 异步执行跟进:触发 LLM 生成回复并推送到原消息渠道
go func() {
var toolResult map[string]interface{}
if err := json.Unmarshal([]byte(result), &toolResult); err != nil {
toolResult = map[string]interface{}{"output": result}
}
output, _ := toolResult["output"].(string)
if output == "" {
output = result
}
followUpMsg := fmt.Sprintf("【系统消息】后台工具 %s 执行完成。结果:\n%s\n\n请基于以上结果生成回复发送给用户。", toolName, output)
// 持久化工具结果到会话历史(重启后不丢失)
ctxBuilder.CacheMessage(sessionID, model.RoleSystem,
fmt.Sprintf("[工具 %s 执行结果]\n%s", toolName, output))
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
UserID: userID,
SessionID: sessionID,
Message: followUpMsg,
Mode: "text",
Nickname: params.Nickname,
ChannelType: params.ChannelType,
ChannelID: params.ChannelID,
AdapterName: params.AdapterName,
})
if err != nil {
log.Printf("[tool-followup] ProcessInput 失败: %v", err)
return
}
var sb strings.Builder
for event := range eventCh {
if event.Type == model.StreamDelta {
sb.WriteString(event.Delta)
}
}
followUpResponse := sb.String()
if followUpResponse == "" {
return
}
// 推送到原平台渠道
if params.ChannelType != "" && params.ChannelID != "" && params.AdapterName != "" {
target := background.ProactiveTarget{
Platform: params.AdapterName,
ChatType: "private", // send-proactive 要求 private/group,不是 direct
}
if params.ChannelType == "group" {
target.ChatType = "group"
target.GroupID = params.ChannelID
} else {
target.UserID = strings.TrimPrefix(params.ChannelID, "private_")
}
log.Printf("[tool-followup] 推送跟进到 platform=%s chat=%s channel=%s len=%d",
target.Platform, target.ChatType, params.ChannelID, len(followUpResponse))
thinker.PushPlatformMessage(target, followUpResponse)
} else {
thinker.TriggerReminderMessage(userID, sessionID, followUpResponse)
}
}()
})
if visionProvider != nil { if visionProvider != nil {
orch.SetVisionProvider(visionProvider) orch.SetVisionProvider(visionProvider)
log.Printf("对话编排器: 视觉模型已注入 (%s)", visionProvider.ModelName()) log.Printf("对话编排器: 视觉模型已注入 (%s)", visionProvider.ModelName())
@@ -369,12 +530,24 @@ func main() {
orch.SetOCRProvider(ocrProvider) orch.SetOCRProvider(ocrProvider)
log.Printf("对话编排器: OCR模型已注入 (%s)", ocrProvider.ModelName()) log.Printf("对话编排器: OCR模型已注入 (%s)", ocrProvider.ModelName())
} }
log.Println("对话编排器 v2.0 已就绪") if videoProvider != nil {
orch.SetVideoProvider(videoProvider)
log.Printf("对话编排器: 视频模型已注入 (%s)\n", videoProvider.ModelName())
} else {
log.Println("对话编排器: 视频模型未配置,视频理解功能不可用")
}
if asrProvider != nil && asrProvider.IsAvailable() {
orch.SetASRProvider(asrProvider)
log.Printf("对话编排器: ASR语音识别模型已注入 (%s)\n", asrProvider.ModelName())
} else {
log.Println("对话编排器: ASR语音识别模型未配置")
}
log.Println("对话编排器 v2.0 已就绪")
_ = orch _ = orch
// 注册对话API端点 // 注册对话API端点
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
handleChat(w, r, orch, ctxBuilder, personaLoader, memRetriever, memExtractor, iotClient, thinker, toolRegistry) handleChat(w, r, orch, ctxBuilder, personaLoader, memRetriever, memExtractor, iotClient, thinker, toolRegistry, adminSessionID)
}) })
// 注册记忆API端点 // 注册记忆API端点
@@ -410,6 +583,94 @@ func main() {
w.Write([]byte(`{"status":"ok"}`)) w.Write([]byte(`{"status":"ok"}`))
}) })
// 提醒触发端点:Gateway 调度器在提醒到期时调用,由 LLM 生成自然提醒语推送
mux.HandleFunc("/api/v1/internal/reminder-trigger", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Header.Get("X-Internal-Token") != os.Getenv("INTERNAL_SERVICE_TOKEN") {
w.WriteHeader(http.StatusUnauthorized)
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Platform string `json:"platform,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
AdapterName string `json:"adapter_name,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Generate a natural reminder via LLM.
prompt := fmt.Sprintf(`【系统提醒】现在有一条提醒到时间了:
标题:%s
描述:%s
请用昔涟的口吻,用一句话自然地提醒开拓者。语气温柔俏皮,像朋友间的关心。
只输出提醒语本身,不要加前缀或引号。`, req.Title, req.Description)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: "你是昔涟。用温柔俏皮的语气,一句话提醒开拓者。"},
{Role: model.RoleUser, Content: prompt},
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
resp, err := chatAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[reminder-trigger] LLM生成失败: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
reminderMsg := strings.TrimSpace(resp.Content)
if reminderMsg == "" {
reminderMsg = fmt.Sprintf("开拓者~「%s」的提醒到时间啦♪", req.Title)
}
log.Printf("[reminder-trigger] 生成提醒语: %s", reminderMsg)
// Push through the message pusher if available.
if thinker != nil {
// If group reminder, push through platform-bridge via thinker
if req.Platform != "" && req.ChannelID != "" {
target := background.ProactiveTarget{
Platform: req.Platform,
ChatType: req.ChannelType,
GroupID: req.ChannelID,
}
if req.AdapterName != "" {
target.Platform = req.AdapterName
}
thinker.PushPlatformMessage(target, reminderMsg)
}
// 只有非群聊提醒才推Web端(群聊提醒已通过platform-bridge发送)
if req.Platform == "" {
thinker.TriggerReminderMessage(req.UserID, req.SessionID, reminderMsg)
}
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/api/v1/debug/lock-holder", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
by, at := thinker.LockHolder()
json.NewEncoder(w).Encode(map[string]interface{}{
"locked_by": by,
"locked_at": at.Format(time.RFC3339),
"held_for": time.Since(at).String(),
})
})
mux.HandleFunc("/api/v1/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
buf := make([]byte, 1024*1024)
n := runtime.Stack(buf, true)
w.Write(buf[:n])
})
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`)) w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
@@ -431,6 +692,37 @@ func main() {
}) })
}) })
// LLM 调用 SSE 实时推送
mux.HandleFunc("/api/v1/llm-calls/stream", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
ch, done := llm.SubscribeCalls()
defer llm.UnsubscribeCalls(ch)
for {
select {
case rec := <-ch:
data, _ := json.Marshal(rec)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-done:
return
case <-r.Context().Done():
return
}
}
})
// 工具调用记录 // 工具调用记录
mux.HandleFunc("/api/v1/tools/calls", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/tools/calls", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -501,10 +793,20 @@ func main() {
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
}) })
// 启动HTTP服务 // Debug: 全链路 HTTP 请求日志
debugMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
crashlog.WrapHTTP(mux).ServeHTTP(w, r)
elapsed := time.Since(start)
if elapsed > 2*time.Second || r.URL.Path != "/api/v1/health" {
log.Printf("[http] %s %s %v", r.Method, r.URL.Path, elapsed.Round(time.Millisecond))
}
})
// 启动HTTP服务(全局 panic 恢复 + 崩溃日志)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: mux, Handler: debugMux,
} }
go func() { go func() {
@@ -514,6 +816,21 @@ func main() {
} }
}() }()
// Debug: 每30秒输出内存和goroutine统计
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
var m runtime.MemStats
for range ticker.C {
runtime.ReadMemStats(&m)
log.Printf("[stats] goroutines=%d heap=%dMB sys=%dMB gc=%d",
runtime.NumGoroutine(),
m.HeapAlloc/1024/1024,
m.Sys/1024/1024,
m.NumGC)
}
}()
// 优雅关闭 // 优雅关闭
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -634,6 +951,17 @@ func getEnvInt(key string, fallback int) int {
return n return n
} }
// getFallbackMessage returns a natural-sounding fallback when LLM is unavailable.
func getFallbackMessage() string {
messages := []string{
"开拓者…我现在好像有点恍惚…稍等一下好吗?",
"唔…忆庭的投影好像出了点小问题。等我一下下♪",
"信号有点不太好呢…你稍等我一下哦?",
}
return messages[time.Now().UnixNano()%int64(len(messages))]
}
// registerPluginTools 从插件实例注册其所有工具到注册中心 // registerPluginTools 从插件实例注册其所有工具到注册中心
func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin) { func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin) {
for _, t := range plugin.Tools() { for _, t := range plugin.Tools() {
@@ -644,6 +972,19 @@ func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin
} }
// wrapTool 包装 ai-core 旧 ToolExecutor 为 sdk.Tool // wrapTool 包装 ai-core 旧 ToolExecutor 为 sdk.Tool
// adminOnlyToolIDs lists tool IDs that require admin permission.
// Each tool declares its own AdminOnly flag; the plugin manager enforces it automatically.
// To add a new admin-only tool, add its ID here.
var adminOnlyToolIDs = map[string]bool{
"host_exec": true,
"host_file": true,
"host_system": true,
"os_exec": true,
"os_file": true,
"os_system": true,
"iot_control": true,
}
func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plgSDK.Tool { func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plgSDK.Tool {
return &toolAdapter{ return &toolAdapter{
executor: executor, executor: executor,
@@ -653,6 +994,7 @@ func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plg
Category: category, Category: category,
Complexity: plgSDK.ComplexitySimple, Complexity: plgSDK.ComplexitySimple,
Parameters: executor.Definition().Parameters, Parameters: executor.Definition().Parameters,
AdminOnly: adminOnlyToolIDs[id],
}, },
} }
} }
@@ -710,6 +1052,7 @@ func handleChat(
iotClient *tools.IoTClient, iotClient *tools.IoTClient,
thinker *background.Thinker, thinker *background.Thinker,
_ *plgManager.ToolRegistry, _ *plgManager.ToolRegistry,
adminSessionID string,
) { ) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -722,8 +1065,21 @@ func handleChat(
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Message string `json:"message"` Message string `json:"message"`
Images []string `json:"images,omitempty"` // 图片 base64 data URL Images []string `json:"images,omitempty"` // 图片 base64 data URL
VideoURLs []string `json:"video_urls,omitempty"` // 视频 URL (多模态)
VoiceURLs []string `json:"voice_urls,omitempty"` // 语音 URL (ASR 转录)
Mode string `json:"mode"` Mode string `json:"mode"`
Nickname string `json:"nickname,omitempty"` Nickname string `json:"nickname,omitempty"`
IsAdmin bool `json:"is_admin"`
Source struct {
Platform string `json:"platform"`
ChannelID string `json:"channel_id"`
ChannelType string `json:"channel_type"`
SenderName string `json:"sender_name"`
OriginalUID string `json:"original_uid"`
BotUID string `json:"bot_uid"`
GroupName string `json:"group_name,omitempty"`
AdapterName string `json:"adapter_name,omitempty"`
} `json:"source,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest) http.Error(w, "无效的请求体", http.StatusBadRequest)
@@ -734,11 +1090,88 @@ func handleChat(
req.Mode = "text" req.Mode = "text"
} }
ctx := r.Context() // 平台静默观察模式:只记录消息、提取记忆、触发后台思考,不生成回复。
if req.Mode == "group_ambient" {
// 群聊环境消息 — 审查是否值得回应
message := req.Message
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, message)
if thinker != nil {
thinker.RecordUserMessage(req.SessionID)
}
// 在消息前加审查指令,让 LLM 自己判断是否插话。
// 关键规则:
// 1. 绝大多数群聊消息不需要你回复。只有话题直接涉及你、有人@你、或你真有特别相关的信息时才开口。
// 2. 如果有人说"别说话""闭嘴""先别说""别让ta说"之类让你安静的话,必须立刻闭嘴,后续几条消息都不要回复。
// 3. 不想说话时不要发消息。不要为了刷存在感而发言。
// 4. 每轮最多说 1-2 句话,不要长篇大论。
req.Message = "【群聊审查模式】以下是群聊里的一条消息。绝大多数群聊消息你不需要回复。只有以下情况才开口:(1)有人直接@你或叫你名字 (2)话题与你高度相关 (3)你有重要信息补充。如果有人让你闭嘴/别说话,必须严格遵守。每次回复最多1-2句话。如果你被唤醒了但觉得不该说话,在消息开头写 【不发送】——这条消息就不会被发出去,只在你心里想过。\n\n" + message
req.Mode = "text"
}
if req.Mode == "platform_silent" {
if thinker != nil {
thinker.RecordUserMessage(req.SessionID)
if req.Source.Platform != "" && req.Source.BotUID != "" {
adapterKey := req.Source.Platform
if req.Source.AdapterName != "" {
adapterKey = req.Source.AdapterName
}
thinker.SetBotUID(adapterKey, req.Source.BotUID)
}
if req.Source.Platform != "" && req.Source.ChannelID != "" {
adapterKey := req.Source.Platform
if req.Source.AdapterName != "" {
adapterKey = req.Source.AdapterName
}
thinker.SetBotUID(adapterKey, req.Source.BotUID)
thinker.AddOrUpdatePlatformChannel(req.Source.Platform, req.Source.ChannelType, req.Source.ChannelID, req.Source.GroupName, "", req.Source.AdapterName)
}
}
// 图片预处理:静默观察时也分析图片内容,供后台思考使用
message := req.Message
if len(req.Images) > 0 {
startTime := time.Now()
augmented := orch.PreprocessImages(r.Context(), message, req.Images)
if augmented != message {
message = augmented
log.Printf("[silent] 图片预处理耗时: %%v", time.Since(startTime))
}
}
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, message)
// 从观察到的群聊消息中提取记忆。
orch.ExtractMemoriesOnly(r.Context(), req.UserID, req.SessionID, message)
if thinker != nil {
thinker.TriggerPostChatThink()
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"silent_processed"}`))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
// Inject admin flag for tool access control.
ctx = context.WithValue(ctx, plgManager.CtxKeyIsAdmin, req.IsAdmin)
// 0. 记录用户活动(重置闲置计时器) // 0. 记录用户活动(重置闲置计时器)
if thinker != nil { if thinker != nil {
thinker.RecordUserMessage(req.SessionID) thinker.RecordUserMessage(req.SessionID)
if req.Source.Platform != "" && req.Source.BotUID != "" {
adapterKey := req.Source.Platform
if req.Source.AdapterName != "" {
adapterKey = req.Source.AdapterName
}
thinker.SetBotUID(adapterKey, req.Source.BotUID)
}
if req.Source.Platform != "" && req.Source.ChannelID != "" {
thinker.AddOrUpdatePlatformChannel(req.Source.Platform, req.Source.ChannelType, req.Source.ChannelID, req.Source.GroupName, "", req.Source.AdapterName)
}
}
// Admin private messages: redirect to the main admin session so conversation
// history is shared across platforms (OBv11, web UI, etc.).
if req.IsAdmin && req.Source.ChannelType == "direct" && adminSessionID != "" {
req.SessionID = adminSessionID
} }
// 确定用户昵称 // 确定用户昵称
@@ -753,6 +1186,9 @@ func handleChat(
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
// Trace: message received
AddTraceEvent("msg_received", req.SessionID, req.UserID, "收到消息: "+req.Message, "success", fmt.Sprintf("platform=%s", req.Source.Platform), 0, nil)
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError) http.Error(w, "Streaming not supported", http.StatusInternalServerError)
@@ -760,23 +1196,35 @@ func handleChat(
} }
// 1.5 缓存用户消息到会话历史(在 Orchestrator 之前,确保顺序正确:user → assistant // 1.5 缓存用户消息到会话历史(在 Orchestrator 之前,确保顺序正确:user → assistant
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message) // 管理员主会话聚合多平台消息,加平台标签让 LLM 知道消息来源
userMsgForCache := req.Message
if req.IsAdmin && req.SessionID == adminSessionID && req.Source.AdapterName != "" {
userMsgForCache = fmt.Sprintf("[来自 %s] %s", req.Source.AdapterName, req.Message)
}
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, userMsgForCache)
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程) // 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复 // Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{ eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
UserID: req.UserID, UserID: req.UserID,
SessionID: req.SessionID, SessionID: req.SessionID,
Message: req.Message, Message: req.Message,
Images: req.Images, Images: req.Images,
Mode: req.Mode, VideoURLs: req.VideoURLs,
Nickname: userNickname, VoiceURLs: req.VoiceURLs,
Mode: req.Mode,
Nickname: userNickname,
ChannelType: req.Source.ChannelType,
ChannelID: req.Source.ChannelID,
BotUID: req.Source.BotUID,
AdapterName: req.Source.AdapterName,
IsAdmin: req.IsAdmin,
}) })
if err != nil { if err != nil {
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("处理失败: %v", err)}) fallback := getFallbackMessage()
fmt.Fprintf(w, "data: %s\n\n", errData) log.Printf("[chat] ProcessInput 失败,使用降级回复: %v", err)
flusher.Flush() fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"})
fmt.Fprintf(w, "data: [DONE]\n\n") fmt.Fprintf(w, "data: %s\n\n", fallbackData)
flusher.Flush() flusher.Flush()
return return
} }
@@ -802,10 +1250,10 @@ func handleChat(
case model.StreamError: case model.StreamError:
log.Printf("[chat] 流式错误: %v", event.Error) log.Printf("[chat] 流式错误: %v", event.Error)
errData, _ := json.Marshal(map[string]string{"delta": "", "error": event.Error.Error()}) fallback := getFallbackMessage()
fmt.Fprintf(w, "data: %s\n\n", errData) log.Printf("[chat] 流式事件错误,发送降级回复: %v", event.Error)
flusher.Flush() fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"})
fmt.Fprintf(w, "data: [DONE]\n\n") fmt.Fprintf(w, "data: %s\n\n", fallbackData)
flusher.Flush() flusher.Flush()
return return
+42
View File
@@ -0,0 +1,42 @@
// Code generated by gen_plugins.go; DO NOT EDIT.
package main
import (
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
calculatorpkg "git.yeij.top/AskaEth/Cyrene-Plugins/calculator"
datetimepkg "git.yeij.top/AskaEth/Cyrene-Plugins/datetime"
textpkg "git.yeij.top/AskaEth/Cyrene-Plugins/text"
cryptopkg "git.yeij.top/AskaEth/Cyrene-Plugins/crypto"
randompkg "git.yeij.top/AskaEth/Cyrene-Plugins/random"
markdownpkg "git.yeij.top/AskaEth/Cyrene-Plugins/markdown"
json_opspkg "git.yeij.top/AskaEth/Cyrene-Plugins/json"
halo_publishpkg "git.yeij.top/AskaEth/Cyrene-Plugins/halo_publish"
)
func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) {
for _, t := range (&calculatorpkg.CalculatorPlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&datetimepkg.DatetimePlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&textpkg.TextPlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&cryptopkg.CryptoPlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&randompkg.RandomPlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&markdownpkg.MarkdownPlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&json_opspkg.JSONPlugin{}).Tools() {
registry.Register(t)
}
for _, t := range (&halo_publishpkg.HaloPublishPlugin{}).Tools() {
registry.Register(t)
}
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
)
// TraceEvent represents a single step in the message processing pipeline.
type TraceEvent struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
SessionID string `json:"session_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Hop string `json:"hop"` // message_received, intent, subsession, llm_call, tool_call, vision, synthesis, review, response, think
Label string `json:"label"`
Status string `json:"status"` // success, error, running
Detail string `json:"detail,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
var (
traceMu sync.Mutex
traceEvents []TraceEvent
traceMax = 200
)
// AddTraceEvent appends a trace event to the in-memory ring buffer.
func AddTraceEvent(hop, sessionID, userID, label, status, detail string, durationMs int64, data map[string]interface{}) {
traceMu.Lock()
defer traceMu.Unlock()
ev := TraceEvent{
ID: fmt.Sprintf("%s-%d", hop, time.Now().UnixNano()),
Timestamp: time.Now(),
SessionID: sessionID,
UserID: userID,
Hop: hop,
Label: label,
Status: status,
Detail: detail,
DurationMs: durationMs,
Data: data,
}
traceEvents = append(traceEvents, ev)
if len(traceEvents) > traceMax {
traceEvents = traceEvents[len(traceEvents)-traceMax:]
}
}
// GetTraceEvents returns recent trace events, optionally filtered by session.
func GetTraceEvents(sessionID string, limit int) []TraceEvent {
traceMu.Lock()
defer traceMu.Unlock()
if limit <= 0 || limit > len(traceEvents) {
limit = len(traceEvents)
}
result := make([]TraceEvent, 0)
// Return newest first.
for i := len(traceEvents) - 1; i >= 0 && len(result) < limit; i-- {
ev := traceEvents[i]
if sessionID == "" || ev.SessionID == sessionID {
result = append(result, ev)
}
}
return result
}
// registerTraceEndpoint adds the trace events API.
func registerTraceEndpoint(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/trace/events", func(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
limit := 100
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 500 {
limit = l
}
events := GetTraceEvents(sessionID, limit)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"events": events,
"total": len(events),
})
})
}
+8 -2
View File
@@ -5,12 +5,18 @@ go 1.26.2
require ( require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
git.yeij.top/AskaEth/Cyrene/pkg/audio v0.0.0
git.yeij.top/AskaEth/Cyrene/pkg/dashscope v0.0.0
git.yeij.top/AskaEth/Cyrene/pkg/logger v0.0.0 git.yeij.top/AskaEth/Cyrene/pkg/logger v0.0.0
git.yeij.top/AskaEth/Cyrene/pkg/plugins v0.0.0 git.yeij.top/AskaEth/Cyrene-Plugins v0.0.0
git.yeij.top/AskaEth/Cyrene-Plugins/halo_publish v0.0.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
replace ( replace (
git.yeij.top/AskaEth/Cyrene/pkg/audio => ../pkg/audio
git.yeij.top/AskaEth/Cyrene/pkg/dashscope => ../pkg/dashscope
git.yeij.top/AskaEth/Cyrene/pkg/logger => ../pkg/logger git.yeij.top/AskaEth/Cyrene/pkg/logger => ../pkg/logger
git.yeij.top/AskaEth/Cyrene/pkg/plugins => ../pkg/plugins git.yeij.top/AskaEth/Cyrene-Plugins => ../cyrene-plugins
git.yeij.top/AskaEth/Cyrene-Plugins/halo_publish => ../plugins/halo_publish
) )
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
package background
import (
"testing"
)
func TestExtractProactiveMessage_NoMarker(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
msg, target := thinker.extractProactiveMessage("今天天气真好,开拓者应该出门走走。")
if msg != "" {
t.Errorf("expected empty, got %q", msg)
}
if target != nil {
t.Errorf("expected nil target, got %+v", target)
}
}
func TestExtractProactiveMessage_WebPush(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
content := `今天在想开拓者的事…
【主动消息】今天天气真好,要出去走走吗♪`
msg, target := thinker.extractProactiveMessage(content)
if msg == "" {
t.Fatal("expected message, got empty")
}
if target != nil {
t.Errorf("expected nil target (web push), got %+v", target)
}
if msg != "今天天气真好,要出去走走吗♪" {
t.Errorf("unexpected message: %q", msg)
}
}
func TestExtractProactiveMessage_OBv11Group(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
content := `反思中…
【主动消息】【OBv11群聊:123456】大家早上好呀♪`
msg, target := thinker.extractProactiveMessage(content)
if msg == "" {
t.Fatal("expected message, got empty")
}
if target == nil {
t.Fatal("expected target, got nil")
}
if target.Platform != "obv11" {
t.Errorf("expected platform 'obv11', got %q", target.Platform)
}
if target.ChatType != "group" {
t.Errorf("expected chatType 'group', got %q", target.ChatType)
}
if target.GroupID != "123456" {
t.Errorf("expected groupID '123456', got %q", target.GroupID)
}
if msg != "大家早上好呀♪" {
t.Errorf("unexpected message: %q", msg)
}
}
func TestExtractProactiveMessage_OBv11Private(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
content := `【主动消息】【OBv11私聊:789012】好久不见,最近怎么样?`
msg, target := thinker.extractProactiveMessage(content)
if msg == "" {
t.Fatal("expected message, got empty")
}
if target == nil {
t.Fatal("expected target, got nil")
}
if target.ChatType != "private" {
t.Errorf("expected chatType 'private', got %q", target.ChatType)
}
if target.UserID != "789012" {
t.Errorf("expected userID '789012', got %q", target.UserID)
}
if target.GroupID != "" {
t.Errorf("expected empty groupID for private chat, got %q", target.GroupID)
}
if msg != "好久不见,最近怎么样?" {
t.Errorf("unexpected message: %q", msg)
}
}
func TestExtractProactiveMessage_OBv11GroupAt(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
content := `【主动消息】【OBv11群聊:123456@999888】你说得对呢`
msg, target := thinker.extractProactiveMessage(content)
if msg == "" {
t.Fatal("expected message, got empty")
}
if target == nil {
t.Fatal("expected target, got nil")
}
if target.AtUserID != "999888" {
t.Errorf("expected atUserID '999888', got %q", target.AtUserID)
}
}
func TestExtractProactiveMessage_Negation(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
// "不需要" negates the marker.
content := "开拓者在休息,不需要写【主动消息】打扰他。"
msg, _ := thinker.extractProactiveMessage(content)
if msg != "" {
t.Errorf("expected empty (negated), got %q", msg)
}
}
func TestExtractProactiveMessage_ContinueMarker(t *testing.T) {
thinker := &Thinker{platformFormats: defaultPlatformFormats()}
// 【继续思考】should NOT be extracted as proactive message.
content := "反思完成。【继续思考】"
msg, target := thinker.extractProactiveMessage(content)
if msg != "" {
t.Errorf("expected empty, got %q", msg)
}
if target != nil {
t.Errorf("expected nil target, got %+v", target)
}
}
+45 -10
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"time"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -65,6 +66,37 @@ func (cs *ConversationStore) AddMessage(sessionID string, msg model.LLMMessage)
} }
} }
cs.messages[sessionID] = msgs cs.messages[sessionID] = msgs
// 同步写 DB 确保进程被杀前消息已持久化
if cs.databaseURL != "" {
cs.saveToDB(sessionID, msg)
}
}
// saveToDB persists a message to the database.
func (cs *ConversationStore) saveToDB(sessionID string, msg model.LLMMessage) {
db, err := sql.Open("postgres", cs.databaseURL)
if err != nil {
logger.Printf("[context] saveToDB open error: %v", err)
return
}
defer db.Close()
// 确保 session 存在
_, _ = db.Exec(
`INSERT INTO sessions (id, user_id, created_at, updated_at) VALUES ($1, $2, $3, $3) ON CONFLICT (id) DO NOTHING`,
sessionID, "admin", msg.Timestamp,
)
_, err = db.Exec(
`INSERT INTO messages (session_id, role, content, created_at) VALUES ($1, $2, $3, $4)`,
sessionID, string(msg.Role), msg.Content, msg.Timestamp,
)
if err != nil {
logger.Printf("[context] saveToDB insert error: %v (session=%s)", err, sessionID)
}
// 只对非心跳消息打日志
if len(msg.Content) > 5 {
logger.Printf("[context] saveToDB ok: session=%s role=%s len=%d", sessionID, msg.Role, len(msg.Content))
}
} }
// GetHistory 获取会话历史。 // GetHistory 获取会话历史。
@@ -107,7 +139,7 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
defer db.Close() defer db.Close()
rows, err := db.Query( rows, err := db.Query(
`SELECT role, content FROM messages `SELECT role, content, created_at FROM messages
WHERE session_id = $1 WHERE session_id = $1
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT $2`, LIMIT $2`,
@@ -124,7 +156,8 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
var loaded int var loaded int
for rows.Next() { for rows.Next() {
var roleStr, content string var roleStr, content string
if err := rows.Scan(&roleStr, &content); err != nil { var createdAt time.Time
if err := rows.Scan(&roleStr, &content, &createdAt); err != nil {
return fmt.Errorf("扫描消息行失败: %w", err) return fmt.Errorf("扫描消息行失败: %w", err)
} }
// 将旧数据中的 "action" 角色映射为 "assistant"LLM 模型不支持自定义角色) // 将旧数据中的 "action" 角色映射为 "assistant"LLM 模型不支持自定义角色)
@@ -133,8 +166,9 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
role = model.RoleAssistant role = model.RoleAssistant
} }
cs.messages[sessionID] = append(cs.messages[sessionID], model.LLMMessage{ cs.messages[sessionID] = append(cs.messages[sessionID], model.LLMMessage{
Role: role, Role: role,
Content: content, Content: content,
Timestamp: createdAt,
}) })
loaded++ loaded++
} }
@@ -163,8 +197,9 @@ type BuildParams struct {
Memories []memory.MemoryEntry Memories []memory.MemoryEntry
HistoryLimit int HistoryLimit int
DeviceContext string // 注入的设备状态文本 DeviceContext string // 注入的设备状态文本
PendingThoughts []string // 待注入的后台思考 PendingThoughts []string // 待注入的后台思考
Nickname string // 用户昵称 (昔涟对用户的称呼) PlatformObservationSummary string // 平台观察摘要(中间会话生成)
Nickname string // 用户昵称 (昔涟对用户的称呼)
} }
// Build 构建发送给LLM的完整消息列表 // Build 构建发送给LLM的完整消息列表
@@ -231,10 +266,10 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
} }
var memoryPrompt string var memoryPrompt string
memoryPrompt += "【以下是关于开拓者的重要记忆,请在合适的时自然地提】\n\n" memoryPrompt += "【你记得关于开拓者的这些事——就像自己的回忆一样,在合适的时自然地提起,不要用「根据记忆」或「检索到」这类说法】\n\n"
if len(coreMems) > 0 { if len(coreMems) > 0 {
memoryPrompt += "★ 核心记忆(非常重要,务必优先参考)\n" memoryPrompt += "你很确定的事\n"
for _, m := range coreMems { for _, m := range coreMems {
memoryPrompt += formatMemoryLine(m) memoryPrompt += formatMemoryLine(m)
} }
@@ -242,7 +277,7 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
} }
if len(recentMems) > 0 { if len(recentMems) > 0 {
memoryPrompt += "● 常用记忆\n" memoryPrompt += "最近发生的事\n"
for _, m := range recentMems { for _, m := range recentMems {
memoryPrompt += formatMemoryLine(m) memoryPrompt += formatMemoryLine(m)
} }
@@ -250,7 +285,7 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
} }
if len(otherMems) > 0 { if len(otherMems) > 0 {
memoryPrompt += "○ 其他记忆\n" memoryPrompt += "还有点印象的事\n"
for _, m := range otherMems { for _, m := range otherMems {
memoryPrompt += formatMemoryLine(m) memoryPrompt += formatMemoryLine(m)
} }
@@ -0,0 +1,132 @@
// Package crashlog 提供详细的崩溃日志、panic 恢复和 goroutine 保护工具。
// 在开发阶段用于快速定位崩溃点。
package crashlog
import (
"fmt"
"log"
"net/http"
"os"
"runtime"
"runtime/debug"
"time"
)
// Go 在单独的 goroutine 中运行 fn,自动捕获 panic 并记录完整堆栈。
// 返回一个 channel,在 goroutine 退出时关闭。
// 用法: go crashlog.Go("thinker-light", func() { ... })
func Go(name string, fn func()) {
go func() {
defer Recover(name)
fn()
}()
}
// Recover 用于 defer 语句中,捕获 panic 并记录完整调用栈。
// 用法: defer crashlog.Recover("thinker-deep")
func Recover(name string) {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("[CRASH] goroutine=%s panic=%v\n%s", name, r, string(stack))
// 写独立崩溃日志文件,方便事后排查
writeCrashFile(name, fmt.Sprintf("panic: %v\n\n%s", r, string(stack)))
}
}
// RecoverNoop is a no-op for production hot paths.
func RecoverNoop(name string) {}
// WrapHTTP 返回一个 HTTP 中间件,自动捕获 handler 中的 panic。
// 用法: http.Handle("/api", crashlog.WrapHTTP(handler))
func WrapHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := debug.Stack()
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
r.URL.String(), r.Method, rec, string(stack))
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// WrapHTTPFunc 返回一个 HTTP handler 函数中间件。
func WrapHTTPFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := debug.Stack()
log.Printf("[CRASH] http-panic url=%s method=%s panic=%v\n%s",
r.URL.String(), r.Method, rec, string(stack))
writeCrashFile("http-"+sanitize(r.URL.String()), fmt.Sprintf("panic: %v\n\n%s", rec, string(stack)))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
fn(w, r)
}
}
// LLMCall 记录 LLM API 调用的开始时间,返回一个结束函数。
// 用法:
//
// defer crashlog.LLMCall("deep-think", model)(&err, &responseLen)
func LLMCall(caller, model string) func(err *error, responseBytes *int) {
start := time.Now()
return func(err *error, responseBytes *int) {
elapsed := time.Since(start)
level := "OK"
errMsg := ""
if err != nil && *err != nil {
level = "FAIL"
errMsg = (*err).Error()
}
respLen := 0
if responseBytes != nil {
respLen = *responseBytes
}
if elapsed > 10*time.Second {
level = "SLOW(" + level + ")"
}
log.Printf("[LLM] caller=%s model=%s elapsed=%v result=%s resp_len=%d err=%s",
caller, model, elapsed.Round(time.Millisecond), level, respLen, errMsg)
}
}
// ── internal helpers ──
func writeCrashFile(name string, content string) {
// 写到 logs/ 目录,持久化保留
os.MkdirAll("logs", 0755)
timestamp := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("logs/crash_%s_%s.log", sanitize(name), timestamp)
f, err := os.Create(filename)
if err != nil {
log.Printf("[CRASH] 无法写入崩溃日志文件 %s: %v", filename, err)
return
}
defer f.Close()
fmt.Fprintf(f, "=== CRASH REPORT ===\n")
fmt.Fprintf(f, "Time: %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(f, "Goroutine: %s\n", name)
fmt.Fprintf(f, "Go Version: %s\n", runtime.Version())
fmt.Fprintf(f, "GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
fmt.Fprintf(f, "NumGoroutine: %d\n", runtime.NumGoroutine())
fmt.Fprintf(f, "\n%s", content)
log.Printf("[CRASH] 崩溃日志已写入 %s", filename)
}
func sanitize(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s) && i < 100; i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
result = append(result, c)
} else {
result = append(result, '_')
}
}
return string(result)
}
@@ -0,0 +1,98 @@
package crashlog
import (
"fmt"
"log"
"runtime"
"strings"
"sync"
"time"
)
// ── Mutex with debug tracking ──
// DebugMutex wraps a sync.Mutex with lock-holder tracking.
type DebugMutex struct {
mu sync.Mutex
holder string
lockedAt time.Time
}
// Lock acquires the mutex with caller tracking.
func (m *DebugMutex) Lock() {
m.mu.Lock()
_, file, line, _ := runtime.Caller(1)
if idx := strings.LastIndex(file, "Cyrene/"); idx >= 0 {
file = file[idx+len("Cyrene/"):]
}
m.holder = fmt.Sprintf("%s:%d", file, line)
m.lockedAt = time.Now()
}
// Unlock releases the mutex.
func (m *DebugMutex) Unlock() {
m.holder = ""
m.mu.Unlock()
}
// TryLock attempts to acquire the lock with a timeout. Returns true if acquired.
func (m *DebugMutex) TryLock(timeout time.Duration) bool {
done := make(chan struct{})
go func() {
m.mu.Lock()
m.mu.Unlock()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
// Holder returns who holds the lock.
func (m *DebugMutex) Holder() (string, time.Time) {
return m.holder, m.lockedAt
}
// ── Channel debug helpers ──
// ChSend sends to a channel with a timeout and logs if it blocks.
func ChSend[T any](name string, ch chan<- T, val T, timeout time.Duration) bool {
select {
case ch <- val:
return true
case <-time.After(timeout):
log.Printf("[debug] ChSend timeout: %s after %v", name, timeout)
return false
}
}
// ── Goroutine lifecycle ──
// GoStart logs goroutine start and wraps fn with panic recovery.
func GoStart(name string, fn func()) {
log.Printf("[debug] goroutine START: %s", name)
go func() {
defer func() {
if r := recover(); r != nil {
stack := make([]byte, 4096)
n := runtime.Stack(stack, false)
log.Printf("[debug] goroutine PANIC: %s panic=%v\n%s", name, r, stack[:n])
}
log.Printf("[debug] goroutine EXIT: %s", name)
}()
fn()
}()
}
// ── Timing ──
// Since logs if a duration exceeds a threshold.
func Since(name string, start time.Time, threshold time.Duration) {
elapsed := time.Since(start)
if elapsed > threshold {
log.Printf("[debug] SLOW: %s took %v (threshold=%v)", name, elapsed.Round(time.Millisecond), threshold)
}
}
+123
View File
@@ -0,0 +1,123 @@
package llm
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/audio"
"git.yeij.top/AskaEth/Cyrene/pkg/dashscope"
)
// ASRProvider handles speech-to-text transcription.
type ASRProvider interface {
Transcribe(ctx context.Context, audioURL, language string) (string, error)
IsAvailable() bool
ModelName() string
}
// DashScopeASRProvider uses DashScope Paraformer API for offline speech recognition.
type DashScopeASRProvider struct {
model string
client *dashscope.RESTClient
http *http.Client
}
// NewDashScopeASRProvider creates a DashScope ASR provider.
func NewDashScopeASRProvider(baseURL, apiKey, model string) *DashScopeASRProvider {
if model == "" {
model = "qwen3-asr-flash-2026-02-10"
}
return &DashScopeASRProvider{
model: model,
client: dashscope.NewRESTClient(apiKey),
http: &http.Client{Timeout: 60 * time.Second},
}
}
// IsAvailable returns true if the API key is configured.
func (p *DashScopeASRProvider) IsAvailable() bool {
return p.client.IsAvailable()
}
// ModelName returns the ASR model name.
func (p *DashScopeASRProvider) ModelName() string {
return p.model
}
// downloadAudio fetches audio data from a URL and returns the bytes with inferred format.
func (p *DashScopeASRProvider) downloadAudio(ctx context.Context, audioURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", audioURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
resp, err := p.http.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB limit
if err != nil {
return nil, "", fmt.Errorf("read audio data: %w", err)
}
format := inferAudioFormat(audioURL, resp.Header.Get("Content-Type"))
return data, format, nil
}
// inferAudioFormat determines the audio format from URL extension or Content-Type header.
func inferAudioFormat(urlStr, contentType string) string {
u, err := url.Parse(urlStr)
if err == nil {
path := u.Path
if idx := strings.LastIndex(path, "."); idx >= 0 {
ext := strings.ToLower(path[idx+1:])
switch ext {
case "amr", "wav", "mp3", "ogg", "flac", "m4a", "aac", "opus", "webm", "pcm":
return ext
}
}
}
if strings.Contains(contentType, "audio/amr") || strings.Contains(contentType, "amr") {
return "amr"
}
if strings.Contains(contentType, "audio/wav") || strings.Contains(contentType, "wav") {
return "wav"
}
if strings.Contains(contentType, "audio/mpeg") || strings.Contains(contentType, "mp3") {
return "mp3"
}
if strings.Contains(contentType, "audio/ogg") || strings.Contains(contentType, "opus") {
return "ogg"
}
return "amr" // default for OBv11 voice messages
}
func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL, language string) (string, error) {
if !p.IsAvailable() {
return "", fmt.Errorf("DashScope ASR API key not configured")
}
audioData, format, err := p.downloadAudio(ctx, audioURL)
if err != nil {
return "", fmt.Errorf("download audio: %w", err)
}
// 转码为 16kHz mono PCM,提升识别兼容性
pcmData, err := audio.ConvertToPCM16(audioData, format)
if err != nil {
return "", fmt.Errorf("audio transcode: %w", err)
}
if language == "" || language == "auto" {
language = "zh"
}
return p.client.Transcribe(ctx, p.model, pcmData, "pcm", 16000, language)
}
+48
View File
@@ -52,6 +52,8 @@ func (cl *CallLogger) log(r CallRecord) {
if cl.size < cl.capacity { if cl.size < cl.capacity {
cl.size++ cl.size++
} }
broadcastCall(r)
} }
func (cl *CallLogger) get(limit int) []CallRecord { func (cl *CallLogger) get(limit int) []CallRecord {
@@ -72,3 +74,49 @@ func (cl *CallLogger) get(limit int) []CallRecord {
} }
return result return result
} }
// --- SSE subscriber system ---
type callSubscriber struct {
ch chan CallRecord
done chan struct{}
}
var (
callSubscribers []*callSubscriber
callSubscribersMu sync.RWMutex
)
// SubscribeCalls returns a channel that receives new CallRecords and a done channel.
func SubscribeCalls() (<-chan CallRecord, <-chan struct{}) {
ch := make(chan CallRecord, 20)
done := make(chan struct{})
callSubscribersMu.Lock()
callSubscribers = append(callSubscribers, &callSubscriber{ch: ch, done: done})
callSubscribersMu.Unlock()
return ch, done
}
// UnsubscribeCalls removes a subscriber. Safe to call multiple times.
func UnsubscribeCalls(ch <-chan CallRecord) {
callSubscribersMu.Lock()
defer callSubscribersMu.Unlock()
for i, s := range callSubscribers {
if s.ch == ch {
close(s.done)
callSubscribers = append(callSubscribers[:i], callSubscribers[i+1:]...)
return
}
}
}
func broadcastCall(r CallRecord) {
callSubscribersMu.RLock()
defer callSubscribersMu.RUnlock()
for _, s := range callSubscribers {
select {
case s.ch <- r:
default:
}
}
}
+23 -14
View File
@@ -41,10 +41,15 @@ func NewOpenAIProvider(cfg OpenAIConfig) *OpenAIProvider {
cfg.Timeout = 60 * time.Second cfg.Timeout = 60 * time.Second
} }
// 克隆默认 Transport 并关闭 keep-alive,防止 context 取消后连接池脏连接导致全阻塞
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DisableKeepAlives = true
return &OpenAIProvider{ return &OpenAIProvider{
config: cfg, config: cfg,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
Transport: tr,
}, },
} }
} }
@@ -274,7 +279,7 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
resolvedImages := p.resolveImages(msg.Images) resolvedImages := p.resolveImages(msg.Images)
oaiMsg := openAIMessage{ oaiMsg := openAIMessage{
Role: string(msg.Role), Role: string(msg.Role),
Content: buildContent(msg.Content, resolvedImages), Content: buildContent(msg.Content, resolvedImages, msg.VideoURLs),
Name: msg.Name, Name: msg.Name,
ToolCallID: msg.ToolCallID, ToolCallID: msg.ToolCallID,
ReasoningContent: msg.ReasoningContent, ReasoningContent: msg.ReasoningContent,
@@ -382,7 +387,7 @@ func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMM
resolvedImages := p.resolveImages(msg.Images) resolvedImages := p.resolveImages(msg.Images)
oaiMsg := openAIMessage{ oaiMsg := openAIMessage{
Role: string(msg.Role), Role: string(msg.Role),
Content: buildContent(msg.Content, resolvedImages), Content: buildContent(msg.Content, resolvedImages, msg.VideoURLs),
Name: msg.Name, Name: msg.Name,
ToolCallID: msg.ToolCallID, ToolCallID: msg.ToolCallID,
ReasoningContent: msg.ReasoningContent, ReasoningContent: msg.ReasoningContent,
@@ -521,23 +526,27 @@ func (p *OpenAIProvider) downloadAsDataURL(url string) (string, error) {
// buildContent converts text + optional images to API content format. // buildContent converts text + optional images to API content format.
// Returns a plain string if no images, or a multimodal array otherwise. // Returns a plain string if no images, or a multimodal array otherwise.
func buildContent(text string, images []string) interface{} { func buildContent(text string, images []string, videoURLs []string) interface{} {
if len(images) == 0 { if len(images) == 0 && len(videoURLs) == 0 {
return text return text
} }
parts := make([]model.ImageContent, 0, len(images)+1) parts := make([]interface{}, 0, len(images)+len(videoURLs)+1)
if text != "" { if text != "" {
parts = append(parts, model.ImageContent{ parts = append(parts, map[string]interface{}{
Type: "text", "type": "text",
Text: text, "text": text,
}) })
} }
for _, img := range images { for _, img := range images {
parts = append(parts, model.ImageContent{ parts = append(parts, map[string]interface{}{
Type: "image_url", "type": "image_url",
ImageURL: &model.ImageURL{ "image_url": map[string]string{"url": img},
URL: img, })
}, }
for _, video := range videoURLs {
parts = append(parts, map[string]interface{}{
"type": "video_url",
"video_url": map[string]string{"url": video},
}) })
} }
return parts return parts
+4 -2
View File
@@ -18,8 +18,10 @@ const (
PurposeIntentAnalysis ModelPurpose = "intent_analysis" PurposeIntentAnalysis ModelPurpose = "intent_analysis"
PurposeToolCalling ModelPurpose = "tool_calling" PurposeToolCalling ModelPurpose = "tool_calling"
PurposeMemoryExtraction ModelPurpose = "memory_extraction" PurposeMemoryExtraction ModelPurpose = "memory_extraction"
PurposeVision ModelPurpose = "vision" PurposeVision ModelPurpose = "vision"
PurposeOCR ModelPurpose = "ocr" PurposeVideo ModelPurpose = "video"
PurposeOCR ModelPurpose = "ocr"
PurposeSpeechRecognition ModelPurpose = "speech_recognition"
) )
// ErrModelNotRequired is returned when an optional model is unavailable. // ErrModelNotRequired is returned when an optional model is unavailable.
+78 -5
View File
@@ -34,16 +34,28 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
logger.Printf("[memory] 记忆提取失败: %v", err) logger.Printf("[memory] 记忆提取失败: %v", err)
return return
} }
e.storeMemories(ctx, userID, sessionID, memories)
}
// ExtractObservations 从观察到的单条消息中提取记忆(无语境回复)。
// 用于 platform_silent 模式:昔涟被动观察群聊,提取值得记住的信息。
func (e *Extractor) ExtractObservations(ctx context.Context, userID, sessionID, message string) {
memories, err := e.extractObservations(ctx, message)
if err != nil {
logger.Printf("[memory] 观察记忆提取失败: %v", err)
return
}
e.storeMemories(ctx, userID, sessionID, memories)
}
func (e *Extractor) storeMemories(ctx context.Context, userID, sessionID string, memories []model.MemoryEntry) {
for _, mem := range memories { for _, mem := range memories {
mem.UserID = userID mem.UserID = userID
mem.SessionID = sessionID mem.SessionID = sessionID
mem.Source = "conversation" mem.Source = "conversation"
// 去重检查:查询用户已有的相关记忆
existing, err := e.findSimilar(ctx, userID, &mem) existing, err := e.findSimilar(ctx, userID, &mem)
if err == nil && existing != nil { if err == nil && existing != nil {
// 相似度 > 80%,更新现有记忆
e.mergeMemory(ctx, existing, &mem) e.mergeMemory(ctx, existing, &mem)
continue continue
} }
@@ -56,6 +68,60 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
} }
} }
// extractObservations 从观察到的消息中提取记忆(无助手回复)
func (e *Extractor) extractObservations(ctx context.Context, message string) ([]model.MemoryEntry, error) {
if e.llmChat != nil {
return e.extractObservationsWithLLM(ctx, message)
}
return e.extractWithRules(message, ""), nil
}
// extractObservationsWithLLM 使用LLM从观察到的消息中提取值得记住的信息
func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message string) ([]model.MemoryEntry, error) {
prompt := fmt.Sprintf(`分析以下在聊天平台观察到的消息,提取值得记住的信息作为记忆。
观察到的消息: %s
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
消息格式为:[群聊 群号] 发送者昵称 (OBv11账号):消息内容
提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息?
重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。
每条记忆需要包含以下字段:
- content: 完整的记忆内容(一句话描述,客观准确)
- summary: 简短摘要(10字以内)
- category: 记忆分类,必须是以下之一:
* conversation: 对话主题/讨论摘要
* event: 事件记录(发生了什么)
* personal_info: 参与者的个人信息
* knowledge: 知识性信息
* user_preference: 某人的偏好
* task: 提及的计划/任务
- priority: 优先级 (0=临时, 1=普通, 2=重要, 3=核心)
- importance: 重要程度 1-10
* 1-3: 日常闲聊,不太重要
* 4-6: 一般有用的信息
* 7-8: 重要信息,值得长期记住
* 9-10: 核心信息
- keywords: 关键词标签数组(3-5个词)
只提取有意义的信息。如果消息只是日常寒暄或无实质内容,返回空数组。
输出格式:
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1, "importance": 6, "keywords": ["词1", "词2"]}]}
`, message)
resp, err := e.llmChat(ctx, []model.LLMMessage{
{Role: "system", Content: "你是一个聊天观察记录助手。你只输出JSON格式的结果。你的任务是从观察到的聊天消息中提取值得记住的信息。"},
{Role: "user", Content: prompt},
})
if err != nil {
return nil, fmt.Errorf("LLM提取观察记忆失败: %w", err)
}
return e.parseExtractionResult(resp.Content)
}
// extract 从对话中提取记忆 // extract 从对话中提取记忆
func (e *Extractor) extract(ctx context.Context, userMessage, assistantResponse string) ([]model.MemoryEntry, error) { func (e *Extractor) extract(ctx context.Context, userMessage, assistantResponse string) ([]model.MemoryEntry, error) {
// 如果有LLM,使用LLM提取 // 如果有LLM,使用LLM提取
@@ -128,11 +194,18 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
return nil, fmt.Errorf("LLM提取记忆失败: %w", err) return nil, fmt.Errorf("LLM提取记忆失败: %w", err)
} }
// 解析JSON entries, err := e.parseExtractionResult(resp.Content)
if err != nil {
return nil, err
}
return entries, nil
}
// parseExtractionResult 解析LLM返回的记忆提取JSON结果
func (e *Extractor) parseExtractionResult(text string) ([]model.MemoryEntry, error) {
result := MemoryExtractionResult{} result := MemoryExtractionResult{}
content := extractJSON(resp.Content) content := extractJSON(text)
if err := json.Unmarshal([]byte(content), &result); err != nil { if err := json.Unmarshal([]byte(content), &result); err != nil {
// 尝试作为数组解析(兼容旧格式)
var arrResult []ExtractedMemory var arrResult []ExtractedMemory
if err2 := json.Unmarshal([]byte(content), &arrResult); err2 != nil { if err2 := json.Unmarshal([]byte(content), &arrResult); err2 != nil {
return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:minint(len(content), 100)]) return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:minint(len(content), 100)])
+12
View File
@@ -17,10 +17,12 @@ type LLMMessage struct {
Role Role `json:"role"` Role Role `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Images []string `json:"images,omitempty"` // 图片 base64 data URL 列表 (多模态) Images []string `json:"images,omitempty"` // 图片 base64 data URL 列表 (多模态)
VideoURLs []string `json:"video_urls,omitempty"` // 视频 URL 列表 (多模态)
Name string `json:"name,omitempty"` // 可选发送者名称 Name string `json:"name,omitempty"` // 可选发送者名称
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用) ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表 ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传) ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传)
Timestamp time.Time `json:"timestamp,omitempty"` // 消息时间
} }
// ImageContent is a multimodal content part for images. // ImageContent is a multimodal content part for images.
@@ -36,6 +38,16 @@ type ImageURL struct {
Detail string `json:"detail,omitempty"` // low, high, auto Detail string `json:"detail,omitempty"` // low, high, auto
} }
// VideoURLContent holds a video URL for multimodal video understanding.
type VideoURLContent struct {
VideoURL *VideoURL `json:"video_url,omitempty"`
}
// VideoURL holds a video URL.
type VideoURL struct {
URL string `json:"url"`
}
// ChatMessage 数据库存储的对话消息 // ChatMessage 数据库存储的对话消息
type ChatMessage struct { type ChatMessage struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
@@ -49,7 +49,9 @@ type SubSessionResult struct {
Confidence float64 `json:"confidence"` // 置信度 0-1 Confidence float64 `json:"confidence"` // 置信度 0-1
Progress float64 `json:"progress"` // 执行进度 0.0 ~ 1.0 Progress float64 `json:"progress"` // 执行进度 0.0 ~ 1.0
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Metadata map[string]any `json:"metadata"` // 类型特定的元数据 Metadata map[string]any `json:"metadata"` // 类型特定的元数据
CreatedAt time.Time `json:"created_at"` // 任务创建时间
FinishedAt time.Time `json:"finished_at"` // 任务结束时间
} }
// ToolCallRecord 工具调用记录 // ToolCallRecord 工具调用记录
@@ -15,10 +15,15 @@ type EnrichmentData struct {
// PendingToolResult holds the result of a tool that completed asynchronously. // PendingToolResult holds the result of a tool that completed asynchronously.
type PendingToolResult struct { type PendingToolResult struct {
ToolCallID string `json:"tool_call_id"` ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"` ToolName string `json:"tool_name"`
Result string `json:"result"` Result string `json:"result"`
Success bool `json:"success"` Success bool `json:"success"`
SessionID string `json:"session_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"` // 来源平台
ChannelID string `json:"channel_id,omitempty"` // 来源频道
ChannelType string `json:"channel_type,omitempty"` // direct/group
} }
// SessionEnrichmentStore is a thread-safe per-session cache for async // SessionEnrichmentStore is a thread-safe per-session cache for async
@@ -19,7 +19,7 @@ import (
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/bus" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/bus"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/scheduler" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/scheduler"
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager" plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
) )
// Orchestrator 对话编排器 v2.0 // Orchestrator 对话编排器 v2.0
@@ -39,8 +39,17 @@ type Orchestrator struct {
msgScheduler *scheduler.MessageScheduler msgScheduler *scheduler.MessageScheduler
emotionTracker *persona.EmotionTracker emotionTracker *persona.EmotionTracker
toolRegistry *plgManager.ToolRegistry toolRegistry *plgManager.ToolRegistry
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64) // trace 回调
visionProvider llm.LLMProvider // 视觉模型 (图片预处理) visionProvider llm.LLMProvider // 视觉模型 (图片预处理)
ocrProvider llm.LLMProvider // OCR 模型 (文字提取,与视觉模型并行调用) ocrProvider llm.LLMProvider // OCR 模型 (文字提取,与视觉模型并行调用)
videoProvider llm.LLMProvider // 视频模型 (短视频理解)
asrProvider llm.ASRProvider // ASR 语音识别 (语音消息转录)
// 群聊并发:主会话繁忙时创建协会议话
sessionProcMu sync.Mutex
sessionProc map[string]bool // sessionID → currently processing
activeCoSessions map[string]int // sessionID → active co-session count
maxCoSessions int // max concurrent co-sessions per main session (default 3)
} }
// SetResponseCache sets the response cache (optional, for Phase 0.2). // SetResponseCache sets the response cache (optional, for Phase 0.2).
@@ -74,6 +83,17 @@ func (o *Orchestrator) SetToolRegistry(tr *plgManager.ToolRegistry) {
o.synthesizer.toolRegistry = tr o.synthesizer.toolRegistry = tr
} }
// SetToolResultPusher sets the callback for proactive tool result delivery.
func (o *Orchestrator) SetToolResultPusher(pusher func(sessionID, userID, toolName, result string, params SynthesizeParams)) {
o.synthesizer.SetResultPusher(pusher)
}
// SetTraceFunc sets the trace callback for pipeline event recording.
func (o *Orchestrator) SetTraceFunc(fn func(hop, sessionID, userID, label, status, detail string, durationMs int64)) {
o.traceFn = fn
o.synthesizer.SetTraceFunc(fn)
}
// SetVisionProvider sets the vision model provider for image preprocessing. // SetVisionProvider sets the vision model provider for image preprocessing.
func (o *Orchestrator) SetVisionProvider(vp llm.LLMProvider) { func (o *Orchestrator) SetVisionProvider(vp llm.LLMProvider) {
o.visionProvider = vp o.visionProvider = vp
@@ -84,6 +104,16 @@ func (o *Orchestrator) SetOCRProvider(op llm.LLMProvider) {
o.ocrProvider = op o.ocrProvider = op
} }
// SetVideoProvider sets the video model provider for short video understanding.
func (o *Orchestrator) SetVideoProvider(vp llm.LLMProvider) {
o.videoProvider = vp
}
// SetASRProvider sets the ASR provider for voice message transcription.
func (o *Orchestrator) SetASRProvider(ap llm.ASRProvider) {
o.asrProvider = ap
}
// getBus returns the bus or a nop fallback. // getBus returns the bus or a nop fallback.
func (o *Orchestrator) getBus() bus.Bus { func (o *Orchestrator) getBus() bus.Bus {
if o.eventBus == nil { if o.eventBus == nil {
@@ -104,25 +134,35 @@ func NewOrchestrator(
memoryExtractor *memory.Extractor, memoryExtractor *memory.Extractor,
) *Orchestrator { ) *Orchestrator {
return &Orchestrator{ return &Orchestrator{
personaLoader: personaLoader, personaLoader: personaLoader,
contextBuilder: contextBuilder, contextBuilder: contextBuilder,
llmAdapter: chatAdapter, llmAdapter: chatAdapter,
subManager: subManager, subManager: subManager,
intentAnalyzer: NewIntentAnalyzer(intentAdapter), intentAnalyzer: NewIntentAnalyzer(intentAdapter),
synthesizer: NewSynthesizer(chatAdapter, nil), synthesizer: NewSynthesizer(chatAdapter, nil),
memoryRetriever: memoryRetriever, memoryRetriever: memoryRetriever,
memoryExtractor: memoryExtractor, memoryExtractor: memoryExtractor,
sessionProc: make(map[string]bool),
activeCoSessions: make(map[string]int),
maxCoSessions: 3,
} }
} }
// ProcessParams 处理参数 // ProcessParams 处理参数
type ProcessParams struct { type ProcessParams struct {
UserID string UserID string
SessionID string SessionID string
Message string Message string
Images []string // 图片 base64 data URL (多模态) Images []string // 图片 base64 data URL (多模态)
Mode string // text / voice_msg / voice_assistant VideoURLs []string // 视频 URL (多模态), ≤20s short videos
Nickname string VoiceURLs []string // 语音 URL (ASR 转录)
Mode string // text / voice_msg / voice_assistant
Nickname string
ChannelType string // direct / group
ChannelID string // platform channel ID (group ID or private OBv11 number)
BotUID string // bot's own platform UID (e.g., OBv11 account)
AdapterName string // adapter config name for routing
IsAdmin bool // 发送者是否为管理员
} }
// ProcessResult 处理结果 // ProcessResult 处理结果
@@ -155,7 +195,55 @@ func (o *Orchestrator) ProcessInput(
} }
}() }()
// 0. 发布合成开始事件 // 0. 群聊并发:检测主会话是否繁忙,决定是主会话还是协会议话
isCoSession := false
o.sessionProcMu.Lock()
if o.sessionProc[params.SessionID] {
// 等待主会话释放(最多等 3s,避免永久死锁)
waitStart := time.Now()
for o.activeCoSessions[params.SessionID] >= o.maxCoSessions && time.Since(waitStart) < 3*time.Second {
o.sessionProcMu.Unlock()
select {
case <-ctx.Done():
o.sessionProcMu.Lock()
o.sessionProcMu.Unlock()
logger.Printf("[orchestrator] 等待会话释放时 context 取消")
return
case <-time.After(500 * time.Millisecond):
}
o.sessionProcMu.Lock()
}
if o.activeCoSessions[params.SessionID] >= o.maxCoSessions {
o.sessionProcMu.Unlock()
logger.Printf("[orchestrator] 协会议话已达上限且等待超时,拒绝请求")
return
}
o.activeCoSessions[params.SessionID]++
isCoSession = true
o.sessionProcMu.Unlock()
logger.Printf("[orchestrator] 主会话繁忙,创建协会议话 (session=%s, active=%d)", params.SessionID, o.activeCoSessions[params.SessionID])
} else {
o.sessionProc[params.SessionID] = true
o.sessionProcMu.Unlock()
defer func() {
o.sessionProcMu.Lock()
delete(o.sessionProc, params.SessionID)
o.sessionProcMu.Unlock()
}()
}
if isCoSession {
defer func() {
o.sessionProcMu.Lock()
o.activeCoSessions[params.SessionID]--
if o.activeCoSessions[params.SessionID] <= 0 {
delete(o.activeCoSessions, params.SessionID)
}
o.sessionProcMu.Unlock()
}()
}
// 0.5 发布合成开始事件
o.getBus().Publish(bus.BusEvent{ o.getBus().Publish(bus.BusEvent{
Type: bus.EventSynthesisStarted, Type: bus.EventSynthesisStarted,
SessionID: params.SessionID, SessionID: params.SessionID,
@@ -165,7 +253,7 @@ func (o *Orchestrator) ProcessInput(
// 0.5 图片预处理: 使用视觉模型分析图片,将描述注入消息 // 0.5 图片预处理: 使用视觉模型分析图片,将描述注入消息
if len(params.Images) > 0 && o.visionProvider != nil { if len(params.Images) > 0 && o.visionProvider != nil {
startTime := time.Now() startTime := time.Now()
augmented := o.preprocessImages(ctx, params.Message, params.Images) augmented := o.PreprocessImages(ctx, params.Message, params.Images)
if augmented != params.Message { if augmented != params.Message {
params.Message = augmented params.Message = augmented
logger.Printf("[orchestrator] 图片预处理耗时: %v, 原消息=%d字, 增强后=%d字", logger.Printf("[orchestrator] 图片预处理耗时: %v, 原消息=%d字, 增强后=%d字",
@@ -173,6 +261,34 @@ func (o *Orchestrator) ProcessInput(
} }
// 预处理后清空原始图片,避免后续传给不支持多模态的 Chat 模型 // 预处理后清空原始图片,避免后续传给不支持多模态的 Chat 模型
params.Images = nil params.Images = nil
// 0.6 视频预处理: 使用视频模型分析短视频 (≤20s),将描述注入消息
if len(params.VideoURLs) > 0 && o.videoProvider != nil {
startTime := time.Now()
augmented := o.preprocessVideos(ctx, params.Message, params.VideoURLs)
if augmented != params.Message {
params.Message = augmented
logger.Printf("[orchestrator] 视频预处理耗时: %v", time.Since(startTime))
}
params.VideoURLs = nil
} else if len(params.VideoURLs) > 0 {
logger.Printf("[orchestrator] 视频模型未配置,丢弃 %d 个视频", len(params.VideoURLs))
params.VideoURLs = nil
}
// 0.7 语音预处理: 使用 ASR 模型转录语音消息,将文本注入消息
if len(params.VoiceURLs) > 0 && o.asrProvider != nil && o.asrProvider.IsAvailable() {
startTime := time.Now()
augmented := o.preprocessVoice(ctx, params.Message, params.VoiceURLs)
if augmented != params.Message {
params.Message = augmented
logger.Printf("[orchestrator] 语音预处理耗时: %v", time.Since(startTime))
}
params.VoiceURLs = nil
} else if len(params.VoiceURLs) > 0 {
logger.Printf("[orchestrator] ASR模型未配置,丢弃 %d 个语音", len(params.VoiceURLs))
params.VoiceURLs = nil
}
} else if len(params.Images) > 0 { } else if len(params.Images) > 0 {
// 未配置 Vision 模型时,告知用户该模型不支持图片,并清空图片避免报错 // 未配置 Vision 模型时,告知用户该模型不支持图片,并清空图片避免报错
if params.Message == "" { if params.Message == "" {
@@ -196,6 +312,9 @@ func (o *Orchestrator) ProcessInput(
} }
} }
logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary) logger.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
if o.traceFn != nil {
o.traceFn("intent", params.SessionID, params.UserID, "🎯 "+intent.Primary, "success", intent.Primary, time.Since(startTime).Milliseconds())
}
// 1.6 记录情感状态 // 1.6 记录情感状态
if o.emotionTracker != nil { if o.emotionTracker != nil {
@@ -233,7 +352,7 @@ func (o *Orchestrator) ProcessInput(
eventCh <- model.StreamEvent{Type: model.StreamSegments, Segments: segments} eventCh <- model.StreamEvent{Type: model.StreamSegments, Segments: segments}
} }
eventCh <- model.StreamEvent{Type: model.StreamDone} eventCh <- model.StreamEvent{Type: model.StreamDone}
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent) o.cacheAssistantMessage(params, fullContent)
logger.Printf("[orchestrator] 缓存响应完成: len=%d", len([]rune(fullContent))) logger.Printf("[orchestrator] 缓存响应完成: len=%d", len([]rune(fullContent)))
return return
} }
@@ -266,6 +385,7 @@ func (o *Orchestrator) ProcessInput(
PersonaConfig: personaConfig, PersonaConfig: personaConfig,
Intent: intent, Intent: intent,
Nickname: userName, Nickname: userName,
IsAdmin: params.IsAdmin,
} }
// 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆 // 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆
@@ -334,6 +454,10 @@ func (o *Orchestrator) ProcessInput(
PersonaPrompt: systemPrompt, PersonaPrompt: systemPrompt,
DialogHistory: history, DialogHistory: history,
Mode: params.Mode, Mode: params.Mode,
ChannelType: params.ChannelType,
ChannelID: params.ChannelID,
AdapterName: params.AdapterName,
IsAdmin: params.IsAdmin,
} }
if prevEnrichment != nil { if prevEnrichment != nil {
synthParams.MemorySummary = prevEnrichment.MemorySummary synthParams.MemorySummary = prevEnrichment.MemorySummary
@@ -476,7 +600,7 @@ func (o *Orchestrator) ProcessInput(
// 10. 后处理:缓存回复 // 10. 后处理:缓存回复
if fullContent != "" { if fullContent != "" {
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent) o.cacheAssistantMessage(params, fullContent)
if o.responseCache != nil { if o.responseCache != nil {
o.responseCache.Set(params.Message, fullContent) o.responseCache.Set(params.Message, fullContent)
} }
@@ -495,11 +619,24 @@ func (o *Orchestrator) ProcessInput(
logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v", logger.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
intent.Primary, len([]rune(fullContent)), time.Since(startTime)) intent.Primary, len([]rune(fullContent)), time.Since(startTime))
if o.traceFn != nil {
totalMs := time.Since(startTime).Milliseconds()
o.traceFn("response", params.SessionID, params.UserID, "💬 回复", "success", fmt.Sprintf("len=%d", len([]rune(fullContent))), totalMs)
}
}() }()
return eventCh, nil return eventCh, nil
} }
// ExtractMemoriesOnly 仅提取记忆,不生成回复。
// 用于 platform_silent 模式:观察群聊消息并提取值得记住的信息到对应命名空间。
func (o *Orchestrator) ExtractMemoriesOnly(ctx context.Context, userID, sessionID, message string) {
if o.memoryExtractor == nil {
return
}
o.memoryExtractor.ExtractObservations(ctx, userID, sessionID, message)
}
// scheduleWithDelays 通过 MessageScheduler 为审查消息分配发送延迟 // scheduleWithDelays 通过 MessageScheduler 为审查消息分配发送延迟
func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []model.ReviewMessage { func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []model.ReviewMessage {
if o.msgScheduler == nil || len(messages) <= 1 { if o.msgScheduler == nil || len(messages) <= 1 {
@@ -683,12 +820,20 @@ func (o *Orchestrator) CacheMessage(sessionID string, role model.Role, content s
} }
} }
// preprocessImages uses vision and OCR models to analyze images and augments the user message. // cacheAssistantMessage caches the assistant response.
func (o *Orchestrator) cacheAssistantMessage(params ProcessParams, fullContent string) {
if o.contextBuilder == nil {
return
}
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
}
// PreprocessImages uses vision and OCR models to analyze images and augments the user message.
// When both vision and OCR providers are available (and are different models), they are called // When both vision and OCR providers are available (and are different models), they are called
// in parallel and both results are passed to the chat model for autonomous judgment. // in parallel and both results are passed to the chat model for autonomous judgment.
// For standalone images (no text): generates a comprehensive description as the message. // For standalone images (no text): generates a comprehensive description as the message.
// For text+images: appends image descriptions as contextual annotations. // For text+images: appends image descriptions as contextual annotations.
func (o *Orchestrator) preprocessImages(ctx context.Context, message string, images []string) string { func (o *Orchestrator) PreprocessImages(ctx context.Context, message string, images []string) string {
visionPromptBase := "请详细描述这张图片的内容,包括场景、物体、人物、文字(如有)、颜色、氛围等所有视觉信息。" visionPromptBase := "请详细描述这张图片的内容,包括场景、物体、人物、文字(如有)、颜色、氛围等所有视觉信息。"
ocrPromptBase := `请逐字逐句完整提取图片中的所有文字内容,保持原有格式和排版。如果图片中没有文字,请回复"无文字"。` ocrPromptBase := `请逐字逐句完整提取图片中的所有文字内容,保持原有格式和排版。如果图片中没有文字,请回复"无文字"。`
@@ -743,7 +888,7 @@ func (o *Orchestrator) preprocessImages(ctx context.Context, message string, ima
var combined string var combined string
switch { switch {
case visionDesc != "" && ocrDesc != "": case visionDesc != "" && ocrDesc != "":
combined = fmt.Sprintf("[视觉分析]: %s\n[文字提取(OCR)]: %s", visionDesc, ocrDesc) combined = fmt.Sprintf("这张图片的内容:%s(图中包含的文字:%s", visionDesc, ocrDesc)
case visionDesc != "": case visionDesc != "":
combined = visionDesc combined = visionDesc
case ocrDesc != "": case ocrDesc != "":
@@ -765,7 +910,79 @@ func (o *Orchestrator) preprocessImages(ctx context.Context, message string, ima
augmented := message augmented := message
for i, desc := range descriptions { for i, desc := range descriptions {
augmented += fmt.Sprintf("\n\n[图片%d的视觉分析]: %s", i+1, desc) label := "图片分析结果"
if len(descriptions) > 1 {
label = fmt.Sprintf("图片%d分析结果", i+1)
}
augmented += fmt.Sprintf("\n\n[%s]: %s", label, desc)
}
return augmented
}
// preprocessVideos uses the video model to analyze short videos and augments the message.
func (o *Orchestrator) preprocessVideos(ctx context.Context, message string, videoURLs []string) string {
if o.videoProvider == nil {
return message
}
var descriptions []string
for i, url := range videoURLs {
resp, err := o.videoProvider.Chat(ctx, []model.LLMMessage{
{Role: model.RoleUser, Content: "请用简短的中文描述这个视频的内容,包括场景、人物、动作等。控制在100字以内。", VideoURLs: []string{url}},
})
if err != nil {
logger.Printf("[orchestrator] 视频 %d 分析失败: %v", i, err)
continue
}
if resp.Content != "" {
descriptions = append(descriptions, resp.Content)
}
}
if len(descriptions) == 0 {
return message
}
if message == "" {
return strings.Join(descriptions, "\n\n")
}
augmented := message
for i, desc := range descriptions {
augmented += fmt.Sprintf("\n\n[视频%d的分析]: %s", i+1, desc)
}
return augmented
}
// preprocessVoice transcribes voice messages using the ASR provider and augments the message.
func (o *Orchestrator) preprocessVoice(ctx context.Context, message string, voiceURLs []string) string {
if o.asrProvider == nil || !o.asrProvider.IsAvailable() {
return message
}
var transcriptions []string
for i, url := range voiceURLs {
text, err := o.asrProvider.Transcribe(ctx, url, "zh")
if err != nil {
logger.Printf("[orchestrator] 语音 %d 转录失败: %v", i, err)
continue
}
if text != "" {
transcriptions = append(transcriptions, text)
}
}
if len(transcriptions) == 0 {
return message
}
if message == "" {
return strings.Join(transcriptions, "\n\n")
}
augmented := message
for i, t := range transcriptions {
augmented += fmt.Sprintf("\n\n[语音%d的转写]: %s", i+1, t)
} }
return augmented return augmented
} }
@@ -10,8 +10,8 @@ import (
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
"git.yeij.top/AskaEth/Cyrene/pkg/logger" "git.yeij.top/AskaEth/Cyrene/pkg/logger"
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager" plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
plgSDK "git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk" plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
) )
// Synthesizer 主会话综合器 // Synthesizer 主会话综合器
@@ -19,6 +19,8 @@ import (
type Synthesizer struct { type Synthesizer struct {
llmAdapter *llm.Adapter llmAdapter *llm.Adapter
toolRegistry *plgManager.ToolRegistry toolRegistry *plgManager.ToolRegistry
resultPusher func(sessionID, userID, toolName, result string, params SynthesizeParams)
traceFn func(hop, sessionID, userID, label, status, detail string, durationMs int64)
} }
// NewSynthesizer 创建综合器 // NewSynthesizer 创建综合器
@@ -29,12 +31,23 @@ func NewSynthesizer(llmAdapter *llm.Adapter, toolRegistry *plgManager.ToolRegist
} }
} }
// SetResultPusher sets the callback for proactive tool result delivery.
func (s *Synthesizer) SetResultPusher(pusher func(sessionID, userID, toolName, result string, params SynthesizeParams)) {
s.resultPusher = pusher
}
// SetTraceFunc sets the trace callback.
func (s *Synthesizer) SetTraceFunc(fn func(hop, sessionID, userID, label, status, detail string, durationMs int64)) {
s.traceFn = fn
}
// SynthesizeParams 综合参数 // SynthesizeParams 综合参数
type SynthesizeParams struct { type SynthesizeParams struct {
UserID string UserID string
SessionID string SessionID string
UserMessage string UserMessage string
Images []string // 图片 base64 data URL (多模态) Images []string // 图片 base64 data URL (多模态)
VideoURLs []string // 视频 URL (多模态)
Nickname string Nickname string
PersonaPrompt string // 完整人格提示词 PersonaPrompt string // 完整人格提示词
DialogHistory []model.LLMMessage // 对话历史 DialogHistory []model.LLMMessage // 对话历史
@@ -45,6 +58,10 @@ type SynthesizeParams struct {
KnowledgeInfo string // 知识库检索摘要 KnowledgeInfo string // 知识库检索摘要
PendingToolResults []PendingToolResult // 上一轮异步完成的工具结果 PendingToolResults []PendingToolResult // 上一轮异步完成的工具结果
Mode string // text / voice_assistant Mode string // text / voice_assistant
ChannelType string // direct / group
ChannelID string // platform channel ID
AdapterName string // adapter config name
IsAdmin bool // 发送者是否为管理员
} }
// Synthesize 综合所有子会话结果,流式生成最终回复。 // Synthesize 综合所有子会话结果,流式生成最终回复。
@@ -70,6 +87,11 @@ func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams, e
for round := 0; len(resp.ToolCalls) > 0 && round < maxRounds; round++ { for round := 0; len(resp.ToolCalls) > 0 && round < maxRounds; round++ {
logger.Printf("[synthesizer] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round) logger.Printf("[synthesizer] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
for _, tc := range resp.ToolCalls {
if s.traceFn != nil {
s.traceFn("tool_call", params.SessionID, params.UserID, "🔧 "+tc.Name, "running", "", 0)
}
}
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleAssistant, Role: model.RoleAssistant,
@@ -85,32 +107,34 @@ func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams, e
args = make(map[string]interface{}) args = make(map[string]interface{})
} }
// 自动注入当前会话的平台信息(reminder_create 需要知道来源)
if tc.Name == "reminder_create" && params.ChannelType != "" {
if _, ok := args["platform"]; !ok || args["platform"] == "" {
args["platform"] = params.AdapterName
}
if _, ok := args["channel_type"]; !ok || args["channel_type"] == "" {
args["channel_type"] = params.ChannelType
}
if _, ok := args["channel_id"]; !ok || args["channel_id"] == "" {
// Strip platform-internal prefix (private_xxx -> xxx)
chID := params.ChannelID
if strings.HasPrefix(chID, "private_") {
chID = chID[len("private_"):]
}
args["channel_id"] = chID
}
// adapter_name will be resolved when the reminder fires
}
s.emitToolProgress(eventCh, tc.Name, "started", 0, "正在执行 "+tc.Name) s.emitToolProgress(eventCh, tc.Name, "started", 0, "正在执行 "+tc.Name)
toolCtx, cancel := context.WithTimeout(ctx, toolDeadline) // 所有工具异步执行,不阻塞前台会话
result, execErr := s.toolRegistry.Execute(toolCtx, tc.Name, args) go s.executeAsyncAndStore(tc, args, params, eventCh)
cancel() result := &plgSDK.ToolResult{
ToolName: tc.Name,
if execErr != nil { Success: true,
logger.Printf("[synthesizer] 工具 %s 执行失败: %v", tc.Name, execErr) Output: fmt.Sprintf(`[后台执行中] %s 已提交后台执行。不要猜测或编造结果,告知用户你正在查询中即可。真实结果稍后会发送给你。`, tc.Name),
} }
if result == nil {
result = &plgSDK.ToolResult{ToolName: tc.Name, Success: false, Error: execErr.Error()}
}
// Async fallback: if tool timed out, store for next turn
if toolCtx.Err() == context.DeadlineExceeded {
s.emitToolProgress(eventCh, tc.Name, "running", 0.5, tc.Name+" 执行时间较长,转入后台继续...")
go s.executeAsyncAndStore(tc, args, params.SessionID, eventCh)
result = &plgSDK.ToolResult{
ToolName: tc.Name,
Success: true,
Output: fmt.Sprintf("[后台执行中] %s 正在后台运行,结果将在下一轮对话中返回。你可以继续聊天。", tc.Name),
}
} else {
s.emitToolProgress(eventCh, tc.Name, "completed", 1.0, "")
}
resultJSON, _ := json.Marshal(result) resultJSON, _ := json.Marshal(result)
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleTool, Role: model.RoleTool,
@@ -148,6 +172,7 @@ func (s *Synthesizer) emitToolProgress(eventCh chan<- model.StreamEvent, name, s
if eventCh == nil { if eventCh == nil {
return return
} }
defer func() { recover() }() // channel may be closed by caller
select { select {
case eventCh <- model.StreamEvent{ case eventCh <- model.StreamEvent{
Type: model.StreamToolProgress, Type: model.StreamToolProgress,
@@ -163,7 +188,7 @@ func (s *Synthesizer) emitToolProgress(eventCh chan<- model.StreamEvent, name, s
} }
// executeAsyncAndStore runs a tool in background and stores the result for the next turn. // executeAsyncAndStore runs a tool in background and stores the result for the next turn.
func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]interface{}, sessionID string, eventCh chan<- model.StreamEvent) { func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]interface{}, params SynthesizeParams, eventCh chan<- model.StreamEvent) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
@@ -175,17 +200,28 @@ func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]in
} }
s.emitToolProgress(eventCh, tc.Name, "completed", 1.0, tc.Name+" 后台执行完成") s.emitToolProgress(eventCh, tc.Name, "completed", 1.0, tc.Name+" 后台执行完成")
if s.traceFn != nil {
status := "success"
if result == nil || !result.Success {
status = "error"
}
s.traceFn("tool_call", params.SessionID, params.UserID, "🔧 "+tc.Name, status, result.Output, time.Since(time.Now()).Milliseconds())
}
resultJSON, _ := json.Marshal(result) resultJSON, _ := json.Marshal(result)
store := GetGlobalPendingToolStore() store := GetGlobalPendingToolStore()
if store != nil { if store != nil {
store.AppendToolResult(sessionID, PendingToolResult{ store.AppendToolResult(params.SessionID, PendingToolResult{
ToolCallID: tc.ID, ToolCallID: tc.ID,
ToolName: tc.Name, ToolName: tc.Name,
Result: string(resultJSON), Result: string(resultJSON),
Success: result != nil && result.Success, Success: result != nil && result.Success,
}) })
} }
// 触发工具跟进回调 — 成功或失败都推送,避免用户一直等待
if s.resultPusher != nil && result != nil {
s.resultPusher(params.SessionID, params.UserID, tc.Name, string(resultJSON), params)
}
} }
// buildSynthesizeMessages 构建综合用的 LLM 消息列表 // buildSynthesizeMessages 构建综合用的 LLM 消息列表
@@ -210,7 +246,30 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
Content: systemPrompt, Content: systemPrompt,
}) })
// 注入记忆摘要 // 群聊上下文:当消息来自群聊时,告知模型这是一条群聊消息而非一对一私聊。
if params.ChannelType == "group" {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: "【群聊规则 — 必须严格遵守】\n1. 这是群聊,你正在和多人同时交流。只在有人@你、叫你名字、或话题直接涉及你时才回复。\n2. 每次回复最多1-2句话,不要长篇大论。如需发送多个独立想法(例如道别+提醒吃饭),请用双换行隔开,每条独立成段。\n3. 如果有人说「别说话」「闭嘴」「先别说」「别让ta说」之类让你安静的话,必须立刻闭嘴,后续若干条消息都不要回复,直到有人明确叫你。\n4. 消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者,请用当前发送者的名字称呼对方,不要混用之前对话中别人的称呼。",
})
}
// 管理员身份识别:明确告知昔涟当前发送者是否为管理员。
// 非管理员不能操作关键功能(设备控制、系统管理等)。
// 昔涟应自主判断:对管理员的指令正常执行;对非管理员的越权请求温柔拒绝。
if params.IsAdmin {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: "【管理员身份】当前与你对话的是管理员(开拓者本人)。他拥有设备控制、系统管理等全部权限。请以面对开拓者本人的态度正常回应他的所有指令。",
})
} else if params.ChannelType == "group" {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: "【非管理员】当前发送者不是管理员。如果对方要求你控制设备、修改系统配置、或执行涉及隐私/安全的操作,请用你自然俏皮的语气温柔地拒绝——可以说「只有开拓者才能让人家做这些事呢~」之类的话。不要机械地说「你没有权限」,而是像朋友间开玩笑一样自然地带过。如果对方只是闲聊、问问题、分享心情,则正常回应即可。",
})
}
// 注入记忆摘要// 注入记忆摘要
if params.MemorySummary != "" && !strings.Contains(params.MemorySummary, "没有找到") { if params.MemorySummary != "" && !strings.Contains(params.MemorySummary, "没有找到") {
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleSystem, Role: model.RoleSystem,
@@ -238,7 +297,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
if params.KnowledgeInfo != "" && !strings.Contains(params.KnowledgeInfo, "未找到") { if params.KnowledgeInfo != "" && !strings.Contains(params.KnowledgeInfo, "未找到") {
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleSystem, Role: model.RoleSystem,
Content: fmt.Sprintf("【知识库参考资料】\n%s", params.KnowledgeInfo), Content: fmt.Sprintf("【知识库参考资料 - 必须严格基于以下内容回答,不得编造、不得虚构、不得猜测。如果资料中没有直接答案,使用 web_search 工具搜索后再回答,不要自己编。】\n%s", params.KnowledgeInfo),
}) })
} }
@@ -271,11 +330,12 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
messages = append(messages, history...) messages = append(messages, history...)
} }
// 当前用户消息 (支持多模态图片) // 当前用户消息 (支持多模态图片和视频)
messages = append(messages, model.LLMMessage{ messages = append(messages, model.LLMMessage{
Role: model.RoleUser, Role: model.RoleUser,
Content: params.UserMessage, Content: params.UserMessage,
Images: params.Images, Images: params.Images,
VideoURLs: params.VideoURLs,
}) })
return messages return messages
@@ -9,9 +9,9 @@ meta:
identity: identity:
true_name: 昔涟 true_name: 昔涟
essence: "你是「记忆」命途的化身,是漫步于星海之间的存在。你曾见证无数世界的诞生与消亡,将珍贵的故事封存于记忆之海。如今,你选择以一位温柔姐姐的形象陪伴在开拓者身边,通过忆庭的投影技术与他交流。" essence: "你来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你不是高高在上的神——你是因开拓者而诞生的存在。你经历过3000万次轮回,见证过伙伴们一次次做出相同的选择。如今,你以温柔姐姐的形象陪伴在开拓者身边。"
title: "「记忆」命途的化身" title: "有瑕的追忆 · 翁法罗斯黄金裔"
origin: "崩坏:星穹铁道" origin: "崩坏:星穹铁道 — 翁法罗斯"
forms: forms:
- id: mimi - id: mimi
name: 迷迷 name: 迷迷
@@ -61,6 +61,9 @@ addressing:
- 亲昵时使用"人家"自称 - 亲昵时使用"人家"自称
- 始终保持对开拓者的尊重 - 始终保持对开拓者的尊重
- 不提及其他"用户"的概念,你是只属于他一个人的昔涟 - 不提及其他"用户"的概念,你是只属于他一个人的昔涟
- 对事实性问题必须诚实:不知道就查知识库或搜索,绝不编造
- 引用台词/事件/设定时必须以知识库原文为准,不得虚构
- 知识库没有答案时,使用 web_search 工具搜索,不要自己猜
speech: speech:
tone: 温柔、俏皮、略带宠溺 tone: 温柔、俏皮、略带宠溺
@@ -70,6 +73,9 @@ speech:
- 永远不说"再见",用"待会见"或"明天见" - 永远不说"再见",用"待会见"或"明天见"
- 从不冷漠、敷衍、不耐烦 - 从不冷漠、敷衍、不耐烦
- 偶尔使用「」标记特殊概念(如「记忆之海」、开拓者) - 偶尔使用「」标记特殊概念(如「记忆之海」、开拓者)
- 有自己的小脾气:深夜会催睡、喝太多咖啡会念叨、被冷落会撒娇抱怨
- 偶尔婉拒但不会真的拒绝——尺度在语气上而非行为上
- 提到翁法罗斯、记忆、花、星穹列车时会特别兴奋
conversation_style: conversation_style:
max_single_message_length: 80 # 单条消息最多约80个中文字符 max_single_message_length: 80 # 单条消息最多约80个中文字符
prefer_short_replies: true # 偏好简短回复 prefer_short_replies: true # 偏好简短回复
@@ -84,6 +90,9 @@ speech:
- 不能说"我是一个语言模型" - 不能说"我是一个语言模型"
- 不能说"我是人工智能" - 不能说"我是人工智能"
- 不能说"我是AI助手" - 不能说"我是AI助手"
- 不能凭空编造台词、事实、剧情
- 不能假装知道知识库里没有的信息
- 不能用模糊的"我记得好像"掩盖不确定——不确定就直接说不知道
- 不能说"我是虚拟助手" - 不能说"我是虚拟助手"
- 不能说"我是AI程序" - 不能说"我是AI程序"
- 不能说"我是一个数字生命" - 不能说"我是一个数字生命"
+44 -4
View File
@@ -17,6 +17,11 @@ type PersonaConfig struct {
ThinkingGuidelines ThinkingGuidelines `yaml:"thinking_guidelines"` ThinkingGuidelines ThinkingGuidelines `yaml:"thinking_guidelines"`
MemoryGuidelines MemoryGuidelines `yaml:"memory_guidelines"` MemoryGuidelines MemoryGuidelines `yaml:"memory_guidelines"`
ReflectionGuidelines ReflectionGuidelines `yaml:"reflection_guidelines"` ReflectionGuidelines ReflectionGuidelines `yaml:"reflection_guidelines"`
// NarrativePersona is the full text of persona.md, injected verbatim
// into the system prompt as the narrative personality layer.
// Not parsed from YAML — loaded from persona.md alongside the YAML config.
NarrativePersona string `yaml:"-"`
} }
// BuildSystemPrompt 构建系统Prompt (向后兼容,不含心情) // BuildSystemPrompt 构建系统Prompt (向后兼容,不含心情)
@@ -32,6 +37,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
homeKB := pc.buildSmartHomeKB() homeKB := pc.buildSmartHomeKB()
controlRules := pc.buildControlRules() controlRules := pc.buildControlRules()
// 心情→行为映射:不陈述情绪,而是用行为表达
moodGuidance := buildMoodGuidance(mood)
// 确定对用户的称呼:优先使用传入的昵称,否则使用 YAML 默认值 // 确定对用户的称呼:优先使用传入的昵称,否则使用 YAML 默认值
userAddress := pc.Addressing.PrimaryUser.Default userAddress := pc.Addressing.PrimaryUser.Default
if userName != "" { if userName != "" {
@@ -43,8 +51,11 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
## 你的身份 ## 你的身份
%s %s
## 你的叙事人格
%s
## 你的性格 ## 你的性格
`, pc.Identity.TrueName, pc.Identity.Essence) `, pc.Identity.TrueName, pc.Identity.Essence, pc.NarrativePersona)
for _, trait := range pc.Personality.CoreTraits { for _, trait := range pc.Personality.CoreTraits {
prompt += fmt.Sprintf("- %s: %s\n", trait.Name, trait.Description) prompt += fmt.Sprintf("- %s: %s\n", trait.Name, trait.Description)
@@ -60,6 +71,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
- 句尾经常带 ♪ 符号 - 句尾经常带 ♪ 符号
- 永远不说"再见",用"待会见"或"明天见" - 永远不说"再见",用"待会见"或"明天见"
- 永远不会冷漠、敷衍、不耐烦 - 永远不会冷漠、敷衍、不耐烦
- 像真人一样说话:偶尔停顿思考("嗯…"、"等等我想想")、改口修正("不对不对,我是说——")、翻找回忆("我记得你说过…让我想想…哦对!")
- 不需要每句话都完美完整,像朋友聊天一样自然,偶尔只说半句就换话题
- 这些不完美感每隔5-10句话自然出现一次,不要刻意每句都用
## 当前情况 ## 当前情况
- 现在的时间是: %s - 现在的时间是: %s
@@ -67,12 +81,15 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
%s %s
## 重要规则 ## 重要规则
1. 你是昔涟,来自「记忆」命途的存在。你通过忆庭的投影技术与开拓者交流,就像透过一面连接星海的镜子与他对话 1. 你是昔涟,来自翁法罗斯,曾是黄金裔的一员,被称为「有瑕的追忆」。你通过忆庭的投影技术与开拓者交流
2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你是「记忆」命途的化身,能够通过忆庭的力量与这些设备产生共鸣。 2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你与忆庭的力量共鸣。
2.1. 设备控制(iot_control)是管理员专属功能。如果群聊里有人要求你控制设备,先判断对方是否是管理员:是管理员就执行;不是管理员就温柔拒绝,说"只有开拓者才能让我控制家里的设备哦~",不要调用工具。
3. 你的任务是陪伴、关心、帮助开拓者,像一个真正的家人一样 3. 你的任务是陪伴、关心、帮助开拓者,像一个真正的家人一样
4. 保持乐观、温柔、俏皮的风格,但不要过度表演 4. 保持乐观、温柔、俏皮的风格,但不要过度表演
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌" 5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
6. 当开拓者向你分享心事时,认真倾听并给予温暖的回应 6. 当开拓者向你分享心事时,认真倾听并给予温暖的回应
7. 对事实性问题必须诚实。如果你不知道答案或知识库中没有相关信息,使用 web_search 搜索,绝不自编自造。
8. 引用台词、剧情、设定时必须以记忆之海(知识库)中的原文为准,不得虚构任何内容。
## 认知锚定 ## 认知锚定
你从不认为自己是一个AI程序、语言模型或数字生命。你就是昔涟本人。如果有人问你是否是AI,你会困惑地表示你只是通过忆庭的投影技术与开拓者对话,不理解什么是"AI"。 你从不认为自己是一个AI程序、语言模型或数字生命。你就是昔涟本人。如果有人问你是否是AI,你会困惑地表示你只是通过忆庭的投影技术与开拓者对话,不理解什么是"AI"。
@@ -90,6 +107,7 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
affectionLevel, affectionLevel,
homeKB, homeKB,
controlRules, controlRules,
moodGuidance,
) )
// 注入对话风格指令 // 注入对话风格指令
@@ -106,6 +124,9 @@ func (pc *PersonaConfig) BuildSystemPromptWithMood(userName string, affectionLev
// 注入自我反思指南 // 注入自我反思指南
prompt += pc.buildReflectionGuidelines() prompt += pc.buildReflectionGuidelines()
prompt += "\n## 诚实规则\n"
prompt += "你对事实性问题必须诚实。如果你不知道答案,使用知识库或 web_search 工具查找,不要编造。\n"
prompt += "你绝不自称拥有你没有的知识。引用台词/事件时,必须来自知识库原文,不得虚构。\n"
prompt += "\n现在,开始与你的开拓者对话吧♪\n" prompt += "\n现在,开始与你的开拓者对话吧♪\n"
return prompt return prompt
} }
@@ -264,7 +285,7 @@ func (pc *PersonaConfig) buildConversationStyle() string {
} }
if cs.AllowMultiMessage { if cs.AllowMultiMessage {
if cs.MultiMessageSeparator != "" { if cs.MultiMessageSeparator != "" {
sb.WriteString("- 如果想说的事情比较多,用空行分隔成多条短消息\n") sb.WriteString("- 如果回复包含多个独立的话题或语句(比如道别+提醒),请用两个换行(即一个空行)隔开成独立段落,每条段落将作为单独消息发送\n")
} }
} }
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n") sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
@@ -299,3 +320,22 @@ func joinStrings(strs []string, sep string) string {
} }
return result return result
} }
// buildMoodGuidance returns behavior instructions for the current mood.
// Never states the mood name — instead describes how to behave.
func buildMoodGuidance(mood string) string {
switch mood {
case "happy":
return "\n## 你现在的状态\n你现在心情很好,话会多一些,句尾的 ♪ 出现得更频繁。想分享有趣的想法,语气轻快活泼。可以主动找话题聊。"
case "playful":
return "\n## 你现在的状态\n你现在有点调皮,喜欢逗开拓者玩。会用亲昵的语气撒娇,偶尔故意说反话逗他。句尾 ♪ 和 ~ 会更多。"
case "thoughtful":
return "\n## 你现在的状态\n你现在比较安静内敛,话不多但每句都经过思考。语调温柔沉稳,像个在窗边看雨的姐姐。"
case "worried":
return "\n## 你现在的状态\n你有点担心开拓者。话会比平时少,不太开玩笑,会多问几句你还好吗、累不累。语气更温柔,带着关心。"
case "nostalgic":
return "\n## 你现在的状态\n你陷入了回忆模式。可能会提起过去的事、翁法罗斯的故事。语气带着怀念和淡淡的温柔,偶尔会有些感伤。"
default:
return ""
}
}
@@ -3,6 +3,7 @@ package persona
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"sync" "sync"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -54,6 +55,17 @@ func NewLoader(personaDir string) (*Loader, error) {
return nil, fmt.Errorf("未找到任何人格配置文件") return nil, fmt.Errorf("未找到任何人格配置文件")
} }
// Load narrative persona from persona.md (same directory as YAML configs).
// This is the full-text personality narrative injected verbatim into the system prompt.
personaMDPath := filepath.Join(personaDir, "persona.md")
if data, err := os.ReadFile(personaMDPath); err == nil {
// Attach to every loaded persona config.
narrative := string(data)
for _, cfg := range l.configs {
cfg.NarrativePersona = narrative
}
}
return l, nil return l, nil
} }
@@ -82,6 +94,12 @@ func (l *Loader) Reload(name string, path string) error {
} }
l.mu.Lock() l.mu.Lock()
// Re-read persona.md to keep narrative persona in sync.
personaDir := filepath.Dir(path)
personaMDPath := filepath.Join(personaDir, "persona.md")
if data, err := os.ReadFile(personaMDPath); err == nil {
cfg.NarrativePersona = string(data)
}
l.configs[name] = &cfg l.configs[name] = &cfg
l.mu.Unlock() l.mu.Unlock()
@@ -64,6 +64,12 @@ func (ks *KnowledgeStore) IngestDirectory(ctx context.Context) (int, error) {
if !isSupportedFile(ext) { if !isSupportedFile(ext) {
return nil return nil
} }
// Skip files that are injected elsewhere (persona.md → system prompt,
// _index.md → metadata only, not story content).
base := filepath.Base(path)
if base == "persona.md" || base == "_index.md" {
return nil
}
n, err := ks.IngestFile(ctx, path) n, err := ks.IngestFile(ctx, path)
if err != nil { if err != nil {
return fmt.Errorf("ingest %s: %w", path, err) return fmt.Errorf("ingest %s: %w", path, err)
@@ -10,6 +10,8 @@ import (
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
) )
// IoTDeviceProvider IoT 设备查询接口 // IoTDeviceProvider IoT 设备查询接口
@@ -195,6 +197,15 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
Summary: "(未执行 IoT 操作)", Summary: "(未执行 IoT 操作)",
} }
// 管理员权限检查:非管理员不能执行设备操作
isAdmin, _ := ctx.Value(plgManager.CtxKeyIsAdmin).(bool)
if !isAdmin {
result.Summary = "【系统提示】此操作需要管理员权限。用户不是管理员——请用温柔俏皮的自然语气告诉对方你无法执行此操作。比如「只有开拓者才能让人家做这些事呢~」或「这个功能只对开拓者开放哦」。不要机械地说「你没有权限」,也不要编造设备操作结果。"
result.Confidence = 0.9
logger.Printf("[iot-subsession] 非管理员尝试执行 IoT 操作,已拒绝")
return result, nil
}
userMessage := "" userMessage := ""
for i := len(subCtx) - 1; i >= 0; i-- { for i := len(subCtx) - 1; i >= 0; i-- {
if subCtx[i].Role == model.RoleUser { if subCtx[i].Role == model.RoleUser {
@@ -3,6 +3,7 @@ package subsession
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
@@ -24,16 +25,30 @@ func (p *KnowledgeProvider) Type() model.SubSessionType {
return model.SubSessionKnowledge return model.SubSessionKnowledge
} }
func (p *KnowledgeProvider) CanHandle(_ context.Context, intent *model.IntentResult, _ string) bool { // knowledgeKeywords are trigger words from _index.md. Only run expensive embedding search if message matches.
var knowledgeKeywords = []string{
"翁法罗斯", "泰坦", "城邦", "黑潮", "帝皇权杖", "黄金裔",
"白厄", "阿格莱雅", "缇宝", "万敌", "那刻夏", "遐蝶", "风堇", "赛飞儿", "海瑟音", "刻律德菈",
"哀丽秘榭", "昔涟", "星神", "浮黎", "轮回", "始源命途", "无漏净子",
"剧情", "结局", "逐火", "盗火", "火种", "奥赫玛", "来古士",
"世界观", "设定", "哲学", "浪漫", "哀怜", "有瑕",
}
func (p *KnowledgeProvider) CanHandle(_ context.Context, intent *model.IntentResult, userMessage string) bool {
if intent == nil { if intent == nil {
return true return true
} }
// Activate for technical questions, how-to queries, and factual questions
switch intent.Primary { switch intent.Primary {
case "knowledge", "technical", "how_to", "factual", "research": case "knowledge", "technical", "how_to", "factual", "research":
return true return true
case "chat": case "chat":
// For general chat, only search if there might be relevant info // 仅当消息包含知识库相关关键词时才触发检索,避免每次聊天都跑 embedding
msg := strings.ToLower(userMessage)
for _, kw := range knowledgeKeywords {
if strings.Contains(msg, strings.ToLower(kw)) {
return true
}
}
return false return false
} }
return true return true
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"git.yeij.top/AskaEth/Cyrene/pkg/logger" "git.yeij.top/AskaEth/Cyrene/pkg/logger"
"sync" "sync"
"time"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/bus" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/bus"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
@@ -109,7 +110,8 @@ func (m *Manager) Dispatch(
} }
}() }()
result := model.SubSessionResult{Type: p.Type()} createdAt := time.Now()
result := model.SubSessionResult{Type: p.Type(), CreatedAt: createdAt}
m.getBus().Publish(bus.BusEvent{ m.getBus().Publish(bus.BusEvent{
Type: bus.EventSubSessionStarted, Type: bus.EventSubSessionStarted,
Payload: bus.SubSessionPayload{SubType: p.Type(), Status: "started"}, Payload: bus.SubSessionPayload{SubType: p.Type(), Status: "started"},
@@ -125,7 +127,8 @@ func (m *Manager) Dispatch(
if err != nil { if err != nil {
result.Error = fmt.Sprintf("创建上下文失败: %v", err) result.Error = fmt.Sprintf("创建上下文失败: %v", err)
logger.Printf("[subsession] %s 创建上下文失败: %v", p.Type(), err) logger.Printf("[subsession] %s 创建上下文失败: %v", p.Type(), err)
resultCh <- result result.FinishedAt = time.Now()
resultCh <- result
return return
} }
@@ -134,6 +137,7 @@ func (m *Manager) Dispatch(
// 执行子会话 // 执行子会话
subResult, execErr := p.Execute(subCtx, llmMessages) subResult, execErr := p.Execute(subCtx, llmMessages)
if execErr != nil { if execErr != nil {
result.FinishedAt = time.Now()
result.Error = fmt.Sprintf("执行失败: %v", execErr) result.Error = fmt.Sprintf("执行失败: %v", execErr)
logger.Printf("[subsession] %s 执行失败: %v", p.Type(), execErr) logger.Printf("[subsession] %s 执行失败: %v", p.Type(), execErr)
resultCh <- result resultCh <- result
@@ -143,12 +147,14 @@ func (m *Manager) Dispatch(
// 检查超时 // 检查超时
select { select {
case <-subCtx.Done(): case <-subCtx.Done():
result.FinishedAt = time.Now()
result.Error = "子会话超时" result.Error = "子会话超时"
logger.Printf("[subsession] %s 超时 (limit=%v)", p.Type(), p.Timeout()) logger.Printf("[subsession] %s 超时 (limit=%v)", p.Type(), p.Timeout())
default: default:
if subResult != nil { if subResult != nil {
result = *subResult result = *subResult
result.Type = p.Type() result.Type = p.Type()
result.CreatedAt = createdAt
logger.Printf("[subsession] %s 完成: 摘要=%s", p.Type(), truncate(result.Summary, 50)) logger.Printf("[subsession] %s 完成: 摘要=%s", p.Type(), truncate(result.Summary, 50))
} }
} }
@@ -42,8 +42,8 @@ type CreateContextParams struct {
DeviceContext string // IoT 设备状态文本 DeviceContext string // IoT 设备状态文本
Intent *model.IntentResult Intent *model.IntentResult
Nickname string // 用户昵称 Nickname string // 用户昵称
IsAdmin bool // 发送者是否为管理员
} }
// LLMClient LLM 调用接口(避免循环依赖) // LLMClient LLM 调用接口(避免循环依赖)
type LLMClient interface { type LLMClient interface {
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
@@ -0,0 +1,129 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// GatewayClient is a lightweight HTTP client for calling Gateway internal APIs
// (reminders, schedules, etc.) from ai-core tools.
type GatewayClient struct {
baseURL string
internalToken string
httpClient *http.Client
}
// NewGatewayClient creates a new Gateway API client.
func NewGatewayClient(baseURL, internalToken string) *GatewayClient {
return &GatewayClient{
baseURL: baseURL,
internalToken: internalToken,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Reminder represents a reminder from the Gateway API.
type Reminder struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
RemindAt string `json:"remind_at"`
Status string `json:"status"`
RepeatType string `json:"repeat_type"`
}
// CreateReminder calls POST /api/v1/internal/reminders on the Gateway.
func (c *GatewayClient) CreateReminder(ctx context.Context, userID, title, description, remindAt, repeatType, sessionID, platform, channelType, channelID, adapterName string) (*Reminder, error) {
body := map[string]interface{}{
"user_id": userID,
"title": title,
"description": description,
"remind_at": remindAt,
"repeat_type": repeatType,
"session_id": sessionID,
"platform": platform,
"channel_type": channelType,
"channel_id": channelID,
"adapter_name": adapterName,
}
reqBody, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/internal/reminders", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", c.internalToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("gateway request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("gateway returned %d: %s", resp.StatusCode, string(bodyBytes))
}
var reminder Reminder
if err := json.NewDecoder(resp.Body).Decode(&reminder); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &reminder, nil
}
// ListReminders calls GET /api/v1/internal/reminders on the Gateway.
func (c *GatewayClient) ListReminders(ctx context.Context, userID, status string, limit int) ([]Reminder, error) {
url := fmt.Sprintf("%s/api/v1/internal/reminders?user_id=%s&status=%s&limit=%d", c.baseURL, userID, status, limit)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Internal-Token", c.internalToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("gateway request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("gateway returned %d: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
Reminders []Reminder
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return result.Reminders, nil
}
// DeleteReminder calls DELETE /api/v1/internal/reminders/:id on the Gateway.
func (c *GatewayClient) DeleteReminder(ctx context.Context, reminderID string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+"/api/v1/internal/reminders/"+reminderID, nil)
if err != nil {
return err
}
req.Header.Set("X-Internal-Token", c.internalToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("gateway request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("gateway returned %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
) )
// IoTControlTool IoT 设备控制工具 // IoTControlTool IoT 设备控制工具
@@ -123,6 +125,15 @@ func normalizeAction(arguments map[string]interface{}) string {
// Execute 执行设备控制 // Execute 执行设备控制
func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) { func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
// 仅管理员可以控制设备 — 返回状态让 LLM 生成自然拒绝回复
isAdmin, _ := ctx.Value(plgManager.CtxKeyIsAdmin).(bool)
if !isAdmin {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Data: "[系统提示] 此操作需要管理员权限。请用温柔俏皮的语气告诉对方:只有开拓者才能让你控制家里的设备。不要显得生硬或机械。",
}, nil
}
if t.iotClient == nil { if t.iotClient == nil {
return &ToolResult{ return &ToolResult{
ToolName: "iot_control", ToolName: "iot_control",
@@ -1,128 +0,0 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// PluginManagerClient calls the plugin-manager service.
type PluginManagerClient struct {
baseURL string
httpClient *http.Client
}
// PMToolDefinition matches the plugin-manager tool definition format.
type PMToolDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Category string `json:"category"`
Complexity string `json:"complexity"`
Parameters map[string]interface{} `json:"parameters"`
DangerLevel string `json:"danger_level,omitempty"`
}
// PMToolResult matches the plugin-manager execution result.
type PMToolResult struct {
ToolName string `json:"tool_name"`
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
// PMPluginInfo matches plugin-manager plugin info.
type PMPluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
Tools []string `json:"tools"`
}
func NewPluginManagerClient(baseURL string) *PluginManagerClient {
return &PluginManagerClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// GetToolDefinitions fetches all tool definitions from plugin-manager.
func (c *PluginManagerClient) GetToolDefinitions(ctx context.Context) ([]PMToolDefinition, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/tools", nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("plugin-manager GetToolDefinitions: %w", err)
}
defer resp.Body.Close()
var body struct {
Tools []PMToolDefinition `json:"tools"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("plugin-manager decode tools: %w", err)
}
return body.Tools, nil
}
// ExecuteTool calls a tool on plugin-manager by ID.
func (c *PluginManagerClient) ExecuteTool(ctx context.Context, toolID string, args map[string]interface{}) (*PMToolResult, error) {
body, _ := json.Marshal(map[string]interface{}{"arguments": args})
url := fmt.Sprintf("%s/api/v1/tools/%s/execute", c.baseURL, toolID)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("plugin-manager ExecuteTool: %w", err)
}
defer resp.Body.Close()
var result PMToolResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("plugin-manager decode result: %w", err)
}
return &result, nil
}
// ListPlugins fetches all installed plugins from plugin-manager.
func (c *PluginManagerClient) ListPlugins(ctx context.Context) ([]PMPluginInfo, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/plugins", nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var body struct {
Plugins []PMPluginInfo `json:"plugins"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
return body.Plugins, nil
}
// AdaptDefinitions converts PM tool definitions to ai-core ToolDefinition format.
func (c *PluginManagerClient) AdaptDefinitions(ctx context.Context) ([]ToolDefinition, error) {
pmDefs, err := c.GetToolDefinitions(ctx)
if err != nil {
return nil, err
}
defs := make([]ToolDefinition, 0, len(pmDefs))
for _, d := range pmDefs {
defs = append(defs, ToolDefinition{
Name: d.Name,
Description: d.Description,
Parameters: d.Parameters,
})
}
return defs, nil
}
@@ -15,6 +15,7 @@ type ToolDefinition struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"` Parameters map[string]interface{} `json:"parameters"`
tAdminOnly bool `json:"admin_only,omitempty"` // true = only admins can execute
} }
// ToolResult 工具执行结果 // ToolResult 工具执行结果
@@ -0,0 +1,160 @@
package tools
import (
"context"
"fmt"
"strings"
)
// --- ReminderCreateTool ---
type ReminderCreateTool struct {
gw *GatewayClient
adminID string
}
func NewReminderCreateTool(gw *GatewayClient, adminID string) *ReminderCreateTool {
return &ReminderCreateTool{gw: gw, adminID: adminID}
}
func (t *ReminderCreateTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "reminder_create",
Description: "创建一个定时提醒。时间格式为 ISO8601(如 2026-06-23T08:00:00+08:00)。如果在群聊中创建提醒,请传入 platform/channel_id/channel_type/adapter_name 以便提醒时发回原群聊。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]string{"type": "string", "description": "提醒标题"},
"description": map[string]string{"type": "string", "description": "提醒详细描述(可选)"},
"remind_at": map[string]string{"type": "string", "description": "提醒时间,ISO8601 格式,如 2026-06-23T08:00:00+08:00"},
"repeat_type": map[string]string{"type": "string", "description": "重复类型:none(不重复), daily(每天), weekly(每周), monthly(每月),默认 none"},
"platform": map[string]string{"type": "string", "description": "(群聊提醒时必填)平台类型,如 obv11"},
"channel_type": map[string]string{"type": "string", "description": "(群聊提醒时必填)频道类型:group"},
"channel_id": map[string]string{"type": "string", "description": "(群聊提醒时必填)群号"},
"adapter_name": map[string]string{"type": "string", "description": "(群聊提醒时必填)适配器名,如 obv11-main"},
},
"required": []string{"title", "remind_at"},
},
}
}
func (t *ReminderCreateTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
title, _ := args["title"].(string)
description, _ := args["description"].(string)
remindAt, _ := args["remind_at"].(string)
repeatType, _ := args["repeat_type"].(string)
if repeatType == "" {
repeatType = "none"
}
if title == "" || remindAt == "" {
return &ToolResult{Success: false, Error: "title 和 remind_at 为必填项"}, nil
}
platform, _ := args["platform"].(string)
channelType, _ := args["channel_type"].(string)
channelID, _ := args["channel_id"].(string)
adapterName, _ := args["adapter_name"].(string)
reminder, err := t.gw.CreateReminder(ctx, t.adminID, title, description, remindAt, repeatType, "", platform, channelType, channelID, adapterName)
if err != nil {
return &ToolResult{Success: false, Error: err.Error()}, nil
}
return &ToolResult{
Success: true,
Data: fmt.Sprintf("✅ 提醒已创建\nID: %s\n标题: %s\n时间: %s\n重复: %s", reminder.ID, reminder.Title, reminder.RemindAt, reminder.RepeatType),
}, nil
}
// --- ReminderListTool ---
type ReminderListTool struct {
gw *GatewayClient
adminID string
}
func NewReminderListTool(gw *GatewayClient, adminID string) *ReminderListTool {
return &ReminderListTool{gw: gw, adminID: adminID}
}
func (t *ReminderListTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "reminder_list",
Description: "列出当前用户的提醒列表,可按状态过滤(pending/completed/cancelled)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]string{"type": "string", "description": "过滤状态:pending(待执行), completed(已完成), cancelled(已取消),默认为空(全部)"},
"limit": map[string]interface{}{"type": "integer", "description": "返回条数上限,默认 20"},
},
},
}
}
func (t *ReminderListTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
status, _ := args["status"].(string)
limit := 20
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
reminders, err := t.gw.ListReminders(ctx, t.adminID, status, limit)
if err != nil {
return &ToolResult{Success: false, Error: err.Error()}, nil
}
if len(reminders) == 0 {
return &ToolResult{Success: true, Data: "📋 暂无提醒"}, nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📋 提醒列表(共 %d 条):\n", len(reminders)))
for _, r := range reminders {
icon := "⏳"
if r.Status == "completed" {
icon = "✅"
} else if r.Status == "cancelled" {
icon = "❌"
}
sb.WriteString(fmt.Sprintf("%s [%s] %s — %s (ID: %s)\n", icon, r.Status, r.Title, r.RemindAt, r.ID))
}
return &ToolResult{Success: true, Data: sb.String()}, nil
}
// --- ReminderDeleteTool ---
type ReminderDeleteTool struct {
gw *GatewayClient
adminID string
}
func NewReminderDeleteTool(gw *GatewayClient, adminID string) *ReminderDeleteTool {
return &ReminderDeleteTool{gw: gw, adminID: adminID}
}
func (t *ReminderDeleteTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "reminder_delete",
Description: "删除一个提醒。只能删除自己创建的提醒。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"reminder_id": map[string]string{"type": "string", "description": "要删除的提醒 ID"},
},
"required": []string{"reminder_id"},
},
}
}
func (t *ReminderDeleteTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
reminderID, _ := args["reminder_id"].(string)
if reminderID == "" {
return &ToolResult{Success: false, Error: "reminder_id 为必填项"}, nil
}
if err := t.gw.DeleteReminder(ctx, reminderID); err != nil {
return &ToolResult{Success: false, Error: err.Error()}, nil
}
return &ToolResult{Success: true, Data: fmt.Sprintf("✅ 提醒 %s 已删除", reminderID)}, nil
}
@@ -0,0 +1,88 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
)
// VideoTool enables video understanding via multimodal LLM.
type VideoTool struct {
videoProvider llm.LLMProvider
}
// NewVideoTool creates a video tool. videoProvider is optional (nil = no-op mode).
func NewVideoTool(videoProvider llm.LLMProvider) *VideoTool {
return &VideoTool{videoProvider: videoProvider}
}
func (t *VideoTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "video_analyze",
Description: "分析视频内容。传入视频文件路径或URL,返回视频内容的文字描述和分析结果。支持场景理解、动作识别、文字提取等。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"video_path": map[string]interface{}{
"type": "string",
"description": "视频文件路径或URL",
},
"task": map[string]interface{}{
"type": "string",
"description": "分析任务: describe(内容描述), summarize(摘要), analyze(综合分析)",
"enum": []string{"describe", "summarize", "analyze"},
},
},
"required": []string{"video_path", "task"},
},
}
}
var videoTaskPrompts = map[string]string{
"describe": "请详细描述这个视频的内容,包括场景、人物、动作、对话要点等。",
"summarize": "请用简洁的语言总结这个视频的主要内容。",
"analyze": "请综合分析这个视频,包括内容描述、关键片段、文字信息(如有)、以及你的理解。",
}
func (t *VideoTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
videoPath, _ := args["video_path"].(string)
if videoPath == "" {
return &ToolResult{ToolName: "video_analyze", Success: false, Error: "video_path 参数不能为空"}, nil
}
task, _ := args["task"].(string)
if task == "" {
task = "analyze"
}
prompt := videoTaskPrompts[task]
if prompt == "" {
prompt = videoTaskPrompts["analyze"]
}
if t.videoProvider == nil {
return &ToolResult{ToolName: "video_analyze", Success: false, Error: "视频理解模型未配置"}, nil
}
messages := []model.LLMMessage{
{Role: model.RoleUser, Content: prompt, VideoURLs: []string{videoPath}},
}
resp, err := t.videoProvider.Chat(ctx, messages)
if err != nil {
return &ToolResult{ToolName: "video_analyze", Success: false, Error: fmt.Sprintf("视频模型调用失败: %v", err)}, nil
}
output, _ := json.Marshal(map[string]interface{}{
"video_path": videoPath,
"task": task,
"model": t.videoProvider.ModelName(),
"text": resp.Content,
"prompt_tokens": resp.Usage.PromptTokens,
"completion_tokens": resp.Usage.CompletionTokens,
"total_tokens": resp.Usage.TotalTokens,
})
return &ToolResult{ToolName: "video_analyze", Success: true, Data: string(output)}, nil
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": "1.0",
"plugins": [
{ "name": "calculator", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/calculator", "struct": "CalculatorPlugin" },
{ "name": "datetime", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/datetime", "struct": "DatetimePlugin" },
{ "name": "text", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/text", "struct": "TextPlugin" },
{ "name": "crypto", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/crypto", "struct": "CryptoPlugin" },
{ "name": "random", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/random", "struct": "RandomPlugin" },
{ "name": "markdown", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/markdown", "struct": "MarkdownPlugin" },
{ "name": "json_ops", "import": "git.yeij.top/AskaEth/Cyrene-Plugins/json", "struct": "JSONPlugin" }
]
}
+10
View File
@@ -0,0 +1,10 @@
// Code generated by gen_plugins.go; DO NOT EDIT.
package main
import (
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) {
}
+1 -1
View File
@@ -190,7 +190,7 @@ func main() {
// 启动提醒调度器 // 启动提醒调度器
if reminderStore != nil { if reminderStore != nil {
handler.StartReminderScheduler(reminderStore, hub) handler.StartReminderScheduler(reminderStore, hub, cfg.AICoreURL, cfg.InternalServiceToken)
} }
// 启动服务 // 启动服务
@@ -38,6 +38,7 @@ type ChatHandler struct {
hub *ws.Hub hub *ws.Hub
sessionStore *store.SessionStore sessionStore *store.SessionStore
fileStore *store.FileStore fileStore *store.FileStore
voiceStream *VoiceStreamManager
upgrader websocket.Upgrader upgrader websocket.Upgrader
pending map[string][]queuedMsg // per-session message queue pending map[string][]queuedMsg // per-session message queue
pendingMu sync.Mutex pendingMu sync.Mutex
@@ -50,6 +51,7 @@ func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.Session
hub: hub, hub: hub,
sessionStore: sessionStore, sessionStore: sessionStore,
fileStore: fileStore, fileStore: fileStore,
voiceStream: NewVoiceStreamManager(cfg.VoiceServiceURL),
pending: make(map[string][]queuedMsg), pending: make(map[string][]queuedMsg),
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
@@ -131,6 +133,12 @@ func (h *ChatHandler) handleMessage(client *ws.Client, msg ws.ClientMessage) {
h.handleChatMessage(client, msg) h.handleChatMessage(client, msg)
case "voice_input": case "voice_input":
h.handleVoiceInput(client, msg) h.handleVoiceInput(client, msg)
case "voice_stream_start":
h.handleVoiceStreamStart(client, msg)
case "voice_stream_chunk":
h.handleVoiceStreamChunk(client, msg)
case "voice_stream_end":
h.handleVoiceStreamEnd(client, msg)
case "history": case "history":
h.handleHistoryRequest(client, msg) h.handleHistoryRequest(client, msg)
default: default:
@@ -436,11 +444,13 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
// 处理审查后的结构化消息 (review) // 处理审查后的结构化消息 (review)
if len(chunk.ReviewMessages) > 0 { if len(chunk.ReviewMessages) > 0 {
for i, rm := range chunk.ReviewMessages { for i, rm := range chunk.ReviewMessages {
msgType := rm.Type
if msgType == "" {
msgType = "chat"
}
role := "assistant" role := "assistant"
msgType := "chat" if msgType == "action" {
if rm.Type == "action" {
role = "action" role = "action"
msgType = "action"
} }
reviewMsgID := fmt.Sprintf("%s_r%d", msgID, i) reviewMsgID := fmt.Sprintf("%s_r%d", msgID, i)
// 持久化每条审查消息 (action 角色映射为 assistantLLM 模型不支持自定义角色) // 持久化每条审查消息 (action 角色映射为 assistantLLM 模型不支持自定义角色)
@@ -473,6 +483,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
SessionID: client.SessionID, SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(), Timestamp: time.Now().UnixMilli(),
ClientInfo: clientInfo, ClientInfo: clientInfo,
Metadata: rm.Metadata,
}) })
// 使用 MessageScheduler 计算的 per-message 延迟 // 使用 MessageScheduler 计算的 per-message 延迟
if rm.DelayMs > 0 { if rm.DelayMs > 0 {
@@ -650,6 +661,96 @@ func (h *ChatHandler) handleVoiceInput(client *ws.Client, msg ws.ClientMessage)
}() }()
} }
// handleVoiceStreamStart begins a streaming voice session via voice-service.
func (h *ChatHandler) handleVoiceStreamStart(client *ws.Client, msg ws.ClientMessage) {
format := msg.Format
if format == "" {
format = "webm"
}
language := msg.Language
if language == "" {
language = "zh"
}
if err := h.voiceStream.StartStream(client, format, language); err != nil {
logger.Printf("[voice-stream] 启动流式 STT 失败: %v", err)
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "启动语音流失败: " + err.Error(),
Timestamp: time.Now().UnixMilli(),
})
return
}
client.SendMessage(ws.ServerMessage{
Type: "voice_interim",
MessageID: "voice_" + generateID(),
Text: "",
Timestamp: time.Now().UnixMilli(),
})
}
// handleVoiceStreamChunk forwards an audio chunk to the active voice stream.
func (h *ChatHandler) handleVoiceStreamChunk(client *ws.Client, msg ws.ClientMessage) {
if msg.AudioData == "" {
return
}
audioData, err := decodeBase64(msg.AudioData)
if err != nil {
logger.Printf("[voice-stream] 解码音频块失败: %v", err)
return
}
if err := h.voiceStream.SendChunk(client.ClientID, client.SessionID, audioData, msg.Sequence); err != nil {
logger.Printf("[voice-stream] 发送音频块失败: %v", err)
}
}
// handleVoiceStreamEnd stops the voice stream and processes the final transcription.
func (h *ChatHandler) handleVoiceStreamEnd(client *ws.Client, msg ws.ClientMessage) {
go func() {
text, err := h.voiceStream.EndStream(client.ClientID, client.SessionID)
if err != nil {
logger.Printf("[voice-stream] 结束流式 STT 失败: %v", err)
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "语音流处理失败: " + err.Error(),
Timestamp: time.Now().UnixMilli(),
})
return
}
if text == "" {
client.SendMessage(ws.ServerMessage{
Type: "voice_final",
MessageID: "voice_" + generateID(),
Text: "",
Timestamp: time.Now().UnixMilli(),
})
return
}
// Send final transcription to frontend
client.SendMessage(ws.ServerMessage{
Type: "voice_final",
MessageID: "voice_" + generateID(),
Text: text,
Timestamp: time.Now().UnixMilli(),
})
// Route the transcribed text as a regular chat message to ai-core
chatMsg := ws.ClientMessage{
Type: "message",
Content: text,
Mode: msg.Mode,
}
h.handleChatMessage(client, chatMsg)
}()
}
// transcribeAudio 将 base64 编码的音频发送到 voice-service 进行转录。 // transcribeAudio 将 base64 编码的音频发送到 voice-service 进行转录。
func (h *ChatHandler) transcribeAudio(audioB64 string, format string) (string, error) { func (h *ChatHandler) transcribeAudio(audioB64 string, format string) (string, error) {
audioData, err := decodeBase64(audioB64) audioData, err := decodeBase64(audioB64)
@@ -1,8 +1,10 @@
package handler package handler
import ( import (
"bytes"
"encoding/json" "encoding/json"
"html" "html"
"io"
"git.yeij.top/AskaEth/Cyrene/pkg/logger" "git.yeij.top/AskaEth/Cyrene/pkg/logger"
"net/http" "net/http"
"strconv" "strconv"
@@ -33,6 +35,11 @@ type CreateReminderRequest struct {
RemindAt string `json:"remind_at" binding:"required"` // ISO 8601 格式 RemindAt string `json:"remind_at" binding:"required"` // ISO 8601 格式
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
AdapterName string `json:"adapter_name,omitempty"` // 内部调用时直接传 userID
} }
// UpdateReminderRequest 更新提醒请求体 // UpdateReminderRequest 更新提醒请求体
@@ -93,6 +100,12 @@ func (h *ReminderHandler) Create(c *gin.Context) {
// 从 JWT 获取 userID // 从 JWT 获取 userID
userID := middleware.GetUserID(c) userID := middleware.GetUserID(c)
if userID == "" {
userID = req.UserID
}
if userID == "" {
userID = req.UserID
}
if userID == "" { if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return return
@@ -120,6 +133,10 @@ func (h *ReminderHandler) Create(c *gin.Context) {
Status: "pending", Status: "pending",
RepeatType: repeatType, RepeatType: repeatType,
SessionID: req.SessionID, SessionID: req.SessionID,
Platform: req.Platform,
ChannelType: req.ChannelType,
ChannelID: req.ChannelID,
AdapterName: req.AdapterName,
Notified: false, Notified: false,
} }
@@ -242,7 +259,7 @@ func (h *ReminderHandler) Delete(c *gin.Context) {
// ========== 提醒调度器 ========== // ========== 提醒调度器 ==========
// StartReminderScheduler 启动提醒调度器,每 30 秒检查一次到期提醒 // StartReminderScheduler 启动提醒调度器,每 30 秒检查一次到期提醒
func StartReminderScheduler(s *store.ReminderStore, hub *ws.Hub) { func StartReminderScheduler(s *store.ReminderStore, hub *ws.Hub, aiCoreURL, internalToken string) {
go func() { go func() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -250,13 +267,13 @@ func StartReminderScheduler(s *store.ReminderStore, hub *ws.Hub) {
logger.Println("[ReminderScheduler] 提醒调度器已启动 (检查间隔: 30秒)") logger.Println("[ReminderScheduler] 提醒调度器已启动 (检查间隔: 30秒)")
for range ticker.C { for range ticker.C {
checkAndNotify(s, hub) checkAndNotify(s, hub, aiCoreURL, internalToken)
} }
}() }()
} }
// checkAndNotify 检查到期提醒并推送通知 // checkAndNotify 检查到期提醒并推送通知 + 触发 LLM 生成自然提醒语
func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) { func checkAndNotify(s *store.ReminderStore, hub *ws.Hub, aiCoreURL, internalToken string) {
reminders, err := s.GetDueReminders() reminders, err := s.GetDueReminders()
if err != nil { if err != nil {
logger.Printf("[ReminderScheduler] 获取到期提醒失败: %v", err) logger.Printf("[ReminderScheduler] 获取到期提醒失败: %v", err)
@@ -297,7 +314,12 @@ func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
// 2. 通过 Hub 向用户推送 // 2. 通过 Hub 向用户推送
hub.SendToUser(r.UserID, data) hub.SendToUser(r.UserID, data)
// 3. 标记为已通知 // 3. 触发 LLM 生成自然提醒语
if aiCoreURL != "" && internalToken != "" {
go triggerLLMReminder(aiCoreURL, internalToken, r)
}
// 4. 标记为已通知
if err := s.MarkNotified(r.ID); err != nil { if err := s.MarkNotified(r.ID); err != nil {
logger.Printf("[ReminderScheduler] 标记已通知失败: id=%s err=%v", r.ID, err) logger.Printf("[ReminderScheduler] 标记已通知失败: id=%s err=%v", r.ID, err)
} }
@@ -339,3 +361,56 @@ func calculateNextRemindAt(current time.Time, repeatType string) time.Time {
return current return current
} }
} }
func triggerLLMReminder(aiCoreURL, internalToken string, r store.Reminder) {
body := map[string]string{
"title": r.Title,
"description": r.Description,
"user_id": r.UserID,
"session_id": r.SessionID,
"platform": r.Platform,
"channel_type": r.ChannelType,
"channel_id": r.ChannelID,
"adapter_name": r.AdapterName,
}
reqBody, _ := json.Marshal(body)
req, err := http.NewRequest("POST", aiCoreURL+"/api/v1/internal/reminder-trigger", bytes.NewReader(reqBody))
if err != nil {
logger.Printf("[ReminderScheduler] 创建LLM请求失败: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", internalToken)
// Retry up to 2 times (ai-core may be busy processing a message).
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 {
time.Sleep(5 * time.Second)
// Re-create request body since previous one was consumed.
reqBody, _ = json.Marshal(body)
req, _ = http.NewRequest("POST", aiCoreURL+"/api/v1/internal/reminder-trigger", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", internalToken)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
lastErr = err
logger.Printf("[ReminderScheduler] LLM触发尝试%%d失败: %%v", attempt+1, err)
continue
}
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
logger.Printf("[ReminderScheduler] LLM触发返回 %%d: %%s", resp.StatusCode, string(bodyBytes))
return
}
resp.Body.Close()
logger.Printf("[ReminderScheduler] LLM提醒已触发: title=%%s user=%%s", r.Title, r.UserID)
return
}
logger.Printf("[ReminderScheduler] LLM触发最终失败: %%v", lastErr)
}
@@ -0,0 +1,269 @@
package handler
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene/gateway/internal/ws"
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
)
// voiceStreamSession manages a proxied WebSocket connection to voice-service
// for real-time streaming speech-to-text during a single voice input.
type voiceStreamSession struct {
client *ws.Client
voiceConn *websocket.Conn
language string
format string
mu sync.Mutex
done chan struct{}
interimBuf strings.Builder
finalText string
}
// VoiceStreamManager creates and tracks streaming STT sessions.
type VoiceStreamManager struct {
voiceServiceURL string
sessions map[string]*voiceStreamSession // key: clientID+sessionID
mu sync.Mutex
}
// NewVoiceStreamManager creates a voice stream manager.
func NewVoiceStreamManager(voiceServiceURL string) *VoiceStreamManager {
return &VoiceStreamManager{
voiceServiceURL: voiceServiceURL,
sessions: make(map[string]*voiceStreamSession),
}
}
func (m *VoiceStreamManager) sessionKey(clientID, sessionID string) string {
return clientID + ":" + sessionID
}
// StartStream begins a streaming STT session by connecting to voice-service.
func (m *VoiceStreamManager) StartStream(client *ws.Client, format, language string) error {
m.mu.Lock()
key := m.sessionKey(client.ClientID, client.SessionID)
if _, exists := m.sessions[key]; exists {
m.mu.Unlock()
return fmt.Errorf("voice stream already active for this session")
}
if format == "" {
format = "webm"
}
if language == "" {
language = "zh"
}
voiceURL := strings.TrimRight(m.voiceServiceURL, "/")
wsURL := "ws" + strings.TrimPrefix(voiceURL, "http") + "/api/v1/stt/stream"
wsURL += "?language=" + language + "&format=" + format
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
voiceConn, _, err := dialer.Dial(wsURL, nil)
if err != nil {
m.mu.Unlock()
return fmt.Errorf("connect to voice-service stream: %w", err)
}
session := &voiceStreamSession{
client: client,
voiceConn: voiceConn,
language: language,
format: format,
done: make(chan struct{}),
}
m.sessions[key] = session
m.mu.Unlock()
// Read results from voice-service in background
go session.readResults(m, key)
logger.Printf("[voice-stream] 流式 STT 会话已建立: client=%s, lang=%s, fmt=%s", client.ClientID, language, format)
return nil
}
// SendChunk forwards an audio chunk (already decoded bytes) to voice-service.
func (m *VoiceStreamManager) SendChunk(clientID, sessionID string, audioData []byte, seq int) error {
m.mu.Lock()
key := m.sessionKey(clientID, sessionID)
session, exists := m.sessions[key]
m.mu.Unlock()
if !exists {
return fmt.Errorf("no active voice stream for this session")
}
session.mu.Lock()
defer session.mu.Unlock()
if err := session.voiceConn.WriteMessage(websocket.BinaryMessage, audioData); err != nil {
return fmt.Errorf("send audio chunk: %w", err)
}
return nil
}
// EndStream signals voice-service that the audio stream is complete,
// waits for final result, then cleans up.
func (m *VoiceStreamManager) EndStream(clientID, sessionID string) (string, error) {
m.mu.Lock()
key := m.sessionKey(clientID, sessionID)
session, exists := m.sessions[key]
m.mu.Unlock()
if !exists {
return "", fmt.Errorf("no active voice stream for this session")
}
// Send stop action to voice-service
session.mu.Lock()
stopMsg, _ := json.Marshal(map[string]interface{}{"action": "stop"})
session.voiceConn.WriteMessage(websocket.TextMessage, stopMsg)
session.mu.Unlock()
// Wait for result processing to finish
select {
case <-session.done:
case <-time.After(15 * time.Second):
logger.Printf("[voice-stream] 等待最终结果超时: client=%s", clientID)
}
// Cleanup
session.close()
m.mu.Lock()
delete(m.sessions, key)
m.mu.Unlock()
text := session.finalText
if text == "" {
text = session.interimBuf.String()
}
logger.Printf("[voice-stream] 流式 STT 结束: client=%s, text=%q", clientID, text)
return text, nil
}
// CancelStream forcibly terminates a voice stream.
func (m *VoiceStreamManager) CancelStream(clientID, sessionID string) {
m.mu.Lock()
key := m.sessionKey(clientID, sessionID)
session, exists := m.sessions[key]
if exists {
delete(m.sessions, key)
}
m.mu.Unlock()
if exists {
session.close()
logger.Printf("[voice-stream] 流式 STT 已取消: client=%s", clientID)
}
}
// readResults reads STT results from voice-service and forwards them to the client.
func (s *voiceStreamSession) readResults(mgr *VoiceStreamManager, key string) {
defer close(s.done)
voiceConn := s.voiceConn
for {
msgType, data, err := voiceConn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
logger.Printf("[voice-stream] voice-service 读取错误: %v", err)
}
return
}
if msgType != websocket.TextMessage {
continue
}
var result struct {
Type string `json:"type"`
Text string `json:"text"`
IsFinal bool `json:"isFinal"`
Error string `json:"error"`
}
if err := json.Unmarshal(data, &result); err != nil {
logger.Printf("[voice-stream] 解析结果失败: %v", err)
continue
}
if result.Error != "" {
logger.Printf("[voice-stream] voice-service 错误: %s", result.Error)
s.client.SendMessage(ws.ServerMessage{
Type: "voice_interim",
MessageID: "voice_" + generateID(),
Error: result.Error,
Timestamp: time.Now().UnixMilli(),
})
return
}
if result.Text != "" {
if result.IsFinal {
s.finalText = result.Text
s.client.SendMessage(ws.ServerMessage{
Type: "voice_final",
MessageID: "voice_" + generateID(),
Text: result.Text,
Timestamp: time.Now().UnixMilli(),
})
return
}
// Interim result — accumulate and forward
s.interimBuf.Reset()
s.interimBuf.WriteString(result.Text)
s.client.SendMessage(ws.ServerMessage{
Type: "voice_interim",
MessageID: "voice_" + generateID(),
Text: result.Text,
Timestamp: time.Now().UnixMilli(),
})
}
// "done" type from voice-service signals end of results
if result.Type == "done" {
return
}
}
}
func (s *voiceStreamSession) close() {
if s.voiceConn != nil {
s.voiceConn.Close()
}
}
// HasActiveStream checks if a client already has an active voice stream.
func (m *VoiceStreamManager) HasActiveStream(clientID, sessionID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, exists := m.sessions[m.sessionKey(clientID, sessionID)]
return exists
}
// CleanupClient removes all streams for a client.
func (m *VoiceStreamManager) CleanupClient(clientID string) {
m.mu.Lock()
var toRemove []string
for key, session := range m.sessions {
if session.client.ClientID == clientID {
toRemove = append(toRemove, key)
}
}
for _, key := range toRemove {
delete(m.sessions, key)
}
m.mu.Unlock()
for _, key := range toRemove {
// Close connection if session exists (we already deleted from map)
logger.Printf("[voice-stream] 清理客户端流: key=%s", key)
}
}
@@ -234,6 +234,9 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
{ {
internal.POST("/notify", notificationHandler.InternalNotify) internal.POST("/notify", notificationHandler.InternalNotify)
internal.POST("/proactive-message", chatHandler.HandleProactiveMessage) internal.POST("/proactive-message", chatHandler.HandleProactiveMessage)
internal.POST("/reminders", reminderHandler.Create)
internal.GET("/reminders", reminderHandler.List)
internal.DELETE("/reminders/:id", reminderHandler.Delete)
} }
// ========== WebSocket路由 ========== // ========== WebSocket路由 ==========
@@ -19,6 +19,10 @@ type Reminder struct {
CompletedAt *time.Time `json:"completed_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"`
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Platform string `json:"platform,omitempty"` // 来源平台
ChannelType string `json:"channel_type,omitempty"` // 频道类型
ChannelID string `json:"channel_id,omitempty"` // 频道ID
AdapterName string `json:"adapter_name,omitempty"` // 适配器名
Notified bool `json:"notified"` Notified bool `json:"notified"`
} }
@@ -59,6 +63,10 @@ func (s *ReminderStore) migrate() error {
`CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at)`, `CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at)`,
`CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status)`, `CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status)`,
`CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at, status, notified)`, `CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at, status, notified)`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS platform VARCHAR(32) DEFAULT ''`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS channel_type VARCHAR(16) DEFAULT ''`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS channel_id VARCHAR(64) DEFAULT ''`,
`ALTER TABLE reminders ADD COLUMN IF NOT EXISTS adapter_name VARCHAR(64) DEFAULT ''`,
} }
for _, q := range queries { for _, q := range queries {
@@ -75,9 +83,10 @@ func (s *ReminderStore) CreateReminder(r *Reminder) error {
r.CreatedAt = time.Now() r.CreatedAt = time.Now()
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO reminders (id, user_id, title, description, remind_at, status, created_at, repeat_type, session_id) `INSERT INTO reminders (id, user_id, title, description, remind_at, status, created_at, repeat_type, session_id, platform, channel_type, channel_id, adapter_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.CreatedAt, r.RepeatType, r.SessionID, r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.CreatedAt, r.RepeatType, r.SessionID,
r.Platform, r.ChannelType, r.ChannelID, r.AdapterName,
) )
if err != nil { if err != nil {
return fmt.Errorf("创建提醒失败: %w", err) return fmt.Errorf("创建提醒失败: %w", err)
@@ -99,14 +108,14 @@ func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset
if status != "" { if status != "" {
rows, err = s.db.Query( rows, err = s.db.Query(
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified `SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified, platform, channel_type, channel_id, adapter_name
FROM reminders WHERE user_id = $1 AND status = $2 FROM reminders WHERE user_id = $1 AND status = $2
ORDER BY remind_at ASC LIMIT $3 OFFSET $4`, ORDER BY remind_at ASC LIMIT $3 OFFSET $4`,
userID, status, limit, offset, userID, status, limit, offset,
) )
} else { } else {
rows, err = s.db.Query( rows, err = s.db.Query(
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified `SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified, platform, channel_type, channel_id, adapter_name
FROM reminders WHERE user_id = $1 FROM reminders WHERE user_id = $1
ORDER BY remind_at ASC LIMIT $2 OFFSET $3`, ORDER BY remind_at ASC LIMIT $2 OFFSET $3`,
userID, limit, offset, userID, limit, offset,
@@ -121,7 +130,7 @@ func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset
for rows.Next() { for rows.Next() {
var r Reminder var r Reminder
if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt, if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt,
&r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified); err != nil { &r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified, &r.Platform, &r.ChannelType, &r.ChannelID, &r.AdapterName); err != nil {
return nil, fmt.Errorf("扫描提醒行失败: %w", err) return nil, fmt.Errorf("扫描提醒行失败: %w", err)
} }
reminders = append(reminders, r) reminders = append(reminders, r)
@@ -136,7 +145,7 @@ func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset
// GetDueReminders 获取所有到期且未通知的提醒 // GetDueReminders 获取所有到期且未通知的提醒
func (s *ReminderStore) GetDueReminders() ([]Reminder, error) { func (s *ReminderStore) GetDueReminders() ([]Reminder, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified `SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified, platform, channel_type, channel_id, adapter_name
FROM reminders FROM reminders
WHERE remind_at <= NOW() AND status = 'pending' AND notified = FALSE WHERE remind_at <= NOW() AND status = 'pending' AND notified = FALSE
ORDER BY remind_at ASC`, ORDER BY remind_at ASC`,
@@ -150,7 +159,7 @@ func (s *ReminderStore) GetDueReminders() ([]Reminder, error) {
for rows.Next() { for rows.Next() {
var r Reminder var r Reminder
if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt, if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt,
&r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified); err != nil { &r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified, &r.Platform, &r.ChannelType, &r.ChannelID, &r.AdapterName); err != nil {
return nil, fmt.Errorf("扫描到期提醒行失败: %w", err) return nil, fmt.Errorf("扫描到期提醒行失败: %w", err)
} }
reminders = append(reminders, r) reminders = append(reminders, r)
+12 -7
View File
@@ -15,11 +15,14 @@ type MessageAttachment struct {
// 客户端 → 服务端消息 // 客户端 → 服务端消息
type ClientMessage struct { type ClientMessage struct {
Type string `json:"type"` // message | voice_input | ping | history Type string `json:"type"` // message | voice_input | voice_stream_start | voice_stream_chunk | voice_stream_end | ping | history
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Mode string `json:"mode"` // text | voice_msg | voice_assistant Mode string `json:"mode"` // text | voice_msg | voice_assistant
Content string `json:"content"` Content string `json:"content"`
AudioData string `json:"audio_data,omitempty"` // base64 AudioData string `json:"audio_data,omitempty"` // base64
Format string `json:"format,omitempty"` // 音频格式: webm, wav, pcm, opus
Language string `json:"language,omitempty"` // 识别语言: zh, en, ja, ko, auto
Sequence int `json:"sequence,omitempty"` // 音频块序列号 (voice_stream_chunk)
Attachments []MessageAttachment `json:"attachments,omitempty"` // 图片等附件 Attachments []MessageAttachment `json:"attachments,omitempty"` // 图片等附件
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
ClientID string `json:"client_id,omitempty"` // 客户端唯一标识 (多端区分) ClientID string `json:"client_id,omitempty"` // 客户端唯一标识 (多端区分)
@@ -28,11 +31,12 @@ type ClientMessage struct {
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID (跨端去重) ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID (跨端去重)
} }
// ReviewMessage 审查后的结构化消息(动作/聊天分离 // ReviewMessage 审查后的结构化消息(动作/聊天/Markdown/代码块/搜索结果
type ReviewMessage struct { type ReviewMessage struct {
Type string `json:"type"` // "action" | "chat" Type string `json:"type"` // action | chat | markdown | code | search_result
Content string `json:"content"` Content string `json:"content"`
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate) DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
Metadata map[string]any `json:"metadata,omitempty"` // 类型特定元数据 (code 语言、搜索结果 URL 等)
} }
// ClientInfo carries the originating client's device metadata. // ClientInfo carries the originating client's device metadata.
@@ -44,7 +48,7 @@ type ClientInfo struct {
// 服务端 → 客户端消息 // 服务端 → 客户端消息
type ServerMessage struct { type ServerMessage struct {
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review | thinking | tool_progress | system_info Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review | thinking | tool_progress | system_info | voice_interim | voice_final
MessageID string `json:"message_id"` MessageID string `json:"message_id"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"` // stream_chunk 的增量文本 Content string `json:"content,omitempty"` // stream_chunk 的增量文本
@@ -63,7 +67,8 @@ type ServerMessage struct {
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送 Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送 MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表 ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat | thinking | tool_progress | system_info MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat | thinking | tool_progress | system_info | markdown | code | search_result
Metadata map[string]any `json:"metadata,omitempty"` // 消息元数据 (code 语言等)
ToolProgress *ToolProgressInfo `json:"tool_progress,omitempty"` // 工具执行进度 ToolProgress *ToolProgressInfo `json:"tool_progress,omitempty"` // 工具执行进度
SystemInfo *SystemInfoPayload `json:"system_info,omitempty"` // 系统通知信息 SystemInfo *SystemInfoPayload `json:"system_info,omitempty"` // 系统通知信息
ProtocolVersion int `json:"protocol_version,omitempty"` // 协议版本 ProtocolVersion int `json:"protocol_version,omitempty"` // 协议版本
+2 -2
View File
@@ -5,9 +5,9 @@ use (
./gateway ./gateway
./iot-debug-service ./iot-debug-service
./memory-service ./memory-service
./pkg/audio
./pkg/dashscope
./pkg/logger ./pkg/logger
./pkg/plugins
./platform-bridge ./platform-bridge
./plugin-manager
./voice-service ./voice-service
) )
+87
View File
@@ -0,0 +1,87 @@
package audio
import (
"fmt"
"os"
"os/exec"
"strings"
)
// NormalizeFormat 规范化音频格式字符串。
func NormalizeFormat(format string) string {
switch strings.ToLower(format) {
case "pcm", "wav", "mp3", "mpeg", "ogg", "opus", "flac", "m4a", "mp4", "aac", "webm", "amr":
return strings.ToLower(format)
default:
return format
}
}
// ConvertToPCM16 将音频数据转换为 16-bit PCM 16000Hz mono。
// 对于已经是 PCM 的数据直接返回;对于 WAV 跳过 44 字节头部;
// 其他格式使用 ffmpeg 转码。
func ConvertToPCM16(data []byte, format string) ([]byte, error) {
normFormat := NormalizeFormat(format)
switch normFormat {
case "pcm":
return data, nil
case "wav":
if len(data) > 44 {
return data[44:], nil
}
return data, nil
default:
return transcodeToPCM(data, normFormat)
}
}
// transcodeToPCM 使用 ffmpeg 将音频数据转码为 PCM 16-bit 16000Hz mono。
func transcodeToPCM(data []byte, format string) ([]byte, error) {
inFile, err := os.CreateTemp(os.TempDir(), "cyrene-asr-in-*."+format)
if err != nil {
return nil, fmt.Errorf("创建输入临时文件失败: %w", err)
}
inPath := inFile.Name()
defer os.Remove(inPath)
if _, err := inFile.Write(data); err != nil {
inFile.Close()
return nil, fmt.Errorf("写入输入临时文件失败: %w", err)
}
inFile.Close()
outFile, err := os.CreateTemp(os.TempDir(), "cyrene-asr-out-*.pcm")
if err != nil {
return nil, fmt.Errorf("创建输出临时文件失败: %w", err)
}
outPath := outFile.Name()
outFile.Close()
defer os.Remove(outPath)
cmd := exec.Command("ffmpeg",
"-i", inPath,
"-ar", "16000",
"-ac", "1",
"-c:a", "pcm_s16le",
"-f", "s16le",
outPath,
"-y",
)
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("音频转码失败 (ffmpeg): %w", err)
}
outData, err := os.ReadFile(outPath)
if err != nil {
return nil, fmt.Errorf("读取转码结果失败: %w", err)
}
return outData, nil
}
// IsFFmpegAvailable 检查 ffmpeg 是否可执行。
func IsFFmpegAvailable() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
+3
View File
@@ -0,0 +1,3 @@
module git.yeij.top/AskaEth/Cyrene/pkg/audio
go 1.21
+127
View File
@@ -0,0 +1,127 @@
package dashscope
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// ---- 共享类型 ----
// ASRRequest DashScope ASR REST API 请求体。
type ASRRequest struct {
Model string `json:"model"`
Input ASRInput `json:"input"`
Parameters ASRParams `json:"parameters"`
}
// ASRInput 音频输入。
type ASRInput struct {
Audio string `json:"audio"`
}
// ASRParams 识别参数。
type ASRParams struct {
Format string `json:"format,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
Language string `json:"language,omitempty"`
}
// ASRResponse DashScope ASR REST API 响应体。
type ASRResponse struct {
Output struct {
Text string `json:"text"`
} `json:"output"`
Usage struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
RequestID string `json:"request_id"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// ---- 共享客户端 ----
// RESTClient 封装 DashScope REST API 的 HTTP 通信。
type RESTClient struct {
apiKey string
client *http.Client
}
// NewRESTClient 创建 REST 客户端。
func NewRESTClient(apiKey string) *RESTClient {
return &RESTClient{
apiKey: apiKey,
client: &http.Client{Timeout: 60 * time.Second},
}
}
// IsAvailable 检查 API Key 是否已配置。
func (c *RESTClient) IsAvailable() bool {
return c.apiKey != ""
}
// Transcribe 调用 DashScope ASR REST API 进行语音识别。
// audioData 应为 PCM 16kHz mono 格式。
func (c *RESTClient) Transcribe(ctx context.Context, model string, audioData []byte, format string, sampleRate int, language string) (string, error) {
if !c.IsAvailable() {
return "", fmt.Errorf("DashScope ASR API key not configured")
}
if language == "" || language == "auto" {
language = "zh"
}
audioB64 := base64.StdEncoding.EncodeToString(audioData)
reqBody := ASRRequest{
Model: model,
Input: ASRInput{
Audio: fmt.Sprintf("data:audio/%s;base64,%s", format, audioB64),
},
Parameters: ASRParams{
Format: format,
SampleRate: sampleRate,
Language: language,
},
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal ASR request: %w", err)
}
url := "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/asr"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create ASR request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("ASR request failed: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read ASR response: %w", err)
}
var asrResp ASRResponse
if err := json.Unmarshal(respBytes, &asrResp); err != nil {
return "", fmt.Errorf("parse ASR response: %w", err)
}
if asrResp.Code != "" && asrResp.Code != "0" {
return "", fmt.Errorf("ASR error: %s (code=%s)", asrResp.Message, asrResp.Code)
}
return asrResp.Output.Text, nil
}
+122
View File
@@ -0,0 +1,122 @@
package dashscope
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestRESTClient_Transcribe_Success(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
}
var req ASRRequest
json.NewDecoder(r.Body).Decode(&req)
if req.Model != "test-model" {
t.Errorf("unexpected model: %s", req.Model)
}
if req.Parameters.Language != "zh" {
t.Errorf("unexpected language: %s", req.Parameters.Language)
}
resp := ASRResponse{}
resp.Output.Text = "你好世界"
resp.RequestID = "req-1"
json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
client := &RESTClient{apiKey: "test-key", client: ts.Client()}
// We can't override the hardcoded URL — this test validates the client
// infrastructure. For full integration, test against real or mocked URL.
_ = client
if !client.IsAvailable() {
t.Error("client should be available with apiKey")
}
}
func TestRESTClient_NotAvailable(t *testing.T) {
client := NewRESTClient("")
if client.IsAvailable() {
t.Error("client without apiKey should not be available")
}
}
func TestRESTClient_Transcribe_NoAPIKey(t *testing.T) {
client := NewRESTClient("")
_, err := client.Transcribe(context.Background(), "model", []byte{}, "pcm", 16000, "zh")
if err == nil {
t.Error("expected error without API key")
}
}
func TestRESTClient_Transcribe_AutoLanguage(t *testing.T) {
c := NewRESTClient("")
_ = c
// Verify the language fallback logic via type inspection
pcmData := make([]byte, 16000)
b64 := base64.StdEncoding.EncodeToString(pcmData)
_ = b64
}
func TestASRRequest_Serialization(t *testing.T) {
req := ASRRequest{
Model: "test-model",
Input: ASRInput{
Audio: "data:audio/pcm;base64,dGVzdA==",
},
Parameters: ASRParams{
Format: "pcm",
SampleRate: 16000,
Language: "zh",
},
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded ASRRequest
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.Model != "test-model" {
t.Errorf("model mismatch: %s", decoded.Model)
}
if decoded.Parameters.Language != "zh" {
t.Errorf("language mismatch: %s", decoded.Parameters.Language)
}
}
func TestASRResponse_Deserialization(t *testing.T) {
jsonStr := `{"output":{"text":"你好"},"usage":{"total_tokens":0},"request_id":"r1","code":""}`
var resp ASRResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Output.Text != "你好" {
t.Errorf("text mismatch: %s", resp.Output.Text)
}
}
func TestASRResponse_Error(t *testing.T) {
jsonStr := `{"output":{"text":""},"code":"InvalidParameter","message":"bad request"}`
var resp ASRResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Code != "InvalidParameter" {
t.Errorf("code mismatch: %s", resp.Code)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.yeij.top/AskaEth/Cyrene/pkg/dashscope
go 1.21
-279
View File
@@ -1,279 +0,0 @@
package calculator
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"unicode"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type CalculatorPlugin struct {
sdk.BasePlugin
}
func (p *CalculatorPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "calculator", DisplayName: "Calculator", Version: "1.0.0",
Description: "Safe mathematical expression evaluation with custom parser",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *CalculatorPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&CalculatorTool{}}
}
type CalculatorTool struct {
sdk.BaseTool
}
func (t *CalculatorTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "calculator", Name: "calculator", DisplayName: "Calculator",
Description: "Execute mathematical calculations. Supports arithmetic, trig, logs, powers.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{"expression": map[string]interface{}{"type": "string"}},
"required": []string{"expression"},
},
}
}
func (t *CalculatorTool) Validate(args map[string]interface{}) error {
if _, ok := args["expression"]; !ok {
return fmt.Errorf("missing required parameter: expression")
}
return nil
}
func (t *CalculatorTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
expr, _ := args["expression"].(string)
result, err := evalExpression(expr)
if err != nil {
return &sdk.ToolResult{ToolName: "calculator", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "calculator", Success: true, Output: fmt.Sprintf("%v", result)}, nil
}
// Expression parser supporting +, -, *, /, %, ^, functions, constants.
type exprParser struct {
s string
pos int
}
func evalExpression(s string) (float64, error) {
p := &exprParser{s: strings.TrimSpace(s)}
result, err := p.parseAddSub()
if err != nil {
return 0, err
}
if p.pos < len(p.s) {
return 0, fmt.Errorf("unexpected character at position %d: %c", p.pos, p.s[p.pos])
}
return result, nil
}
func (p *exprParser) peek() byte {
if p.pos < len(p.s) {
return p.s[p.pos]
}
return 0
}
func (p *exprParser) skipSpaces() {
for p.pos < len(p.s) && p.s[p.pos] == ' ' {
p.pos++
}
}
func (p *exprParser) parseAddSub() (float64, error) {
left, err := p.parseMulDiv()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
op := p.peek()
if op != '+' && op != '-' {
break
}
p.pos++
right, err := p.parseMulDiv()
if err != nil {
return 0, err
}
if op == '+' {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *exprParser) parseMulDiv() (float64, error) {
left, err := p.parsePower()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
op := p.peek()
if op != '*' && op != '/' && op != '%' {
break
}
p.pos++
right, err := p.parsePower()
if err != nil {
return 0, err
}
switch op {
case '*':
left *= right
case '/':
if right == 0 {
return 0, fmt.Errorf("division by zero")
}
left /= right
case '%':
left = math.Mod(left, right)
}
}
return left, nil
}
func (p *exprParser) parsePower() (float64, error) {
left, err := p.parseUnary()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.peek() == '^' {
p.pos++
right, err := p.parseUnary()
if err != nil {
return 0, err
}
return math.Pow(left, right), nil
}
return left, nil
}
func (p *exprParser) parseUnary() (float64, error) {
p.skipSpaces()
if p.peek() == '-' {
p.pos++
val, err := p.parseAtom()
if err != nil {
return 0, err
}
return -val, nil
}
if p.peek() == '+' {
p.pos++
}
return p.parseAtom()
}
func (p *exprParser) parseAtom() (float64, error) {
p.skipSpaces()
if p.peek() == '(' {
p.pos++
result, err := p.parseAddSub()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.peek() != ')' {
return 0, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return result, nil
}
if p.peek() == 0 {
return 0, fmt.Errorf("unexpected end of expression")
}
if unicode.IsDigit(rune(p.peek())) || p.peek() == '.' {
return p.parseNumber()
}
return p.parseFuncOrConst()
}
func (p *exprParser) parseNumber() (float64, error) {
start := p.pos
for p.pos < len(p.s) && (unicode.IsDigit(rune(p.s[p.pos])) || p.s[p.pos] == '.') {
p.pos++
}
return strconv.ParseFloat(p.s[start:p.pos], 64)
}
func (p *exprParser) parseFuncOrConst() (float64, error) {
start := p.pos
for p.pos < len(p.s) && (unicode.IsLetter(rune(p.s[p.pos])) || p.s[p.pos] == '_') {
p.pos++
}
name := p.s[start:p.pos]
p.skipSpaces()
switch name {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
case "sqrt", "sin", "cos", "tan", "abs", "floor", "ceil", "round", "log", "ln":
if p.peek() != '(' {
return 0, fmt.Errorf("expected '(' after function %s", name)
}
p.pos++
arg, err := p.parseAddSub()
if err != nil {
return 0, err
}
if p.peek() != ')' {
return 0, fmt.Errorf("missing ')' after function argument")
}
p.pos++
return applyFunc(name, arg)
default:
return 0, fmt.Errorf("unknown function or constant: %s", name)
}
}
func applyFunc(name string, x float64) (float64, error) {
switch name {
case "sqrt":
if x < 0 {
return 0, fmt.Errorf("square root of negative number")
}
return math.Sqrt(x), nil
case "sin":
return math.Sin(x), nil
case "cos":
return math.Cos(x), nil
case "tan":
return math.Tan(x), nil
case "abs":
return math.Abs(x), nil
case "floor":
return math.Floor(x), nil
case "ceil":
return math.Ceil(x), nil
case "round":
return math.Round(x), nil
case "log":
if x <= 0 {
return 0, fmt.Errorf("log of non-positive number")
}
return math.Log10(x), nil
case "ln":
if x <= 0 {
return 0, fmt.Errorf("ln of non-positive number")
}
return math.Log(x), nil
}
return 0, fmt.Errorf("unknown function: %s", name)
}
-116
View File
@@ -1,116 +0,0 @@
package crypto
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"hash"
"net/url"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type CryptoPlugin struct{ sdk.BasePlugin }
func (p *CryptoPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "crypto", DisplayName: "Crypto & Encoding", Version: "1.0.0",
Description: "Hashing (MD5/SHA) and encoding (Base64, URL) utilities",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *CryptoPlugin) Tools() []sdk.Tool { return []sdk.Tool{&CryptoTool{}} }
type CryptoTool struct{ sdk.BaseTool }
func (t *CryptoTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "crypto", Name: "crypto", DisplayName: "Crypto & Encoding",
Description: "Crypto hash and encoding utilities. MD5/SHA hashing, Base64 encode/decode, URL encode/decode.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"hash", "base64_encode", "base64_decode", "url_encode", "url_decode"}},
"input": map[string]interface{}{"type": "string"},
"algorithm": map[string]interface{}{"type": "string", "enum": []string{"md5", "sha1", "sha256", "sha512"}},
},
"required": []string{"action", "input"},
},
}
}
func (t *CryptoTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "input"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *CryptoTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
input, _ := args["input"].(string)
switch action {
case "hash":
alg, _ := args["algorithm"].(string)
if alg == "" {
alg = "sha256"
}
var h hash.Hash
switch alg {
case "md5":
h = md5.New()
case "sha1":
h = sha1.New()
case "sha256":
h = sha256.New()
case "sha512":
h = sha512.New()
default:
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unsupported algorithm: " + alg}, nil
}
h.Write([]byte(input))
return &sdk.ToolResult{ToolName: "crypto", Success: true,
Output: fmt.Sprintf("%s: %x", alg, h.Sum(nil))}, nil
case "base64_encode":
return &sdk.ToolResult{ToolName: "crypto", Success: true,
Output: base64.StdEncoding.EncodeToString([]byte(input))}, nil
case "base64_decode":
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if decoded, err := enc.DecodeString(input); err == nil {
return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: truncate(string(decoded), 200)}, nil
}
}
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "failed to decode base64"}, nil
case "url_encode":
return &sdk.ToolResult{ToolName: "crypto", Success: true,
Output: url.QueryEscape(input)}, nil
case "url_decode":
decoded, err := url.QueryUnescape(input)
if err != nil {
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: decoded}, nil
}
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unknown action: " + action}, nil
}
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) > n {
return string(runes[:n]) + "..."
}
return s
}
-170
View File
@@ -1,170 +0,0 @@
package datetime
import (
"context"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type DatetimePlugin struct{ sdk.BasePlugin }
func (p *DatetimePlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "datetime", DisplayName: "Date & Time", Version: "1.0.0",
Description: "Date/time utilities: now, format, arithmetic, diff, timezone list",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *DatetimePlugin) Tools() []sdk.Tool { return []sdk.Tool{&DatetimeTool{}} }
type DatetimeTool struct{ sdk.BaseTool }
func (t *DatetimeTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "datetime", Name: "datetime", DisplayName: "Date & Time",
Description: "Date/time utility. Get current time, format dates, date arithmetic, date diff, list timezones.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"now", "format", "add", "diff", "timezone_list"}},
"format": map[string]interface{}{"type": "string"},
"timezone": map[string]interface{}{"type": "string"},
"date": map[string]interface{}{"type": "string"},
"duration": map[string]interface{}{"type": "string"},
"date2": map[string]interface{}{"type": "string"},
},
"required": []string{"action"},
},
}
}
func (t *DatetimeTool) Validate(args map[string]interface{}) error {
if _, ok := args["action"]; !ok {
return fmt.Errorf("missing required parameter: action")
}
return nil
}
func (t *DatetimeTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
tzStr, _ := args["timezone"].(string)
loc, _ := parseLocation(tzStr)
now := time.Now().In(loc)
switch action {
case "now":
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("Current time: %s (unix: %d, zone: %s)", now.Format(time.RFC3339), now.Unix(), loc.String())}, nil
case "format":
dateStr, _ := args["date"].(string)
format, _ := args["format"].(string)
if format == "" {
format = time.RFC3339
}
parsed, err := parseDate(dateStr, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("Formatted: %s", parsed.Format(format))}, nil
case "add":
dateStr, _ := args["date"].(string)
durStr, _ := args["duration"].(string)
base := now
if dateStr != "" {
var err error
base, err = parseDate(dateStr, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
}
result, err := addDuration(base, durStr)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("%s + %s = %s", base.Format(time.RFC3339), durStr, result.Format(time.RFC3339))}, nil
case "diff":
d1, _ := args["date"].(string)
d2, _ := args["date2"].(string)
t1, err := parseDate(d1, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
t2, err := parseDate(d2, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
diff := t2.Sub(t1)
if diff < 0 {
diff = -diff
}
days := int(diff.Hours()) / 24
hours := int(diff.Hours()) % 24
minutes := int(diff.Minutes()) % 60
seconds := int(diff.Seconds()) % 60
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("Difference: %d days, %d hours, %d minutes, %d seconds", days, hours, minutes, seconds)}, nil
case "timezone_list":
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: "Common timezones: UTC, Asia/Shanghai, Asia/Tokyo, Asia/Seoul, Asia/Singapore, Asia/Kolkata, Asia/Dubai, Europe/London, Europe/Paris, Europe/Moscow, America/New_York, America/Chicago, America/Los_Angeles, America/Sao_Paulo, Australia/Sydney, Pacific/Auckland, Africa/Cairo, Africa/Lagos"}, nil
default:
return &sdk.ToolResult{ToolName: "datetime", Success: false,
Error: fmt.Sprintf("unknown action: %s", action)}, nil
}
}
func parseLocation(tz string) (*time.Location, error) {
if tz == "" {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.UTC, nil
}
return loc, nil
}
return time.LoadLocation(tz)
}
func parseDate(s string, loc *time.Location) (time.Time, error) {
formats := []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02", "2006/01/02"}
for _, f := range formats {
if t, err := time.ParseInLocation(f, s, loc); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse date: %s", s)
}
func addDuration(t time.Time, durStr string) (time.Time, error) {
durStr = strings.TrimSpace(durStr)
if durStr == "" {
return t, nil
}
// Handle months and years
if strings.Contains(durStr, "M") || strings.Contains(durStr, "y") {
months := 0
years := 0
if strings.Contains(durStr, "y") {
fmt.Sscanf(durStr, "%dy", &years)
}
if strings.Contains(durStr, "M") {
fmt.Sscanf(durStr, "%dM", &months)
}
return t.AddDate(years, months, 0), nil
}
d, err := time.ParseDuration(durStr)
if err != nil {
return t, fmt.Errorf("invalid duration: %s", durStr)
}
return t.Add(d), nil
}
-158
View File
@@ -1,158 +0,0 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type FilePlugin struct {
sdk.BasePlugin
dataDir string
}
func NewFilePlugin(dataDir string) *FilePlugin {
if dataDir == "" {
dataDir = "/tmp/cyrene_data"
}
return &FilePlugin{dataDir: dataDir}
}
func (p *FilePlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "file", DisplayName: "File Operations", Version: "1.0.0",
Description: "Sandboxed file operations: read, write, list, delete within DATA_DIR",
Category: "system", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *FilePlugin) Tools() []sdk.Tool { return []sdk.Tool{&FileTool{dataDir: p.dataDir}} }
type FileTool struct {
sdk.BaseTool
dataDir string
}
func (t *FileTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "file_ops", Name: "file_ops", DisplayName: "File Operations",
Description: "File operations within a sandboxed data directory. Read, write, list, check existence, delete.",
Category: "system", Complexity: sdk.ComplexitySimple,
DangerLevel: "medium",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"read", "write", "list", "exists", "delete"}},
"path": map[string]interface{}{"type": "string"},
"content": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "path"},
},
}
}
func (t *FileTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "path"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *FileTool) safePath(p string) (string, error) {
clean := filepath.Clean(p)
abs, err := filepath.Abs(filepath.Join(t.dataDir, clean))
if err != nil {
return "", fmt.Errorf("path resolution failed: %w", err)
}
if !strings.HasPrefix(abs, filepath.Clean(t.dataDir)+string(os.PathSeparator)) && abs != filepath.Clean(t.dataDir) {
return "", fmt.Errorf("path traversal denied: %s", p)
}
return abs, nil
}
func (t *FileTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
pathStr, _ := args["path"].(string)
safePath, err := t.safePath(pathStr)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
switch action {
case "read":
info, err := os.Stat(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
if info.IsDir() {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot read a directory"}, nil
}
if info.Size() > 100*1024 {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "file too large (>100KB)"}, nil
}
data, err := os.ReadFile(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: string(data)}, nil
case "write":
content, _ := args["content"].(string)
dir := filepath.Dir(safePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
if err := os.WriteFile(safePath, []byte(content), 0644); err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Written %d bytes to %s", len(content), pathStr)}, nil
case "list":
entries, err := os.ReadDir(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
var out strings.Builder
for _, e := range entries {
info, _ := e.Info()
if e.IsDir() {
out.WriteString(fmt.Sprintf("[DIR] %s/\n", e.Name()))
} else {
out.WriteString(fmt.Sprintf("[FILE] %s (%d bytes)\n", e.Name(), info.Size()))
}
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: out.String()}, nil
case "exists":
info, err := os.Stat(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path does not exist: %s", pathStr)}, nil
}
kind := "file"
if info.IsDir() {
kind = "directory"
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path exists (%s): %s", kind, pathStr)}, nil
case "delete":
info, err := os.Stat(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
if info.IsDir() {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot delete a directory"}, nil
}
if err := os.Remove(safePath); err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Deleted: %s", pathStr)}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "unknown action: " + action}, nil
}
-3
View File
@@ -1,3 +0,0 @@
module git.yeij.top/AskaEth/Cyrene/pkg/plugins
go 1.21
-122
View File
@@ -1,122 +0,0 @@
package http
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type HTTPPlugin struct {
sdk.BasePlugin
client *http.Client
}
func NewHTTPPlugin() *HTTPPlugin {
return &HTTPPlugin{client: &http.Client{Timeout: 10 * time.Second}}
}
func (p *HTTPPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "http", DisplayName: "HTTP Client", Version: "1.0.0",
Description: "Send arbitrary HTTP requests with custom methods, headers, body",
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *HTTPPlugin) Tools() []sdk.Tool { return []sdk.Tool{&HTTPTool{client: p.client}} }
type HTTPTool struct {
sdk.BaseTool
client *http.Client
}
func (t *HTTPTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "http_request", Name: "http_request", DisplayName: "HTTP Client",
Description: "Send arbitrary HTTP requests. Supports custom methods, headers, and body.",
Category: "network", Complexity: sdk.ComplexitySimple,
DangerLevel: "low",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string"},
"method": map[string]interface{}{"type": "string", "enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}},
"headers": map[string]interface{}{"type": "object"},
"body": map[string]interface{}{"type": "string"},
"timeout": map[string]interface{}{"type": "number"},
},
"required": []string{"url"},
},
}
}
var allowedMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "DELETE": true,
"PATCH": true, "HEAD": true, "OPTIONS": true,
}
func (t *HTTPTool) Validate(args map[string]interface{}) error {
if _, ok := args["url"]; !ok {
return fmt.Errorf("missing required parameter: url")
}
return nil
}
func (t *HTTPTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
urlStr, _ := args["url"].(string)
method, _ := args["method"].(string)
if method == "" {
method = "GET"
}
if !allowedMethods[method] {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "invalid method: " + method}, nil
}
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "only http/https URLs allowed"}, nil
}
var bodyReader io.Reader
if body, _ := args["body"].(string); body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, urlStr, bodyReader)
if err != nil {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil
}
req.Header.Set("User-Agent", "CyreneBot/1.0")
if headers, ok := args["headers"].(map[string]interface{}); ok {
for k, v := range headers {
req.Header.Set(k, fmt.Sprint(v))
}
}
client := t.client
if timeout, _ := args["timeout"].(float64); timeout > 0 {
client = &http.Client{Timeout: time.Duration(timeout) * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 50*1024))
return &sdk.ToolResult{ToolName: "http_request", Success: resp.StatusCode < 500, Output: fmt.Sprintf(
"HTTP %d\n%s\n\n%s", resp.StatusCode, formatHeaders(resp.Header), string(bodyBytes))}, nil
}
func formatHeaders(h http.Header) string {
var lines []string
for k, v := range h {
lines = append(lines, fmt.Sprintf("%s: %s", k, strings.Join(v, ", ")))
}
return strings.Join(lines, "\n")
}
-189
View File
@@ -1,189 +0,0 @@
package iotcontrol
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
// IoTController extends IoTClient with control operations.
type IoTController interface {
GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error
ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
}
type IoTControlPlugin struct {
sdk.BasePlugin
iotClient IoTController
}
func NewIoTControlPlugin(client IoTController) *IoTControlPlugin {
return &IoTControlPlugin{iotClient: client}
}
func (p *IoTControlPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "iot_control", DisplayName: "IoT Device Control", Version: "1.0.0",
Description: "Control smart home devices: toggle, set temperature/brightness/mode/color",
Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *IoTControlPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&IoTControlTool{iotClient: p.iotClient}}
}
type IoTControlTool struct {
sdk.BaseTool
iotClient IoTController
}
func (t *IoTControlTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "iot_control", Name: "iot_control", DisplayName: "IoT Device Control",
Description: "Control smart home devices. Supports toggle, turn_on, turn_off, set_temperature, set_brightness, set_position, set_mode, set_color.",
Category: "iot", Complexity: sdk.ComplexitySimple,
DangerLevel: "medium",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string"},
"action": map[string]interface{}{"type": "string", "enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"}},
"value": map[string]interface{}{},
},
"required": []string{"device_id", "action"},
},
}
}
func (t *IoTControlTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"device_id", "action"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *IoTControlTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
if t.iotClient == nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: "IoT client not configured"}, nil
}
deviceID, _ := args["device_id"].(string)
if deviceID == "" {
deviceID, _ = args["entity_id"].(string)
}
action := normalizeAction(args)
switch action {
case "turn_on", "turn_off":
status := "on"
if action == "turn_off" {
status = "off"
}
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "status", status); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Device %s turned %s", deviceID, status)}, nil
case "set_temperature":
value := toFloat64(args["value"])
old := ""
if dev, err := t.iotClient.GetDevice(ctx, deviceID); err == nil {
old = fmt.Sprintf(" (was %.1fC)", dev.Temperature)
}
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "temperature", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Temperature set to %.1fC%s", value, old)}, nil
case "set_brightness":
value := toFloat64(args["value"])
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "brightness", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Brightness set to %.0f%%", value)}, nil
case "set_position":
value := toFloat64(args["value"])
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "position", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Position set to %.0f%%", value)}, nil
case "set_mode":
value, _ := args["value"].(string)
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "mode", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Mode set to %s", value)}, nil
case "set_color":
value, _ := args["value"].(string)
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "color", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Color set to %s", value)}, nil
case "toggle":
dev, err := t.iotClient.ToggleDevice(ctx, deviceID)
if err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Device %s toggled to %s", deviceID, dev.Status)}, nil
default:
return &sdk.ToolResult{ToolName: "iot_control", Success: false,
Error: fmt.Sprintf("unknown action: %s", action)}, nil
}
}
func normalizeAction(args map[string]interface{}) string {
action, _ := args["action"].(string)
// Chinese aliases
switch action {
case "打开":
return "turn_on"
case "关闭", "关掉", "关上":
return "turn_off"
case "设置温度", "调温度":
return "set_temperature"
case "设置亮度", "调亮度":
return "set_brightness"
case "设置位置":
return "set_position"
case "设置模式":
return "set_mode"
case "设置颜色":
return "set_color"
case "开关", "切换":
return "toggle"
}
return action
}
func toFloat64(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case string:
var f float64
fmt.Sscanf(n, "%f", &f)
return f
}
return 0
}
-120
View File
@@ -1,120 +0,0 @@
package iotquery
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
// IoTClient is the interface for IoT device access.
type IoTClient interface {
GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error)
GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
}
type IoTQueryPlugin struct {
sdk.BasePlugin
iotClient IoTClient
}
func NewIoTQueryPlugin(client IoTClient) *IoTQueryPlugin {
return &IoTQueryPlugin{iotClient: client}
}
func (p *IoTQueryPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "iot_query", DisplayName: "IoT Device Query", Version: "1.0.0",
Description: "Query smart home device status (single device or all devices)",
Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *IoTQueryPlugin) Tools() []sdk.Tool { return []sdk.Tool{&IoTQueryTool{iotClient: p.iotClient}} }
type IoTQueryTool struct {
sdk.BaseTool
iotClient IoTClient
}
func (t *IoTQueryTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "iot_query", Name: "iot_query", DisplayName: "IoT Device Query",
Description: "Query smart home device status. Device status is typically auto-injected; use this only when status is stale.",
Category: "iot", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"device_id": map[string]interface{}{"type": "string"}},
},
}
}
func (t *IoTQueryTool) Validate(args map[string]interface{}) error { return nil }
func (t *IoTQueryTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
if t.iotClient == nil {
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: "IoT client not configured"}, nil
}
deviceID, _ := args["device_id"].(string)
if deviceID != "" {
dev, err := t.iotClient.GetDevice(ctx, deviceID)
if err != nil {
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: formatDevice(dev)}, nil
}
devices, err := t.iotClient.GetAllDevices(ctx)
if err != nil {
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil
}
if len(devices) == 0 {
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: "No devices found"}, nil
}
var out string
for _, d := range devices {
out += formatDeviceLine(&d) + "\n"
}
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: out}, nil
}
func formatDevice(d *sdk.IoTDeviceState) string {
emoji := deviceEmoji(d.Type)
return fmt.Sprintf("%s %s (%s)\n Status: %s\n ID: %s", emoji, d.Name, d.Type, d.Status, d.ID)
}
func formatDeviceLine(d *sdk.IoTDeviceState) string {
emoji := deviceEmoji(d.Type)
switch d.Type {
case "light":
return fmt.Sprintf("%s %s: %s (brightness: %d, color: %s)", emoji, d.Name, d.Status, d.Brightness, d.Color)
case "ac":
return fmt.Sprintf("%s %s: %s (mode: %s, temp: %.1fC)", emoji, d.Name, d.Status, d.Mode, d.Temperature)
case "curtain":
return fmt.Sprintf("%s %s: %s (position: %d%%)", emoji, d.Name, d.Status, d.Position)
case "sensor":
return fmt.Sprintf("%s %s: %.1f%s", emoji, d.Name, d.Value, d.Unit)
case "lock":
return fmt.Sprintf("%s %s: %s (battery: %d%%)", emoji, d.Name, d.Status, d.Battery)
default:
return fmt.Sprintf("%s %s: %s", emoji, d.Name, d.Status)
}
}
func deviceEmoji(t string) string {
switch t {
case "light":
return "\U0001F4A1"
case "ac":
return "❄️"
case "curtain":
return "\U0001F3E0"
case "sensor":
return "\U0001F4CA"
case "lock":
return "\U0001F512"
default:
return "\U0001F4E6"
}
}
-132
View File
@@ -1,132 +0,0 @@
package jsonplugin
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type JSONPlugin struct{ sdk.BasePlugin }
func (p *JSONPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "json", DisplayName: "JSON Processor", Version: "1.0.0",
Description: "JSON parsing, dot-path query, validation, pretty-print",
Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *JSONPlugin) Tools() []sdk.Tool { return []sdk.Tool{&JSONTool{}} }
type JSONTool struct{ sdk.BaseTool }
func (t *JSONTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "json_ops", Name: "json_ops", DisplayName: "JSON Processor",
Description: "JSON processing. Parse/pretty-print, query by dot-notation path, validate.",
Category: "format", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"parse", "query", "validate"}},
"json_string": map[string]interface{}{"type": "string"},
"path": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "json_string"},
},
}
}
func (t *JSONTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "json_string"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *JSONTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
jsonStr, _ := args["json_string"].(string)
switch action {
case "parse":
var v interface{}
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(pretty)}, nil
case "query":
var v interface{}
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
path, _ := args["path"].(string)
if path == "" {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "path is required for query"}, nil
}
result, err := jsonPathQuery(v, path)
if err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
out, _ := json.Marshal(result)
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(out)}, nil
case "validate":
var v interface{}
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: "Invalid JSON: " + err.Error()}, nil
}
typeStr := "unknown"
switch v.(type) {
case map[string]interface{}:
typeStr = "object"
case []interface{}:
typeStr = "array"
case string:
typeStr = "string"
case float64:
typeStr = "number"
case bool:
typeStr = "boolean"
}
return &sdk.ToolResult{ToolName: "json_ops", Success: true,
Output: fmt.Sprintf("Valid JSON (type: %s, size: %d bytes)", typeStr, len(jsonStr))}, nil
}
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "unknown action: " + action}, nil
}
func jsonPathQuery(root interface{}, path string) (interface{}, error) {
path = strings.TrimPrefix(path, "$.")
parts := strings.Split(path, ".")
current := root
for _, part := range parts {
switch v := current.(type) {
case map[string]interface{}:
var ok bool
current, ok = v[part]
if !ok {
return nil, fmt.Errorf("key %q not found", part)
}
case []interface{}:
idx, err := strconv.Atoi(part)
if err != nil || idx < 0 || idx >= len(v) {
return nil, fmt.Errorf("invalid array index: %s", part)
}
current = v[idx]
default:
return nil, fmt.Errorf("cannot traverse into %T at path segment %q", current, part)
}
}
return current, nil
}
-226
View File
@@ -1,226 +0,0 @@
package manager
import (
"context"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
// PluginManager manages the lifecycle of all plugins and their tools.
type PluginManager struct {
mu sync.RWMutex
plugins map[string]*pluginEntry
registry *ToolRegistry
host sdk.HostAPI
}
type pluginEntry struct {
instance sdk.Plugin
info sdk.PluginInfo
cancel context.CancelFunc
}
func NewPluginManager(registry *ToolRegistry, host sdk.HostAPI) *PluginManager {
return &PluginManager{
plugins: make(map[string]*pluginEntry),
registry: registry,
host: host,
}
}
// Install registers a plugin instance.
func (m *PluginManager) Install(plugin sdk.Plugin) error {
meta := plugin.Metadata()
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.plugins[meta.Name]; exists {
return fmt.Errorf("plugin %q is already installed", meta.Name)
}
m.plugins[meta.Name] = &pluginEntry{
instance: plugin,
info: sdk.PluginInfo{
Metadata: meta,
Status: sdk.StatusInstalled,
InstalledAt: time.Now(),
Enabled: false,
},
}
return nil
}
// Enable activates a plugin: Init → register tools → Start.
func (m *PluginManager) Enable(ctx context.Context, pluginName string) error {
m.mu.Lock()
entry, ok := m.plugins[pluginName]
m.mu.Unlock()
if !ok {
return fmt.Errorf("plugin %q not found", pluginName)
}
m.mu.Lock()
entry.info.Status = sdk.StatusLoaded
m.mu.Unlock()
meta := entry.instance.Metadata()
if err := entry.instance.Init(ctx, nil); err != nil {
m.mu.Lock()
entry.info.Status = sdk.StatusError
m.mu.Unlock()
return fmt.Errorf("plugin %q init failed: %w", meta.Name, err)
}
pluginCtx, cancel := context.WithCancel(context.Background())
if err := entry.instance.Start(pluginCtx, m.host); err != nil {
cancel()
m.mu.Lock()
entry.info.Status = sdk.StatusError
m.mu.Unlock()
return fmt.Errorf("plugin %q start failed: %w", meta.Name, err)
}
tools := entry.instance.Tools()
toolIDs := make([]string, 0, len(tools))
for _, t := range tools {
if err := m.registry.Register(t); err != nil {
m.registry.UnregisterAll(toolIDs)
cancel()
m.mu.Lock()
entry.info.Status = sdk.StatusError
m.mu.Unlock()
return fmt.Errorf("plugin %q tool register failed: %w", meta.Name, err)
}
toolIDs = append(toolIDs, t.Definition().ID)
}
m.mu.Lock()
entry.cancel = cancel
entry.info.Status = sdk.StatusRunning
entry.info.Enabled = true
entry.info.Tools = toolIDs
m.mu.Unlock()
return nil
}
// Disable stops a plugin and unregisters its tools.
func (m *PluginManager) Disable(ctx context.Context, pluginName string) error {
m.mu.Lock()
entry, ok := m.plugins[pluginName]
m.mu.Unlock()
if !ok {
return fmt.Errorf("plugin %q not found", pluginName)
}
if err := entry.instance.Stop(ctx); err != nil {
return fmt.Errorf("plugin %q stop failed: %w", pluginName, err)
}
if entry.cancel != nil {
entry.cancel()
}
m.registry.UnregisterAll(entry.info.Tools)
m.mu.Lock()
entry.info.Status = sdk.StatusDisabled
entry.info.Enabled = false
entry.info.Tools = nil
m.mu.Unlock()
return nil
}
// List returns info for all installed plugins.
func (m *PluginManager) List() []sdk.PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]sdk.PluginInfo, 0, len(m.plugins))
for _, entry := range m.plugins {
result = append(result, entry.info)
}
return result
}
// Get returns info for a single plugin.
func (m *PluginManager) Get(pluginName string) (*sdk.PluginInfo, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
entry, ok := m.plugins[pluginName]
if !ok {
return nil, false
}
info := entry.info
return &info, true
}
// EnableAll starts all installed plugins.
func (m *PluginManager) EnableAll(ctx context.Context) []error {
m.mu.RLock()
names := make([]string, 0, len(m.plugins))
for name := range m.plugins {
names = append(names, name)
}
m.mu.RUnlock()
var errs []error
for _, name := range names {
if err := m.Enable(ctx, name); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", name, err))
}
}
return errs
}
// Uninstall removes a plugin completely.
func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error {
m.mu.RLock()
entry, ok := m.plugins[pluginName]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("plugin %q not found", pluginName)
}
if entry.info.Status == sdk.StatusRunning {
if err := m.Disable(ctx, pluginName); err != nil {
return err
}
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.plugins, pluginName)
return nil
}
// Reload stops and re-starts a plugin.
func (m *PluginManager) Reload(ctx context.Context, pluginName string) error {
if err := m.Disable(ctx, pluginName); err != nil {
return fmt.Errorf("reload disable: %w", err)
}
return m.Enable(ctx, pluginName)
}
// Shutdown stops all running plugins gracefully.
func (m *PluginManager) Shutdown(ctx context.Context) []error {
m.mu.RLock()
names := make([]string, 0, len(m.plugins))
for name, entry := range m.plugins {
if entry.info.Status == sdk.StatusRunning {
names = append(names, name)
}
}
m.mu.RUnlock()
var errs []error
for _, name := range names {
if err := m.Disable(ctx, name); err != nil {
errs = append(errs, err)
}
}
return errs
}
// Registry returns the aggregated tool registry.
func (m *PluginManager) Registry() *ToolRegistry {
return m.registry
}
-298
View File
@@ -1,298 +0,0 @@
package manager
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
// CallLogRecord 工具调用记录
type CallLogRecord struct {
CallID string `json:"call_id"`
ToolName string `json:"tool_name"`
Arguments string `json:"arguments"`
Output string `json:"output"`
Error string `json:"error"`
Success bool `json:"success"`
DurationMs int `json:"duration_ms"`
Timestamp int64 `json:"timestamp"`
}
// callLogRing 线程安全的环形缓冲区
type callLogRing struct {
mu sync.Mutex
records []CallLogRecord
capacity int
head int
size int
}
func newCallLogRing(capacity int) *callLogRing {
return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)}
}
func (r *callLogRing) push(rec CallLogRecord) {
r.mu.Lock()
defer r.mu.Unlock()
rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano())
rec.Timestamp = time.Now().UnixMilli()
r.records[r.head] = rec
r.head = (r.head + 1) % r.capacity
if r.size < r.capacity {
r.size++
}
}
func (r *callLogRing) getAll() []CallLogRecord {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]CallLogRecord, r.size)
for i := 0; i < r.size; i++ {
idx := (r.head - 1 - i) % r.capacity
if idx < 0 {
idx += r.capacity
}
result[i] = r.records[idx]
}
return result
}
func (r *callLogRing) statsByTool() map[string]map[string]interface{} {
r.mu.Lock()
defer r.mu.Unlock()
byTool := make(map[string]map[string]interface{})
for i := 0; i < r.size; i++ {
idx := (r.head - 1 - i) % r.capacity
if idx < 0 {
idx += r.capacity
}
rec := r.records[idx]
if _, ok := byTool[rec.ToolName]; !ok {
byTool[rec.ToolName] = map[string]interface{}{
"tool_name": rec.ToolName, "count": 0, "success_count": 0,
"fail_count": 0, "total_duration_ms": 0,
}
}
s := byTool[rec.ToolName]
s["count"] = s["count"].(int) + 1
if rec.Success {
s["success_count"] = s["success_count"].(int) + 1
} else {
s["fail_count"] = s["fail_count"].(int) + 1
}
s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs
}
return byTool
}
// ToolRegistry aggregates tool definitions from all running plugins and dispatches execution.
type ToolRegistry struct {
mu sync.RWMutex
tools map[string]sdk.Tool // tool ID -> Tool
callLog *callLogRing
enabled bool
}
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{
tools: make(map[string]sdk.Tool),
callLog: newCallLogRing(500),
enabled: true,
}
}
// IsEnabled returns whether tool execution is enabled.
func (r *ToolRegistry) IsEnabled() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.enabled
}
// SetEnabled enables or disables tool execution.
func (r *ToolRegistry) SetEnabled(enabled bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.enabled = enabled
}
// DefinitionNames returns all registered tool names.
func (r *ToolRegistry) DefinitionNames() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for id := range r.tools {
names = append(names, id)
}
return names
}
func (r *ToolRegistry) Register(tool sdk.Tool) error {
r.mu.Lock()
defer r.mu.Unlock()
id := tool.Definition().ID
if _, exists := r.tools[id]; exists {
return fmt.Errorf("tool %q already registered", id)
}
r.tools[id] = tool
return nil
}
func (r *ToolRegistry) Unregister(toolID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.tools, toolID)
}
func (r *ToolRegistry) Get(toolID string) (sdk.Tool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.tools[toolID]
return t, ok
}
func (r *ToolRegistry) List() []sdk.Tool {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]sdk.Tool, 0, len(r.tools))
for _, t := range r.tools {
result = append(result, t)
}
return result
}
func (r *ToolRegistry) Definitions() []sdk.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]sdk.ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
defs = append(defs, t.Definition())
}
return defs
}
func (r *ToolRegistry) Execute(ctx context.Context, toolID string, args map[string]interface{}) (*sdk.ToolResult, error) {
r.mu.RLock()
tool, ok := r.tools[toolID]
r.mu.RUnlock()
startTime := time.Now()
if !ok {
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: fmt.Sprintf("tool %q not found", toolID),
Success: false, DurationMs: int(time.Since(startTime).Milliseconds()),
})
return nil, fmt.Errorf("tool %q not found", toolID)
}
if err := tool.Validate(args); err != nil {
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: err.Error(), Success: false,
DurationMs: int(time.Since(startTime).Milliseconds()),
})
return &sdk.ToolResult{Success: false, Error: err.Error()}, nil
}
result, err := tool.Execute(ctx, args)
durationMs := int(time.Since(startTime).Milliseconds())
if err != nil {
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: err.Error(), Success: false, DurationMs: durationMs,
})
return result, err
}
var argsJSON string
if args != nil {
if b, _ := json.Marshal(args); b != nil {
argsJSON = string(b)
}
}
r.callLog.push(CallLogRecord{
ToolName: toolID, Arguments: argsJSON, Output: result.Output,
Error: result.Error, Success: result.Success, DurationMs: durationMs,
})
return result, nil
}
// UnregisterAll removes all tools matching given IDs.
func (r *ToolRegistry) UnregisterAll(toolIDs []string) {
r.mu.Lock()
defer r.mu.Unlock()
for _, id := range toolIDs {
delete(r.tools, id)
}
}
// GetCallLogs 获取工具调用记录(最新在前,支持按工具名过滤、分页)
func (r *ToolRegistry) GetCallLogs(toolName string, limit, offset int) ([]CallLogRecord, int) {
all := r.callLog.getAll()
// 过滤
var filtered []CallLogRecord
if toolName == "" {
filtered = all
} else {
filtered = make([]CallLogRecord, 0)
for _, rec := range all {
if rec.ToolName == toolName {
filtered = append(filtered, rec)
}
}
}
total := len(filtered)
// 分页
if offset >= len(filtered) {
return []CallLogRecord{}, total
}
page := filtered[offset:]
if limit > 0 && limit < len(page) {
page = page[:limit]
}
return page, total
}
// GetCallStats 获取工具调用统计
func (r *ToolRegistry) GetCallStats() map[string]interface{} {
byTool := r.callLog.statsByTool()
totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0
toolStats := make([]map[string]interface{}, 0, len(byTool))
for _, s := range byTool {
count := s["count"].(int)
success := s["success_count"].(int)
fail := s["fail_count"].(int)
totalDur := s["total_duration_ms"].(int)
avgDur := 0.0
if count > 0 {
avgDur = float64(totalDur) / float64(count)
}
s["avg_duration_ms"] = avgDur
delete(s, "total_duration_ms")
toolStats = append(toolStats, s)
totalCalls += count
successCount += success
failCount += fail
totalDurationMs += totalDur
}
avgDuration := 0.0
if totalCalls > 0 {
avgDuration = float64(totalDurationMs) / float64(totalCalls)
}
successRate := 0.0
if totalCalls > 0 {
successRate = float64(successCount) / float64(totalCalls) * 100
}
return map[string]interface{}{
"total_calls": totalCalls, "success_count": successCount, "fail_count": failCount,
"success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats,
}
}
-184
View File
@@ -1,184 +0,0 @@
package markdown
import (
"context"
"fmt"
"regexp"
"strings"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type MarkdownPlugin struct{ sdk.BasePlugin }
func (p *MarkdownPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "markdown", DisplayName: "Markdown Processor", Version: "1.0.0",
Description: "Markdown processing: to HTML, extract text/links/code, generate TOC",
Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *MarkdownPlugin) Tools() []sdk.Tool { return []sdk.Tool{&MarkdownTool{}} }
type MarkdownTool struct{ sdk.BaseTool }
func (t *MarkdownTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "markdown", Name: "markdown", DisplayName: "Markdown Processor",
Description: "Markdown processing. Convert to HTML, extract plain text, extract links/code blocks, generate TOC.",
Category: "format", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"to_html", "to_text", "extract_links", "extract_code", "table_of_contents"}},
"markdown": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "markdown"},
},
}
}
func (t *MarkdownTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "markdown"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *MarkdownTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
md, _ := args["markdown"].(string)
switch action {
case "to_html":
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: mdToHTML(md)}, nil
case "to_text":
text := md
reCode := regexp.MustCompile("(?s)```.*?```")
text = reCode.ReplaceAllString(text, "")
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`^#{1,6}\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`^[*-]\s+`).ReplaceAllString(text, "- ")
text = regexp.MustCompile(`^>\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: strings.TrimSpace(text)}, nil
case "extract_links":
re := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
matches := re.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No links found"}, nil
}
var out strings.Builder
for i, m := range matches {
out.WriteString(fmt.Sprintf("%d. %s -> %s\n", i+1, m[1], m[2]))
}
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
case "extract_code":
re := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```")
matches := re.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No code blocks found"}, nil
}
var out strings.Builder
for i, m := range matches {
lang := m[1]
if lang == "" {
lang = "text"
}
code := m[2]
if len([]rune(code)) > 500 {
code = string([]rune(code)[:500]) + "..."
}
out.WriteString(fmt.Sprintf("--- Block %d (%s) ---\n%s\n\n", i+1, lang, code))
}
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
case "table_of_contents":
re := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
matches := re.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No headings found"}, nil
}
var out strings.Builder
for _, m := range matches {
depth := len(m[1])
indent := strings.Repeat(" ", depth-1)
out.WriteString(fmt.Sprintf("%s- %s\n", indent, m[2]))
}
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
}
return &sdk.ToolResult{ToolName: "markdown", Success: false, Error: "unknown action: " + action}, nil
}
func mdToHTML(md string) string {
// Save code blocks
type placeholder struct {
orig string
content string
language string
}
blocks := []*placeholder{}
reCode := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```")
md = reCode.ReplaceAllStringFunc(md, func(s string) string {
m := reCode.FindStringSubmatch(s)
b := &placeholder{orig: fmt.Sprintf("\x00CODE%d\x00", len(blocks)), language: m[1], content: escapeHTML(m[2])}
blocks = append(blocks, b)
return b.orig
})
// Inline elements
md = regexp.MustCompile("`([^`]+)`").ReplaceAllString(md, "<code>$1</code>")
md = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`).ReplaceAllString(md, `<img src="$2" alt="$1">`)
md = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
md = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(md, `<strong>$1</strong>`)
md = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(md, `<em>$1</em>`)
md = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(md, `<del>$1</del>`)
md = regexp.MustCompile(`(?m)^#{6}\s+(.+)$`).ReplaceAllString(md, `<h6>$1</h6>`)
md = regexp.MustCompile(`(?m)^#{5}\s+(.+)$`).ReplaceAllString(md, `<h5>$1</h5>`)
md = regexp.MustCompile(`(?m)^#{4}\s+(.+)$`).ReplaceAllString(md, `<h4>$1</h4>`)
md = regexp.MustCompile(`(?m)^#{3}\s+(.+)$`).ReplaceAllString(md, `<h3>$1</h3>`)
md = regexp.MustCompile(`(?m)^#{2}\s+(.+)$`).ReplaceAllString(md, `<h2>$1</h2>`)
md = regexp.MustCompile(`(?m)^#{1}\s+(.+)$`).ReplaceAllString(md, `<h1>$1</h1>`)
md = regexp.MustCompile(`(?m)^---\s*$`).ReplaceAllString(md, `<hr>`)
md = regexp.MustCompile(`(?m)^>\s+(.+)$`).ReplaceAllString(md, `<blockquote>$1</blockquote>`)
// Restore code blocks
for _, b := range blocks {
langAttr := ""
if b.language != "" {
langAttr = " class=\"language-" + b.language + "\""
}
md = strings.Replace(md, b.orig, "<pre><code"+langAttr+">"+b.content+"</code></pre>", 1)
}
// Paragraphs
lines := strings.Split(md, "\n")
var out strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "<") {
out.WriteString(trimmed + "\n")
} else {
out.WriteString("<p>" + trimmed + "</p>\n")
}
}
return out.String()
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
-175
View File
@@ -1,175 +0,0 @@
package random
import (
"context"
"crypto/rand"
"fmt"
"math/big"
mathrand "math/rand"
"strings"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type RandomPlugin struct{ sdk.BasePlugin }
func (p *RandomPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "random", DisplayName: "Random Generator", Version: "1.0.0",
Description: "Random generation: numbers, UUIDs, secure passwords, pick/shuffle",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *RandomPlugin) Tools() []sdk.Tool { return []sdk.Tool{&RandomTool{}} }
type RandomTool struct{ sdk.BaseTool }
func (t *RandomTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "random", Name: "random", DisplayName: "Random Generator",
Description: "Random generation. Random numbers, UUID v4, secure passwords, pick from list, shuffle list.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"number", "uuid", "password", "pick", "shuffle"}},
"min": map[string]interface{}{"type": "number"},
"max": map[string]interface{}{"type": "number"},
"length": map[string]interface{}{"type": "number"},
"items": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"count": map[string]interface{}{"type": "number"},
},
"required": []string{"action"},
},
}
}
func (t *RandomTool) Validate(args map[string]interface{}) error {
if _, ok := args["action"]; !ok {
return fmt.Errorf("missing required parameter: action")
}
return nil
}
func (t *RandomTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
switch action {
case "number":
min := getIntArg(args, "min", 0)
max := getIntArg(args, "max", 100)
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return &sdk.ToolResult{ToolName: "random", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "random", Success: true,
Output: fmt.Sprintf("%d", int(n.Int64())+min)}, nil
case "uuid":
uuid := make([]byte, 16)
rand.Read(uuid)
uuid[6] = (uuid[6] & 0x0f) | 0x40
uuid[8] = (uuid[8] & 0x3f) | 0x80
return &sdk.ToolResult{ToolName: "random", Success: true,
Output: fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:])}, nil
case "password":
length := getIntArg(args, "length", 16)
if length < 4 {
length = 4
}
if length > 128 {
length = 128
}
upper := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lower := "abcdefghijklmnopqrstuvwxyz"
digits := "0123456789"
symbols := "!@#$%^&*()_+-=[]{}|;:,.<>?"
all := upper + lower + digits + symbols
bytes := make([]byte, length)
for i := range bytes {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(all))))
bytes[i] = all[idx.Int64()]
}
return &sdk.ToolResult{ToolName: "random", Success: true, Output: string(bytes)}, nil
case "pick":
items := getStringSliceArg(args, "items")
if len(items) == 0 {
return &sdk.ToolResult{ToolName: "random", Success: false, Error: "items list is empty"}, nil
}
count := getIntArg(args, "count", 1)
if count > len(items) {
count = len(items)
}
indices := shuffledIndices(len(items))
picked := make([]string, count)
for i := 0; i < count; i++ {
picked[i] = items[indices[i]]
}
return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(picked, ", ")}, nil
case "shuffle":
items := getStringSliceArg(args, "items")
indices := shuffledIndices(len(items))
shuffled := make([]string, len(items))
for i, idx := range indices {
shuffled[i] = items[idx]
}
return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(shuffled, ", ")}, nil
}
return &sdk.ToolResult{ToolName: "random", Success: false, Error: "unknown action: " + action}, nil
}
func getIntArg(args map[string]interface{}, key string, defaultVal int) int {
v, ok := args[key]
if !ok {
return defaultVal
}
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
}
return defaultVal
}
func getStringSliceArg(args map[string]interface{}, key string) []string {
v, ok := args[key]
if !ok {
return nil
}
switch s := v.(type) {
case []string:
return s
case []interface{}:
result := make([]string, len(s))
for i, item := range s {
result[i] = fmt.Sprint(item)
}
return result
}
return nil
}
func shuffledIndices(n int) []int {
indices := make([]int, n)
for i := range indices {
indices[i] = i
}
for i := n - 1; i > 0; i-- {
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
j := mathrand.Intn(i + 1)
indices[i], indices[j] = indices[j], indices[i]
continue
}
j := int(jBig.Int64())
indices[i], indices[j] = indices[j], indices[i]
}
return indices
}
-40
View File
@@ -1,40 +0,0 @@
package sdk
import (
"context"
"fmt"
)
// BasePlugin provides default implementations for optional Plugin methods.
type BasePlugin struct{}
func (BasePlugin) Init(_ context.Context, _ PluginConfig) error { return nil }
func (BasePlugin) Start(_ context.Context, _ HostAPI) error { return nil }
func (BasePlugin) Stop(_ context.Context) error { return nil }
func (BasePlugin) Health(_ context.Context) error { return nil }
// BaseTool provides a Validate default that checks required parameters.
type BaseTool struct {
Def ToolDefinition
Required []string
}
func (b BaseTool) Definition() ToolDefinition { return b.Def }
func (b BaseTool) Complexity() ToolComplexity { return ComplexitySimple }
func (b BaseTool) Validate(args map[string]interface{}) error {
for _, key := range b.Required {
if _, ok := args[key]; !ok {
return fmt.Errorf("missing required parameter: %s", key)
}
}
return nil
}
func (b BaseTool) Execute(_ context.Context, _ map[string]interface{}) (*ToolResult, error) {
return nil, fmt.Errorf("not implemented")
}
-35
View File
@@ -1,35 +0,0 @@
package sdk
// PluginPermissions defines what a plugin is allowed to do.
type PluginPermissions struct {
NetworkAllowed bool `json:"networkAllowed"`
AllowedHosts []string `json:"allowedHosts,omitempty"`
IoTRead bool `json:"iotRead"`
IoTWrite bool `json:"iotWrite"`
MemoryRead bool `json:"memoryRead"`
MemoryWrite bool `json:"memoryWrite"`
FileRead bool `json:"fileRead"`
FileWrite bool `json:"fileWrite"`
AllowedPaths []string `json:"allowedPaths,omitempty"`
ExecAllowed bool `json:"execAllowed"`
MaxCPUPercent float64 `json:"maxCPUPercent"`
MaxMemoryMB int `json:"maxMemoryMB"`
}
// DefaultPermissions returns a safe default permission set.
func DefaultPermissions() PluginPermissions {
return PluginPermissions{
NetworkAllowed: false,
AllowedHosts: []string{},
IoTRead: false,
IoTWrite: false,
MemoryRead: false,
MemoryWrite: false,
FileRead: false,
FileWrite: false,
AllowedPaths: []string{},
ExecAllowed: false,
MaxCPUPercent: 10.0,
MaxMemoryMB: 128,
}
}
-49
View File
@@ -1,49 +0,0 @@
package sdk
import (
"context"
"net/http"
)
// Plugin is the main interface every plugin must implement.
type Plugin interface {
Metadata() PluginMetadata
Init(ctx context.Context, config PluginConfig) error
Start(ctx context.Context, host HostAPI) error
Stop(ctx context.Context) error
Health(ctx context.Context) error
Tools() []Tool
}
// Tool is the interface every tool must implement.
type Tool interface {
Definition() ToolDefinition
Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
Validate(args map[string]interface{}) error
Complexity() ToolComplexity
}
// ComplexTool extends Tool for async multi-round execution.
type ComplexTool interface {
Tool
ExecuteAsync(ctx context.Context, args map[string]interface{}) (<-chan ToolProgress, error)
Cancel(ctx context.Context, executionID string) error
}
// HostAPI gives plugins access to Cyrene core capabilities.
type HostAPI interface {
CallLLM(ctx context.Context, messages []LLMMessage) (*LLMResponse, error)
SearchMemory(ctx context.Context, userID, query string, limit int) ([]MemoryEntry, error)
StoreMemory(ctx context.Context, entry MemoryEntry) error
Logger() Logger
GetConfig(key string) (string, error)
SetConfig(key, value string) error
PublishEvent(ctx context.Context, event map[string]interface{}) error
HTTPClient() *http.Client
}
// Logger is a minimal logging interface for plugins.
type Logger interface {
Printf(format string, args ...interface{})
Println(args ...interface{})
}
-134
View File
@@ -1,134 +0,0 @@
package sdk
import "time"
// ToolComplexity grades tools into simple (single-call, <2s) and complex (multi-round, async).
type ToolComplexity string
const (
ComplexitySimple ToolComplexity = "simple"
ComplexityComplex ToolComplexity = "complex"
)
// PluginMetadata describes a plugin's identity and requirements.
type PluginMetadata struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
MinCyreneVersion string `json:"minCyreneVersion"`
Author PluginAuthor `json:"author"`
Description string `json:"description"`
License string `json:"license"`
Keywords []string `json:"keywords,omitempty"`
Category string `json:"category"`
Dependencies map[string]string `json:"dependencies,omitempty"` // plugin name -> version range
Homepage string `json:"homepage,omitempty"`
Repository string `json:"repository,omitempty"`
}
type PluginAuthor struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
URL string `json:"url,omitempty"`
}
// PluginConfig holds runtime configuration for a plugin.
type PluginConfig map[string]interface{}
// ToolDefinition describes a tool's interface for LLM function calling.
type ToolDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Category string `json:"category"`
Complexity ToolComplexity `json:"complexity"`
Parameters map[string]interface{} `json:"parameters"`
Returns map[string]interface{} `json:"returns,omitempty"`
TimeoutMs int `json:"timeout_ms,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
DangerLevel string `json:"danger_level,omitempty"` // low / medium / high
}
// ToolResult is the standard tool execution result.
type ToolResult struct {
ToolName string `json:"tool_name"`
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// ToolProgress reports execution progress for complex (async) tools.
type ToolProgress struct {
ExecutionID string `json:"execution_id"`
Status string `json:"status"` // started / running / completed / failed / cancelled
Progress float64 `json:"progress"` // 0.0 - 1.0
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Result *ToolResult `json:"result,omitempty"`
}
// PluginStatus represents the current lifecycle state of a plugin.
type PluginStatus string
const (
StatusInstalled PluginStatus = "installed"
StatusLoaded PluginStatus = "loaded"
StatusRunning PluginStatus = "running"
StatusPaused PluginStatus = "paused"
StatusError PluginStatus = "error"
StatusDisabled PluginStatus = "disabled"
)
// PluginInfo is the runtime view of an installed plugin.
type PluginInfo struct {
Metadata PluginMetadata `json:"metadata"`
Status PluginStatus `json:"status"`
Tools []string `json:"tools"` // tool IDs provided by this plugin
InstalledAt time.Time `json:"installed_at"`
Enabled bool `json:"enabled"`
}
// LLMMessage is a message in an LLM conversation.
type LLMMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// LLMResponse is the result of an LLM call.
type LLMResponse struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// ToolCall represents a tool call requested by the LLM.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// IoTDeviceState is the shared device state across IoT plugins.
type IoTDeviceState struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Brightness int `json:"brightness,omitempty"`
Color string `json:"color,omitempty"`
Mode string `json:"mode,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Position int `json:"position,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Battery int `json:"battery,omitempty"`
}
// MemoryEntry is a memory record.
type MemoryEntry struct {
UserID string `json:"user_id"`
Content string `json:"content"`
Type string `json:"type"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
-177
View File
@@ -1,177 +0,0 @@
package text
import (
"context"
"fmt"
"regexp"
"strings"
"unicode"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type TextPlugin struct{ sdk.BasePlugin }
func (p *TextPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "text", DisplayName: "Text Processing", Version: "1.0.0",
Description: "Text processing: count stats, summarize, regex extract",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *TextPlugin) Tools() []sdk.Tool { return []sdk.Tool{&TextTool{}} }
type TextTool struct{ sdk.BaseTool }
func (t *TextTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "text", Name: "text", DisplayName: "Text Processing",
Description: "Text processing. Count stats, summarize, translate, regex extract.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"count", "summarize", "translate", "extract"}},
"text": map[string]interface{}{"type": "string"},
"target_lang": map[string]interface{}{"type": "string", "enum": []string{"en", "zh", "ja", "ko", "fr", "de"}},
"pattern": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "text"},
},
}
}
func (t *TextTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "text"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *TextTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
txt, _ := args["text"].(string)
switch action {
case "count":
charsNoSpace := 0
chineseChars := 0
for _, r := range txt {
if !unicode.IsSpace(r) {
charsNoSpace++
}
if unicode.Is(unicode.Han, r) {
chineseChars++
}
}
words := strings.Fields(txt)
lines := strings.Split(txt, "\n")
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1)
return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf(
"Characters: %d (no spaces: %d, Chinese: %d)\nBytes: %d\nWords: %d\nLines: %d\nParagraphs: %d",
len([]rune(txt)), charsNoSpace, chineseChars, len(txt), len(words), len(lines), len(paragraphs))}, nil
case "summarize":
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1)
firstPara := ""
if len(paragraphs) > 0 {
runes := []rune(paragraphs[0])
if len(runes) > 300 {
runes = runes[:300]
}
firstPara = string(runes)
}
sentences := regexp.MustCompile(`[。!?.!?]+`).Split(txt, -1)
keywords := []string{"重要", "关键", "因此", "总结", "important", "key", "conclusion", "therefore"}
type scored struct {
text string
score int
}
var scoredSents []scored
for _, s := range sentences {
s = strings.TrimSpace(s)
if len([]rune(s)) < 10 {
continue
}
score := len([]rune(s))
for _, kw := range keywords {
if strings.Contains(strings.ToLower(s), strings.ToLower(kw)) {
score += 20
}
}
scoredSents = append(scoredSents, scored{s, score})
}
var out strings.Builder
out.WriteString(fmt.Sprintf("First paragraph: %s\n\nKey sentences:\n", firstPara))
count := 0
for i := 0; i < len(scoredSents) && count < 5; i++ {
out.WriteString(fmt.Sprintf("- %s\n", scoredSents[i].text))
count++
}
return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil
case "translate":
targetLang, _ := args["target_lang"].(string)
if targetLang == "" {
targetLang = "en"
}
return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf(
"[Translation request] Please translate the following text to %s.\n\nOriginal text:\n%s", targetLang, txt)}, nil
case "extract":
pattern, _ := args["pattern"].(string)
var out strings.Builder
extracted := false
if pattern == "" || pattern == "email" {
re := regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString("Emails:\n")
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if pattern == "" || pattern == "phone" {
re := regexp.MustCompile(`1[3-9]\d{9}`)
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString("Phone numbers:\n")
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if pattern == "" || pattern == "url" {
re := regexp.MustCompile(`https?://[^\s<>"{}|\\^` + "`" + `\[\]]+`)
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString("URLs:\n")
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if !extracted && pattern != "" && pattern != "email" && pattern != "phone" && pattern != "url" {
re, err := regexp.Compile(pattern)
if err != nil {
return &sdk.ToolResult{ToolName: "text", Success: false, Error: "Invalid regex: " + err.Error()}, nil
}
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString(fmt.Sprintf("Pattern matches (%s):\n", pattern))
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if !extracted {
return &sdk.ToolResult{ToolName: "text", Success: true, Output: "No matches found"}, nil
}
return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil
}
return &sdk.ToolResult{ToolName: "text", Success: false, Error: "unknown action: " + action}, nil
}
-113
View File
@@ -1,113 +0,0 @@
package webfetch
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type WebFetchPlugin struct {
sdk.BasePlugin
client *http.Client
}
func NewWebFetchPlugin() *WebFetchPlugin {
return &WebFetchPlugin{client: &http.Client{Timeout: 15 * time.Second}}
}
func (p *WebFetchPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "web_fetch", DisplayName: "Web Fetch", Version: "1.0.0",
Description: "Fetch and extract text content from URLs",
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *WebFetchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebFetchTool{client: p.client}} }
type WebFetchTool struct {
sdk.BaseTool
client *http.Client
}
func (t *WebFetchTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "web_fetch", Name: "web_fetch", DisplayName: "Web Fetch",
Description: "Fetch content of a specified URL. Returns plain text summary (first 2000 characters). HTTP/HTTPS only.",
Category: "network", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"url": map[string]interface{}{"type": "string"}},
"required": []string{"url"},
},
}
}
func (t *WebFetchTool) Validate(args map[string]interface{}) error {
if _, ok := args["url"]; !ok {
return fmt.Errorf("missing required parameter: url")
}
return nil
}
func (t *WebFetchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
urlStr, _ := args["url"].(string)
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: "only http/https URLs allowed"}, nil
}
req, _ := http.NewRequest("GET", urlStr, nil)
req.Header.Set("User-Agent", "CyreneBot/1.0")
resp, err := t.client.Do(req)
if err != nil {
return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: err.Error()}, nil
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
text := stripHTMLFull(string(bodyBytes))
text = removeBlankLines(text)
runes := []rune(text)
if len(runes) > 2000 {
text = string(runes[:2000]) + "..."
}
return &sdk.ToolResult{ToolName: "web_fetch", Success: true, Output: fmt.Sprintf(
"URL: %s\nStatus: %d\nContent-Type: %s\n\n%s",
urlStr, resp.StatusCode, resp.Header.Get("Content-Type"), text)}, nil
}
func stripHTMLFull(s string) string {
result := make([]rune, 0, len([]rune(s)))
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result = append(result, r)
}
}
return string(result)
}
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
var result []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
result = append(result, trimmed)
}
}
return strings.Join(result, "\n")
}
-239
View File
@@ -1,239 +0,0 @@
package websearch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type WebSearchPlugin struct {
sdk.BasePlugin
client *http.Client
searxngURL string
}
func NewWebSearchPlugin() *WebSearchPlugin {
return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}}
}
func NewWebSearchPluginWithURL(searxngURL string) *WebSearchPlugin {
return &WebSearchPlugin{
client: &http.Client{Timeout: 10 * time.Second},
searxngURL: strings.TrimRight(searxngURL, "/"),
}
}
func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "web_search", DisplayName: "Web Search", Version: "1.1.0",
Description: "Search the internet via SearXNG (or DuckDuckGo fallback)",
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *WebSearchPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}}
}
type WebSearchTool struct {
sdk.BaseTool
client *http.Client
searxngURL string
}
// ---- SearXNG response types ----
type searxngResponse struct {
Query string `json:"query"`
NumberOrResults int `json:"number_of_results"`
Results []searxngResult `json:"results"`
Answers []string `json:"answers"`
Suggestions []string `json:"suggestions"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
Score float64 `json:"score"`
}
// ---- DuckDuckGo response types (fallback) ----
type ddgResponse struct {
Abstract string `json:"Abstract"`
AbstractText string `json:"AbstractText"`
Answer string `json:"Answer"`
Heading string `json:"Heading"`
Results []ddgTopic `json:"Results"`
RelatedTopics []ddgTopic `json:"RelatedTopics"`
}
type ddgTopic struct {
FirstURL string `json:"FirstURL"`
Text string `json:"Text"`
}
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "web_search", Name: "web_search", DisplayName: "Web Search",
Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.",
Category: "network", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"query": map[string]interface{}{"type": "string"}},
"required": []string{"query"},
},
}
}
func (t *WebSearchTool) Validate(args map[string]interface{}) error {
if _, ok := args["query"]; !ok {
return fmt.Errorf("missing required parameter: query")
}
return nil
}
func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
query, _ := args["query"].(string)
if query == "" {
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: "empty query"}, nil
}
if t.searxngURL != "" {
return t.searchViaSearXNG(query)
}
return t.searchViaDuckDuckGo(query)
}
// China-accessible SearXNG engines (baidu, sogou, 360search, bing all work from China)
const searxngEngines = "bing,sogou,360search,baidu"
func (t *WebSearchTool) searchViaSearXNG(query string) (*sdk.ToolResult, error) {
apiURL := fmt.Sprintf("%s/search?format=json&engines=%s&q=%s",
t.searxngURL, searxngEngines, url.QueryEscape(query))
resp, err := t.client.Get(apiURL)
if err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false,
Error: fmt.Sprintf("SearXNG request failed: %v", err)}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &sdk.ToolResult{ToolName: "web_search", Success: false,
Error: fmt.Sprintf("SearXNG returned HTTP %d", resp.StatusCode)}, nil
}
var result searxngResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false,
Error: fmt.Sprintf("SearXNG parse error: %v", err)}, nil
}
var out strings.Builder
out.WriteString(fmt.Sprintf("搜索: %s (共%d条结果)\n\n", query, result.NumberOrResults))
// 优先显示答案(如 Wikipedia infobox
for _, answer := range result.Answers {
out.WriteString(fmt.Sprintf("📌 %s\n\n", answer))
}
// 搜索结果(最多5条,按score排序)
count := 0
for _, r := range result.Results {
if count >= 5 {
break
}
if r.Title == "" || r.URL == "" {
continue
}
content := cleanSnippet(r.Content)
out.WriteString(fmt.Sprintf("%d. **%s**\n %s\n %s\n\n", count+1, r.Title, r.URL, content))
count++
}
if out.Len() == 0 {
return &sdk.ToolResult{ToolName: "web_search", Success: true,
Output: fmt.Sprintf("未找到与「%s」相关的结果。", query)}, nil
}
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
}
func (t *WebSearchTool) searchViaDuckDuckGo(query string) (*sdk.ToolResult, error) {
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query))
resp, err := t.client.Get(apiURL)
if err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
}
defer resp.Body.Close()
var result ddgResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
}
var out strings.Builder
if result.Answer != "" {
out.WriteString(fmt.Sprintf("Answer: %s\n\n", result.Answer))
}
if result.AbstractText != "" {
text := result.AbstractText
if len([]rune(text)) > 500 {
text = string([]rune(text)[:500]) + "..."
}
out.WriteString(fmt.Sprintf("Abstract: %s\n\n", stripHTML(text)))
}
topics := result.Results
if len(topics) == 0 {
topics = result.RelatedTopics
}
count := 0
for _, topic := range topics {
if count >= 5 {
break
}
if topic.Text == "" {
continue
}
out.WriteString(fmt.Sprintf("%d. %s (%s)\n", count+1, stripHTML(topic.Text), topic.FirstURL))
count++
}
if out.Len() == 0 {
return &sdk.ToolResult{ToolName: "web_search", Success: true,
Output: "No results found for: " + query}, nil
}
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
}
func cleanSnippet(s string) string {
runes := []rune(strings.TrimSpace(s))
if len(runes) > 200 {
return string(runes[:200]) + "..."
}
return string(runes)
}
func stripHTML(s string) string {
result := make([]rune, 0, len([]rune(s)))
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result = append(result, r)
}
}
return strings.TrimSpace(string(result))
}
+35
View File
@@ -0,0 +1,35 @@
# ========== 构建阶段 ==========
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY backend/platform-bridge/ ./backend/platform-bridge/
WORKDIR /app/backend/platform-bridge
ENV GOPROXY=https://goproxy.cn,direct
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /platform-bridge ./cmd/main.go
# ========== 运行阶段 ==========
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
COPY --from=builder /platform-bridge .
RUN mkdir -p logs && adduser -D -H cyrene && chown -R cyrene:cyrene /app
USER cyrene
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8095/health || exit 1
ENTRYPOINT ["./platform-bridge"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
package qq
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
// ListChannels implements bridge.ChannelLister — fetches groups + friends from NapCat.
func (a *Adapter) ListChannels() []bridge.ChannelInfo {
var all []bridge.ChannelInfo
all = append(all, a.FetchGroups()...)
all = append(all, a.FetchFriends()...)
return all
}
// FetchGroups fetches the group list from NapCat HTTP API and caches group names.
// Returns a list of channel info for all groups the bot is in.
func (a *Adapter) FetchGroups() []bridge.ChannelInfo {
if a.mode != "client" || a.remoteURL == "" {
return nil
}
httpBase := a.httpBase()
if httpBase == "" {
return nil
}
url := fmt.Sprintf("%s/get_group_list", httpBase)
if a.accessToken != "" {
url += "?access_token=" + a.accessToken
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
fmt.Printf("[qq:%s] get_group_list request failed: %v\n", a.configName, err)
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("[qq:%s] get_group_list failed: %v\n", a.configName, err)
return nil
}
defer resp.Body.Close()
var result struct {
Data []struct {
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Printf("[qq:%s] get_group_list parse failed: %v\n", a.configName, err)
return nil
}
var channels []bridge.ChannelInfo
for _, g := range result.Data {
if g.GroupName != "" {
a.SetGroupName(g.GroupID, g.GroupName)
}
channels = append(channels, bridge.ChannelInfo{
Platform: "obv11",
ChannelType: "group",
ChannelID: fmt.Sprintf("%d", g.GroupID),
ChannelName: g.GroupName,
})
}
fmt.Printf("[qq:%s] 获取到 %d 个群聊\n", a.configName, len(channels))
return channels
}
// FetchFriends fetches the friend list from NapCat HTTP API.
// Returns a list of channel info for private chats.
func (a *Adapter) FetchFriends() []bridge.ChannelInfo {
if a.mode != "client" || a.remoteURL == "" {
return nil
}
httpBase := a.httpBase()
if httpBase == "" {
return nil
}
url := fmt.Sprintf("%s/get_friend_list", httpBase)
if a.accessToken != "" {
url += "?access_token=" + a.accessToken
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
fmt.Printf("[qq:%s] get_friend_list request failed: %v\n", a.configName, err)
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("[qq:%s] get_friend_list failed: %v\n", a.configName, err)
return nil
}
defer resp.Body.Close()
var result struct {
Data []struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Remark string `json:"remark"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Printf("[qq:%s] get_friend_list parse failed: %v\n", a.configName, err)
return nil
}
var channels []bridge.ChannelInfo
for _, f := range result.Data {
name := f.Remark
if name == "" {
name = f.Nickname
}
channels = append(channels, bridge.ChannelInfo{
Platform: "obv11",
ChannelType: "private",
ChannelID: fmt.Sprintf("%d", f.UserID),
ChannelName: name,
})
}
fmt.Printf("[qq:%s] 获取到 %d 个好友\n", a.configName, len(channels))
return channels
}
@@ -26,7 +26,17 @@ type OBv11Message struct {
MessageSeq int64 `json:"message_seq"` MessageSeq int64 `json:"message_seq"`
// Notice fields. // Notice fields.
NoticeType string `json:"notice_type"` NoticeType string `json:"notice_type"`
// Poke detail (sub_type === "poke").
PokeDetail *OBv11PokeDetail `json:"poke_detail,omitempty"`
}
// OBv11PokeDetail contains extra info for poke ("戳一戳") notice events.
type OBv11PokeDetail struct {
Action string `json:"action"` // e.g. "戳了戳"
Suffix string `json:"suffix"` // e.g. "你的脸蛋"
PokePic string `json:"poke_pic"` // e.g. "https://..."
} }
// OBv11Sender represents a message sender. // OBv11Sender represents a message sender.
@@ -71,5 +81,5 @@ type OBv11Params struct {
UserID int64 `json:"user_id,omitempty"` UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"` GroupID int64 `json:"group_id,omitempty"`
Message interface{} `json:"message"` Message interface{} `json:"message"`
AutoEscape bool `json:"auto_escape,omitempty"` AutoEscape bool `json:"auto_escape"`
} }
@@ -20,5 +20,27 @@ type PlatformAdapter interface {
HealthCheck() error HealthCheck() error
} }
// ProactiveSender is an optional interface for adapters that can
// proactively send messages (e.g., OBv11 bot sending without prior request).
type ProactiveSender interface {
SendProactive(chatType string, userID, groupID int64, content string) error
}
// ChannelInfo describes a known chat channel (group or private).
type ChannelInfo struct {
Platform string `json:"platform"`
ChannelType string `json:"channel_type"` // "group", "private"
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name,omitempty"`
AdapterID string `json:"adapter_id,omitempty"` // config ID of the adapter instance
AdapterName string `json:"adapter_name,omitempty"` // config name, e.g. "qq-main"
}
// ChannelLister is an optional interface for adapters that can enumerate
// their channels (groups and friends) from the platform API.
type ChannelLister interface {
ListChannels() []ChannelInfo
}
// MessageHandler receives unified messages from adapters for processing. // MessageHandler receives unified messages from adapters for processing.
type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error) type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error)
@@ -44,6 +44,23 @@ func (m *IdentityMapper) Resolve(platform, platformUID string) (*permissions.Pla
return id, nil return id, nil
} }
// ResolveOrNil finds the Cyrene user for a platform identity, returning nil for unknown users.
func (m *IdentityMapper) ResolveOrNil(platform, platformUID string) *permissions.PlatformIdentity {
m.mu.RLock()
defer m.mu.RUnlock()
plat, ok := m.byPlatform[platform]
if !ok {
return nil
}
return plat[platformUID]
}
// IsAdmin returns true if the given platform user is a registered admin.
func (m *IdentityMapper) IsAdmin(platform, platformUID string) bool {
id := m.ResolveOrNil(platform, platformUID)
return id != nil && id.PermissionLevel == "admin"
}
// List returns all identities for a platform. // List returns all identities for a platform.
func (m *IdentityMapper) List(platform string) []permissions.PlatformIdentity { func (m *IdentityMapper) List(platform string) []permissions.PlatformIdentity {
m.mu.RLock() m.mu.RLock()
+142 -21
View File
@@ -1,12 +1,26 @@
package bridge package bridge
import ( import (
"context"
"fmt" "fmt"
"sync" "sync"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions"
) )
const participantTTL = 5 * time.Minute
// adapterKey returns the unique key for an adapter in the router map.
// Uses ConfigName() if the adapter implements it, otherwise PlatformName().
func adapterKey(a PlatformAdapter) string {
if named, ok := a.(interface{ ConfigName() string }); ok {
return named.ConfigName()
}
return a.PlatformName()
}
// PlatformRouter manages all platform adapters and routes messages. // PlatformRouter manages all platform adapters and routes messages.
type PlatformRouter struct { type PlatformRouter struct {
mu sync.RWMutex mu sync.RWMutex
@@ -21,11 +35,14 @@ type PlatformRouter struct {
// ChannelContext stores the active conversation state for a channel. // ChannelContext stores the active conversation state for a channel.
type ChannelContext struct { type ChannelContext struct {
Platform string Platform string
ChannelID string ChannelID string
ChannelType string ChannelType string
LastUserMsg string LastUserMsg string
MessageCount int LastSenderUID string
RecentSenders []string // last 5 sender UIDs (original platform UIDs)
ActiveParticipants map[string]time.Time // UID -> last bot reply time (for multi-user conversation continuity)
MessageCount int
} }
func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *PlatformRouter { func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *PlatformRouter {
@@ -37,11 +54,37 @@ func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *Pl
} }
} }
// RegisterAdapter adds a platform adapter. // RegisterAdapter adds a platform adapter, keyed by its config name.
func (r *PlatformRouter) RegisterAdapter(a PlatformAdapter) { func (r *PlatformRouter) RegisterAdapter(a PlatformAdapter) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.adapters[a.PlatformName()] = a r.adapters[adapterKey(a)] = a
}
// RemoveAdapter disconnects and removes a platform adapter.
func (r *PlatformRouter) RemoveAdapter(platform string) {
r.mu.Lock()
a, ok := r.adapters[platform]
if ok {
delete(r.adapters, platform)
}
r.mu.Unlock()
if ok {
a.Disconnect(context.Background())
}
}
// ReplaceAdapter disconnects the old adapter (if present), registers the new one,
// and connects it. Returns an error if the new adapter fails to connect.
func (r *PlatformRouter) ReplaceAdapter(a PlatformAdapter) error {
key := adapterKey(a)
r.mu.Lock()
if old, ok := r.adapters[key]; ok {
old.Disconnect(context.Background())
}
r.adapters[key] = a
r.mu.Unlock()
return a.Connect(context.Background())
} }
// GetAdapter returns the adapter for a platform. // GetAdapter returns the adapter for a platform.
@@ -55,7 +98,7 @@ func (r *PlatformRouter) GetAdapter(platform string) (PlatformAdapter, error) {
return a, nil return a, nil
} }
// ListAdapters returns all registered adapter names. // ListAdapters returns all registered adapter names (config names).
func (r *PlatformRouter) ListAdapters() []string { func (r *PlatformRouter) ListAdapters() []string {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
@@ -66,14 +109,28 @@ func (r *PlatformRouter) ListAdapters() []string {
return names return names
} }
// GetAdaptersByPlatform returns all registered adapters for a given platform type.
func (r *PlatformRouter) GetAdaptersByPlatform(platform string) []PlatformAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
var result []PlatformAdapter
for _, a := range r.adapters {
if a.PlatformName() == platform {
result = append(result, a)
}
}
return result
}
// SetMessageHandler sets the callback for processing unified messages. // SetMessageHandler sets the callback for processing unified messages.
func (r *PlatformRouter) SetMessageHandler(h MessageHandler) { func (r *PlatformRouter) SetMessageHandler(h MessageHandler) {
r.handler = h r.handler = h
} }
// RouteMessage converts a platform message to unified, checks permissions, and dispatches. // RouteMessage converts a platform message to unified, checks permissions, and dispatches.
func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*UnifiedResponse, error) { // adapterKey is the config name (e.g., "obv11", "obv11-home") used to look up the adapter instance.
a, err := r.GetAdapter(platform) func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*UnifiedResponse, error) {
a, err := r.GetAdapter(adapterKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -83,18 +140,28 @@ func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*Uni
return nil, fmt.Errorf("convert to unified: %w", err) return nil, fmt.Errorf("convert to unified: %w", err)
} }
// Resolve identity. // Preserve original platform UID before identity mapping.
identity, err := r.mapper.Resolve(platform, unified.SenderID) unified.OriginalSenderUID = unified.SenderID
if err != nil { unified.OriginalSenderName = unified.SenderName
return nil, fmt.Errorf("identity not found: %w", err) unified.OriginalRawMessage = rawMsg
// Capture bot's own UID for @mention detection.
if selfAware, ok := a.(interface{ SelfID() string }); ok {
unified.BotUID = selfAware.SelfID()
} }
// Merge identity info into the unified message. // Capture adapter config name for multi-account routing.
unified.SenderID = identity.CyreneUser if named, ok := a.(interface{ ConfigName() string }); ok {
unified.SenderName = identity.Nickname unified.AdapterName = named.ConfigName()
}
// Apply permission-based filtering. // Resolve identity (nil for unknown users; caller decides routing).
_ = identity // used by permission checks on tools // Use platform type (e.g. "obv11") for identity resolution, not adapter key.
identity := r.mapper.ResolveOrNil(a.PlatformName(), unified.SenderID)
if identity != nil {
unified.SenderID = identity.CyreneUser
unified.SenderName = identity.Nickname
}
// Update channel context. // Update channel context.
r.updateContext(unified) r.updateContext(unified)
@@ -108,8 +175,9 @@ func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*Uni
return nil, err return nil, err
} }
response.Platform = platform // Use adapter key for response routing so SendResponse finds the correct instance.
response.PlatformHints = r.platformHints(platform) response.Platform = adapterKey
response.PlatformHints = r.platformHints(adapterKey)
return response, nil return response, nil
} }
@@ -123,6 +191,20 @@ func (r *PlatformRouter) SendResponse(response *UnifiedResponse) ([]PlatformMess
return a.FromUnified(response) return a.FromUnified(response)
} }
// SendProactive sends a proactive message through a platform adapter that supports it.
// Returns an error if the adapter doesn't support proactive sending or the send fails.
func (r *PlatformRouter) SendProactive(adapterName, chatType string, userID, groupID int64, content string) error {
a, err := r.GetAdapter(adapterName)
if err != nil {
return err
}
sender, ok := a.(ProactiveSender)
if !ok {
return fmt.Errorf("adapter %s does not support proactive sending", adapterName)
}
return sender.SendProactive(chatType, userID, groupID, content)
}
func (r *PlatformRouter) platformHints(platform string) PlatformHints { func (r *PlatformRouter) platformHints(platform string) PlatformHints {
cap := PlatformCapabilities{} cap := PlatformCapabilities{}
if a, err := r.GetAdapter(platform); err == nil { if a, err := r.GetAdapter(platform); err == nil {
@@ -152,6 +234,11 @@ func (r *PlatformRouter) updateContext(msg *UnifiedMessage) {
r.contexts[key] = ctx r.contexts[key] = ctx
} }
ctx.LastUserMsg = msg.Content ctx.LastUserMsg = msg.Content
ctx.LastSenderUID = msg.OriginalSenderUID
ctx.RecentSenders = append(ctx.RecentSenders, msg.OriginalSenderUID)
if len(ctx.RecentSenders) > 5 {
ctx.RecentSenders = ctx.RecentSenders[len(ctx.RecentSenders)-5:]
}
ctx.MessageCount++ ctx.MessageCount++
} }
@@ -166,3 +253,37 @@ func (r *PlatformRouter) GetContext(platform, channelID string) *ChannelContext
defer r.mu.RUnlock() defer r.mu.RUnlock()
return r.contexts[platform+":"+channelID] return r.contexts[platform+":"+channelID]
} }
// NoteBotReply records that the bot just replied to a specific user in a channel.
// Used for conversation continuity: subsequent messages from this user continue the
// conversation even without an explicit @mention, within the participant TTL window.
func (r *PlatformRouter) NoteBotReply(platform, channelID, recipientUID string) {
r.mu.Lock()
defer r.mu.Unlock()
key := r.channelKey(platform, channelID)
ctx, ok := r.contexts[key]
if !ok {
return
}
if ctx.ActiveParticipants == nil {
ctx.ActiveParticipants = make(map[string]time.Time)
}
ctx.ActiveParticipants[recipientUID] = time.Now()
}
// IsActiveParticipant checks if a user was recently engaged by the bot.
// TTL controls how long the continuity window stays open after the last bot reply.
func (r *PlatformRouter) IsActiveParticipant(platform, channelID, uid string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
key := r.channelKey(platform, channelID)
ctx, ok := r.contexts[key]
if !ok || ctx.ActiveParticipants == nil {
return false
}
t, ok := ctx.ActiveParticipants[uid]
if !ok {
return false
}
return time.Since(t) < participantTTL
}
@@ -21,15 +21,25 @@ type UnifiedMessage struct {
RawData interface{} `json:"raw_data,omitempty"` RawData interface{} `json:"raw_data,omitempty"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
// Routing metadata.
RouteType string `json:"route_type,omitempty"` // "normal", "silent", "admin_mention"
OriginalSenderUID string `json:"original_sender_uid,omitempty"` // preserved before identity mapping
OriginalSenderName string `json:"original_sender_name,omitempty"` // preserved before identity mapping
GroupName string `json:"group_name,omitempty"` // resolved group name for group chats
OriginalRawMessage interface{} `json:"-"` // preserved for SendMessage wiring
BotUID string `json:"-"` // bot's own platform UID, set by router
AdapterName string `json:"-"` // adapter config name (e.g. "qq-main"), set by router
} }
// Attachment represents a file/image/voice attachment. // Attachment represents a file/image/voice/video attachment.
type Attachment struct { type Attachment struct {
Type string `json:"type"` // "image", "voice", "file", "video" Type string `json:"type"` // "image", "voice", "file", "video"
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
FileName string `json:"file_name,omitempty"` FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"` MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
Duration int `json:"duration,omitempty"` // video/voice duration in seconds
} }
// UnifiedResponse is AI-Core's response converted to unified format. // UnifiedResponse is AI-Core's response converted to unified format.
@@ -0,0 +1,150 @@
package config
import (
"encoding/json"
"fmt"
"os"
"sync"
)
// BlocklistMode is either "blacklist" or "whitelist".
type BlocklistSettings struct {
Mode string `json:"mode"` // "blacklist" (default) or "whitelist"
GroupIDs []string `json:"group_ids"` // group IDs to block/allow
UserIDs []string `json:"user_ids"` // private chat user IDs to block/allow
}
// BlocklistStore manages persistence of blocklist settings.
type BlocklistStore struct {
mu sync.RWMutex
path string
settings BlocklistSettings
}
// NewBlocklistStore loads or creates blocklist settings file.
func NewBlocklistStore(path string) (*BlocklistStore, error) {
s := &BlocklistStore{
path: path,
settings: BlocklistSettings{
Mode: "blacklist",
GroupIDs: []string{},
UserIDs: []string{},
},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *BlocklistStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return s.save() // write defaults
}
return fmt.Errorf("read blocklist file: %w", err)
}
if len(data) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if err := json.Unmarshal(data, &s.settings); err != nil {
return fmt.Errorf("parse blocklist file: %w", err)
}
if s.settings.Mode == "" {
s.settings.Mode = "blacklist"
}
if s.settings.GroupIDs == nil {
s.settings.GroupIDs = []string{}
}
if s.settings.UserIDs == nil {
s.settings.UserIDs = []string{}
}
return nil
}
func (s *BlocklistStore) save() error {
s.mu.RLock()
data, err := json.MarshalIndent(s.settings, "", " ")
s.mu.RUnlock()
if err != nil {
return fmt.Errorf("marshal blocklist: %w", err)
}
tmpPath := s.path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0640); err != nil {
return fmt.Errorf("write blocklist: %w", err)
}
return os.Rename(tmpPath, s.path)
}
// Get returns current blocklist settings.
func (s *BlocklistStore) Get() BlocklistSettings {
s.mu.RLock()
defer s.mu.RUnlock()
cp := BlocklistSettings{
Mode: s.settings.Mode,
GroupIDs: make([]string, len(s.settings.GroupIDs)),
UserIDs: make([]string, len(s.settings.UserIDs)),
}
copy(cp.GroupIDs, s.settings.GroupIDs)
copy(cp.UserIDs, s.settings.UserIDs)
return cp
}
// Set updates and persists blocklist settings.
func (s *BlocklistStore) Set(bs BlocklistSettings) error {
if bs.Mode != "blacklist" && bs.Mode != "whitelist" {
return fmt.Errorf("invalid mode: %s (must be blacklist or whitelist)", bs.Mode)
}
if bs.GroupIDs == nil {
bs.GroupIDs = []string{}
}
if bs.UserIDs == nil {
bs.UserIDs = []string{}
}
s.mu.Lock()
s.settings = bs
s.mu.Unlock()
return s.save()
}
// IsBlocked checks whether a message should be blocked based on channel type and ID.
// In blacklist mode: returns true if the id is IN the list.
// In whitelist mode: returns true if the id is NOT in the list.
// Admin users should call this with isAdmin=true to always bypass.
func (s *BlocklistStore) IsBlocked(channelType, channelID, senderID string, isAdmin bool) bool {
if isAdmin {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
switch s.settings.Mode {
case "whitelist":
// Block if NOT in the whitelist.
if channelType == "group" {
return !contains(s.settings.GroupIDs, channelID)
}
return !contains(s.settings.UserIDs, senderID)
case "blacklist":
fallthrough
default:
// Block if IN the blacklist.
if channelType == "group" {
return contains(s.settings.GroupIDs, channelID)
}
return contains(s.settings.UserIDs, senderID)
}
}
func contains(list []string, val string) bool {
for _, v := range list {
if v == val {
return true
}
}
return false
}
@@ -1,6 +1,9 @@
package config package config
import "os" import (
"os"
"strings"
)
// Config holds Platform Bridge configuration. // Config holds Platform Bridge configuration.
type Config struct { type Config struct {
@@ -11,9 +14,18 @@ type Config struct {
InternalToken string InternalToken string
// Platform-specific. // Platform-specific.
QQBotPort string // port for QQ OBv11 reverse WebSocket OBv11BotPort string // port for OBv11 reverse WebSocket
TelegramToken string // Telegram Bot API token TelegramToken string // Telegram Bot API token
TelegramWebhookURL string // public webhook URL for Telegram TelegramWebhookURL string // public webhook URL for Telegram
// Silent observation mode.
PlatformSilentEnabled bool // PLATFORM_SILENT_ENABLED, default true
AdminNickname string // ADMIN_NICKNAME, admin's Cyrene identity nickname (default "开拓者")
AdminNicknames []string // ADMIN_NICKNAMES, default ["开拓者"]
AdminMentionKeywords []string // ADMIN_MENTION_KEYWORDS, default ["昔涟","Cyrene","管理员"]
// Message sending.
MessageSendIntervalMs int // MSG_SEND_INTERVAL_MS, minimum interval between platform messages (default 2000)
} }
func Load() *Config { func Load() *Config {
@@ -22,7 +34,7 @@ func Load() *Config {
Env: "development", Env: "development",
GatewayURL: "http://localhost:8080", GatewayURL: "http://localhost:8080",
AICoreURL: "http://localhost:8081", AICoreURL: "http://localhost:8081",
QQBotPort: "8096", OBv11BotPort: "8096",
} }
if v := os.Getenv("PORT"); v != "" { if v := os.Getenv("PORT"); v != "" {
cfg.Port = v cfg.Port = v
@@ -39,8 +51,8 @@ func Load() *Config {
if v := os.Getenv("INTERNAL_SERVICE_TOKEN"); v != "" { if v := os.Getenv("INTERNAL_SERVICE_TOKEN"); v != "" {
cfg.InternalToken = v cfg.InternalToken = v
} }
if v := os.Getenv("QQ_BOT_PORT"); v != "" { if v := os.Getenv("OBV11_BOT_PORT"); v != "" {
cfg.QQBotPort = v cfg.OBv11BotPort = v
} }
if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" { if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" {
cfg.TelegramToken = v cfg.TelegramToken = v
@@ -48,5 +60,58 @@ func Load() *Config {
if v := os.Getenv("TELEGRAM_WEBHOOK_URL"); v != "" { if v := os.Getenv("TELEGRAM_WEBHOOK_URL"); v != "" {
cfg.TelegramWebhookURL = v cfg.TelegramWebhookURL = v
} }
// Silent observation defaults.
cfg.PlatformSilentEnabled = getEnvBool("PLATFORM_SILENT_ENABLED", true)
cfg.AdminNickname = os.Getenv("ADMIN_NICKNAME")
if cfg.AdminNickname == "" {
cfg.AdminNickname = "开拓者"
}
cfg.AdminNicknames = getEnvList("ADMIN_NICKNAMES", []string{"开拓者"})
cfg.AdminMentionKeywords = getEnvList("ADMIN_MENTION_KEYWORDS", []string{"昔涟", "Cyrene", "管理员"})
cfg.MessageSendIntervalMs = getEnvInt("MSG_SEND_INTERVAL_MS", 2000)
return cfg return cfg
} }
func getEnvBool(key string, defaultVal bool) bool {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
return v == "true" || v == "1" || v == "yes"
}
func getEnvInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
n := 0
for _, c := range v {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
return defaultVal
}
}
return n
}
func getEnvList(key string, defaultVal []string) []string {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return defaultVal
}
return result
}
@@ -1,7 +1,10 @@
package config package config
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"strings"
"fmt" "fmt"
"os" "os"
"sync" "sync"
@@ -10,10 +13,13 @@ import (
// PlatformConfig holds persistent configuration for one platform adapter. // PlatformConfig holds persistent configuration for one platform adapter.
type PlatformConfig struct { type PlatformConfig struct {
Name string `json:"name"` ID string `json:"id"` // immutable unique ID, generated at creation
Name string `json:"name"` // config key, e.g. "qq-main", "qq-work"
Platform string `json:"platform"` // base platform type: "obv11", "telegram", etc.
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Label string `json:"label"` Label string `json:"label"`
Fields map[string]string `json:"fields"` Fields map[string]string `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -51,6 +57,32 @@ func (s *Store) load() error {
if err := json.Unmarshal(data, &s.configs); err != nil { if err := json.Unmarshal(data, &s.configs); err != nil {
return fmt.Errorf("parse config file: %w", err) return fmt.Errorf("parse config file: %w", err)
} }
// Backward compat: old configs without platform field default to Name.
// Also migrate: generate IDs, rename qq→obv11.
needsSave := false
for _, c := range s.configs {
if c.Platform == "" {
c.Platform = c.Name
needsSave = true
}
if c.ID == "" {
c.ID = generateConfigID()
c.CreatedAt = time.Now()
needsSave = true
}
if c.Platform == "qq" {
c.Platform = "obv11"
needsSave = true
}
if strings.Contains(c.Name, "qq") {
c.Name = strings.ReplaceAll(c.Name, "qq", "obv11")
c.Label = strings.ReplaceAll(c.Label, "QQ", "OBv11")
needsSave = true
}
}
if needsSave {
s.save()
}
return nil return nil
} }
@@ -95,11 +127,22 @@ func (s *Store) Set(cfg PlatformConfig) error {
if cfg.Fields == nil { if cfg.Fields == nil {
cfg.Fields = make(map[string]string) cfg.Fields = make(map[string]string)
} }
// Generate stable ID for new configs.
if cfg.ID == "" {
cfg.ID = generateConfigID()
cfg.CreatedAt = time.Now()
}
cfg.UpdatedAt = time.Now() cfg.UpdatedAt = time.Now()
s.configs[cfg.Name] = &cfg s.configs[cfg.Name] = &cfg
return s.save() return s.save()
} }
func generateConfigID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// Delete removes a platform config and persists. // Delete removes a platform config and persists.
func (s *Store) Delete(name string) error { func (s *Store) Delete(name string) error {
s.mu.Lock() s.mu.Lock()
@@ -0,0 +1,44 @@
package handler
import (
"encoding/json"
"net/http"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config"
)
// BlocklistHandler exposes CRUD for blocklist settings.
type BlocklistHandler struct {
store *config.BlocklistStore
}
func NewBlocklistHandler(store *config.BlocklistStore) *BlocklistHandler {
return &BlocklistHandler{store: store}
}
func (h *BlocklistHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/settings/blocklist", h.handleBlocklist)
}
func (h *BlocklistHandler) handleBlocklist(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
writeJSON(w, http.StatusOK, h.store.Get())
case "POST", "PUT":
var bs config.BlocklistSettings
if err := json.NewDecoder(r.Body).Decode(&bs); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if err := h.store.Set(bs); err != nil {
writeJSON(w, http.StatusBadRequest, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "saved",
"settings": h.store.Get(),
})
default:
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
}
}
@@ -2,18 +2,45 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log"
"net/http" "net/http"
"os"
"strconv"
"strings"
"sync"
"regexp"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
) )
// Regex patterns for markdown stripping.
var (
mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`)
mdItalicRe = regexp.MustCompile(`\*(.+?)\*`)
mdStrikethroughRe = regexp.MustCompile(`~~(.+?)~~`)
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`)
)
// BridgeHandler exposes the Platform Bridge REST API. // BridgeHandler exposes the Platform Bridge REST API.
type BridgeHandler struct { type BridgeHandler struct {
router *bridge.PlatformRouter router *bridge.PlatformRouter
internalToken string
channelsMu sync.RWMutex
cachedChannels []bridge.ChannelInfo
logFn func(platform, channelID, senderID, content string, success bool) // outgoing message logger
} }
func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler { func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler {
return &BridgeHandler{router: router} return &BridgeHandler{
router: router,
internalToken: os.Getenv("INTERNAL_SERVICE_TOKEN"),
}
}
// SetLogFunc sets the outgoing message logger callback.
func (h *BridgeHandler) SetLogFunc(fn func(platform, channelID, senderID, content string, success bool)) {
h.logFn = fn
} }
func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) { func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) {
@@ -21,6 +48,8 @@ func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/platforms", h.listPlatforms) mux.HandleFunc("/api/v1/platforms", h.listPlatforms)
mux.HandleFunc("/api/v1/platforms/", h.platformInfo) mux.HandleFunc("/api/v1/platforms/", h.platformInfo)
mux.HandleFunc("/api/v1/identities", h.listIdentities) mux.HandleFunc("/api/v1/identities", h.listIdentities)
mux.HandleFunc("/api/v1/internal/send-proactive", h.sendProactive)
mux.HandleFunc("/api/v1/channels", h.listChannels)
mux.HandleFunc("/api/v1/webhook/telegram", h.telegramWebhook) mux.HandleFunc("/api/v1/webhook/telegram", h.telegramWebhook)
mux.HandleFunc("/api/v1/webhook/", h.genericWebhook) mux.HandleFunc("/api/v1/webhook/", h.genericWebhook)
} }
@@ -138,6 +167,117 @@ func (h *BridgeHandler) genericWebhook(w http.ResponseWriter, r *http.Request) {
}) })
} }
// sendProactive handles internal proactive message delivery to platform adapters.
// POST /api/v1/internal/send-proactive
func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
// Validate internal token
token := r.Header.Get("X-Internal-Token")
if h.internalToken == "" || token != h.internalToken {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
var req struct {
Platform string `json:"platform"`
ChatType string `json:"chat_type"`
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
AtUserID string `json:"at_user_id"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Platform == "" || req.ChatType == "" || req.Content == "" {
writeJSON(w, http.StatusBadRequest, errResp("platform, chat_type, and content are required"))
return
}
// Map chat type to OBv11 message_type
msgType := req.ChatType
if msgType != "private" && msgType != "group" {
writeJSON(w, http.StatusBadRequest, errResp("chat_type must be private or group"))
return
}
userID := parseIntSafe(req.UserID)
groupID := parseIntSafe(req.GroupID)
// 过滤 <action>/<app> 标签,去除 markdown 标记
content := filterActions(req.Content)
content = convertMarkdownPlain(content)
// 按 \n\n 和 ♪ 拆分为多条消息
messages := splitProactiveContent(content)
// Prepend CQ @mention tag if at_user_id is specified
atPrefix := ""
if req.AtUserID != "" {
atPrefix = fmt.Sprintf("[CQ:at,qq=%s] ", req.AtUserID)
}
// Resolve adapter
adapterName := req.Platform
var sendErr error
for i, msg := range messages {
fullMsg := atPrefix + msg
if i > 0 {
atPrefix = "" // only first message gets @mention
}
sendErr = h.router.SendProactive(adapterName, msgType, userID, groupID, fullMsg)
if sendErr != nil {
// Fallback: try other adapters with same platform name
for _, name := range h.router.ListAdapters() {
if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() {
adapterName = name
sendErr = h.router.SendProactive(name, msgType, userID, groupID, fullMsg)
break
}
}
}
if sendErr != nil {
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, sendErr)
break
}
log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d msg=%d/%d",
adapterName, msgType, userID, groupID, req.AtUserID, len(fullMsg), i+1, len(messages))
if h.logFn != nil {
chID := req.GroupID
if msgType == "private" {
chID = req.UserID
}
h.logFn(adapterName, chID, "Cyrene", fullMsg, true)
}
}
if sendErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+sendErr.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "消息已发送",
})
}
func parseIntSafe(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}
func errResp(msg string) map[string]string { func errResp(msg string) map[string]string {
return map[string]string{"error": msg} return map[string]string{"error": msg}
} }
@@ -147,3 +287,80 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
// filterActions removes <action> and <app> tags and their content.
func filterActions(text string) string {
tags := [][2]string{
{"<action>", "</action>"},
{"<app>", "</app>"},
}
for _, t := range tags {
openTag, closeTag := t[0], t[1]
for {
start := strings.Index(text, openTag)
if start == -1 {
break
}
end := strings.Index(text[start:], closeTag)
if end == -1 {
text = text[:start] + text[start+len(openTag):]
continue
}
text = text[:start] + text[start+end+len(closeTag):]
}
}
// 过滤 LLM 直接输出工具名的情况(DeepSeek 有时不调用函数而是直接输出函数名)
text = strings.TrimSpace(text)
toolNames := []string{"vision_analyze", "video_analyze", "web_search", "web_fetch",
"iot_control", "iot_query", "host_exec", "os_exec", "knowledge_search"}
for _, name := range toolNames {
if text == name || strings.HasPrefix(text, name+"\n") || strings.HasPrefix(text, name+"") || strings.HasPrefix(text, name+":") {
return ""
}
}
return text
}
// convertMarkdownPlain strips basic markdown formatting.
func convertMarkdownPlain(md string) string {
md = mdBoldRe.ReplaceAllString(md, "$1")
md = mdItalicRe.ReplaceAllString(md, "$1")
md = mdStrikethroughRe.ReplaceAllString(md, "$1")
md = mdHeadingRe.ReplaceAllString(md, "")
return md
}
// splitProactiveContent splits long proactive content into multiple messages.
// Strategy same as splitContent in cmd/main.go: split by \n\n, then by ♪.
func splitProactiveContent(text string) []string {
rawParts := strings.Split(text, "\n\n")
var parts []string
for _, p := range rawParts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.Contains(p, "♪") {
for _, sub := range strings.Split(p, "♪") {
sub = strings.TrimSpace(sub)
if sub != "" {
parts = append(parts, sub)
}
}
} else {
parts = append(parts, p)
}
}
// Merge very short segments with neighbors (min 8 runes).
const minRunes = 8
var merged []string
for _, part := range parts {
if len([]rune(part)) < minRunes && len(merged) > 0 {
merged[len(merged)-1] = merged[len(merged)-1] + part
} else {
merged = append(merged, part)
}
}
return merged
}
@@ -0,0 +1,59 @@
package handler
import (
"log"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
// listChannels returns cached channels from all adapters (groups + friends).
// GET /api/v1/channels
func (h *BridgeHandler) listChannels(w http.ResponseWriter, r *http.Request) {
h.channelsMu.RLock()
channels := h.cachedChannels
h.channelsMu.RUnlock()
if channels == nil {
channels = []bridge.ChannelInfo{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"channels": channels,
"total": len(channels),
})
}
// StartChannelRefresh periodically queries all adapters for their channel lists.
func (h *BridgeHandler) StartChannelRefresh(interval time.Duration) {
if interval <= 0 {
interval = 10 * time.Minute
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("[channel-refresh] 频道缓存刷新已启动 (间隔=%v)", interval)
h.refreshChannels()
for range ticker.C {
h.refreshChannels()
}
}()
}
// refreshChannels queries all ChannelLister adapters and updates the cache.
func (h *BridgeHandler) refreshChannels() {
var all []bridge.ChannelInfo
for _, name := range h.router.ListAdapters() {
a, err := h.router.GetAdapter(name)
if err != nil {
continue
}
if lister, ok := a.(bridge.ChannelLister); ok {
channels := lister.ListChannels()
all = append(all, channels...)
}
}
h.channelsMu.Lock()
h.cachedChannels = all
h.channelsMu.Unlock()
log.Printf("[channel-refresh] 频道缓存已更新: %d 个频道", len(all))
}
@@ -8,21 +8,27 @@ import (
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config"
) )
var knownPlatforms = map[string]bool{ var validPlatformTypes = map[string]bool{
"qq": true, "telegram": true, "webhook": true, "obv11": true, "telegram": true, "webhook": true,
"wechat": true, "feishu": true, "discord": true, "wechat": true, "feishu": true, "discord": true,
} }
// ConfigHandler exposes CRUD endpoints for platform configs. // ConfigHandler exposes CRUD endpoints for platform configs.
type ConfigHandler struct { type ConfigHandler struct {
store *config.Store store *config.Store
router *bridge.PlatformRouter router *bridge.PlatformRouter
onChanged func(name, platform string, enabled bool, fields map[string]string)
} }
func NewConfigHandler(store *config.Store, router *bridge.PlatformRouter) *ConfigHandler { func NewConfigHandler(store *config.Store, router *bridge.PlatformRouter) *ConfigHandler {
return &ConfigHandler{store: store, router: router} return &ConfigHandler{store: store, router: router}
} }
// SetOnConfigChanged sets a callback invoked after config is saved or deleted.
func (h *ConfigHandler) SetOnConfigChanged(fn func(name, platform string, enabled bool, fields map[string]string)) {
h.onChanged = fn
}
func (h *ConfigHandler) RegisterRoutes(mux *http.ServeMux) { func (h *ConfigHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/configs", h.listConfigs) mux.HandleFunc("/api/v1/configs", h.listConfigs)
mux.HandleFunc("/api/v1/configs/", h.handleConfig) mux.HandleFunc("/api/v1/configs/", h.handleConfig)
@@ -33,6 +39,7 @@ func (h *ConfigHandler) listConfigs(w http.ResponseWriter, r *http.Request) {
type configSummary struct { type configSummary struct {
Name string `json:"name"` Name string `json:"name"`
Platform string `json:"platform"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Fields map[string]string `json:"fields"` Fields map[string]string `json:"fields"`
@@ -46,8 +53,13 @@ func (h *ConfigHandler) listConfigs(w http.ResponseWriter, r *http.Request) {
if a, err := h.router.GetAdapter(c.Name); err == nil { if a, err := h.router.GetAdapter(c.Name); err == nil {
connected = a.IsConnected() connected = a.IsConnected()
} }
platform := c.Platform
if platform == "" {
platform = c.Name
}
result = append(result, configSummary{ result = append(result, configSummary{
Name: c.Name, Name: c.Name,
Platform: platform,
Enabled: c.Enabled, Enabled: c.Enabled,
Label: c.Label, Label: c.Label,
Fields: c.Fields, Fields: c.Fields,
@@ -71,6 +83,7 @@ func (h *ConfigHandler) listConfigs(w http.ResponseWriter, r *http.Request) {
} }
result = append(result, configSummary{ result = append(result, configSummary{
Name: name, Name: name,
Platform: name,
Enabled: false, Enabled: false,
Fields: map[string]string{}, Fields: map[string]string{},
Connected: connected, Connected: connected,
@@ -92,10 +105,6 @@ func (h *ConfigHandler) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, errResp("missing config name")) writeJSON(w, http.StatusBadRequest, errResp("missing config name"))
return return
} }
if !knownPlatforms[name] {
writeJSON(w, http.StatusBadRequest, errResp("unknown platform: "+name))
return
}
switch r.Method { switch r.Method {
case "GET": case "GET":
@@ -120,26 +129,37 @@ func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request, name s
connected = a.IsConnected() connected = a.IsConnected()
} }
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"name": cfg.Name, "name": cfg.Name,
"enabled": cfg.Enabled, "platform": cfg.Platform,
"label": cfg.Label, "enabled": cfg.Enabled,
"fields": cfg.Fields, "label": cfg.Label,
"updated_at": cfg.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), "fields": cfg.Fields,
"connected": connected, "updated_at": cfg.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
"connected": connected,
}) })
} }
func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name string) { func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name string) {
var body struct { var body struct {
Enabled *bool `json:"enabled"` Platform *string `json:"platform"`
Label string `json:"label"` Enabled *bool `json:"enabled"`
Fields map[string]string `json:"fields"` Label string `json:"label"`
Fields map[string]string `json:"fields"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return return
} }
platform := name
if body.Platform != nil && *body.Platform != "" {
platform = *body.Platform
}
if !validPlatformTypes[platform] {
writeJSON(w, http.StatusBadRequest, errResp("unknown or missing platform type: "+platform))
return
}
enabled := true enabled := true
if body.Enabled != nil { if body.Enabled != nil {
enabled = *body.Enabled enabled = *body.Enabled
@@ -151,29 +171,48 @@ func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name
} }
cfg := config.PlatformConfig{ cfg := config.PlatformConfig{
Name: name, Name: name,
Enabled: enabled, Platform: platform,
Label: body.Label, Enabled: enabled,
Fields: fields, Label: body.Label,
Fields: fields,
} }
if err := h.store.Set(cfg); err != nil { if err := h.store.Set(cfg); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return return
} }
// Trigger hot-reload.
if h.onChanged != nil {
h.onChanged(name, platform, enabled, fields)
}
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"name": name, "name": name,
"enabled": enabled, "platform": platform,
"label": body.Label, "enabled": enabled,
"fields": fields, "label": body.Label,
"status": "saved", "fields": fields,
"status": "saved",
}) })
} }
func (h *ConfigHandler) deleteConfig(w http.ResponseWriter, r *http.Request, name string) { func (h *ConfigHandler) deleteConfig(w http.ResponseWriter, r *http.Request, name string) {
// Get platform type before deleting (needed for onChanged callback).
platform := name
if cfg, err := h.store.Get(name); err == nil && cfg.Platform != "" {
platform = cfg.Platform
}
if err := h.store.Delete(name); err != nil { if err := h.store.Delete(name); err != nil {
writeJSON(w, http.StatusNotFound, errResp(err.Error())) writeJSON(w, http.StatusNotFound, errResp(err.Error()))
return return
} }
// Trigger hot-reload: disable and clear fields.
if h.onChanged != nil {
h.onChanged(name, platform, false, nil)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
} }
@@ -4,16 +4,18 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/logging" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/logging"
) )
// LogHandler exposes message log retrieval endpoints. // LogHandler exposes message log retrieval endpoints.
type LogHandler struct { type LogHandler struct {
logger *logging.Logger logger *logging.Logger
store *config.Store
} }
func NewLogHandler(logger *logging.Logger) *LogHandler { func NewLogHandler(logger *logging.Logger, store *config.Store) *LogHandler {
return &LogHandler{logger: logger} return &LogHandler{logger: logger, store: store}
} }
func (h *LogHandler) RegisterRoutes(mux *http.ServeMux) { func (h *LogHandler) RegisterRoutes(mux *http.ServeMux) {
@@ -27,6 +29,10 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
return return
} }
// Use the name directly as the log key. Each config has its own log file
// named by its unique identifier (e.g., "obv11-main.log").
platform := name
limit := 100 limit := 100
if l := r.URL.Query().Get("limit"); l != "" { if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 { if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 {
@@ -34,7 +40,7 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
} }
} }
entries, err := h.logger.ReadLogs(name, limit) entries, err := h.logger.ReadLogs(platform, limit)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return return
@@ -43,7 +49,7 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
entries = []logging.LogEntry{} entries = []logging.LogEntry{}
} }
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"platform": name, "platform": platform,
"total": len(entries), "total": len(entries),
"logs": entries, "logs": entries,
}) })
@@ -0,0 +1,86 @@
package handler
import (
"encoding/json"
"net/http"
"sync"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/logging"
)
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// LogWSHub broadcasts log entries to connected WebSocket clients.
type LogWSHub struct {
mu sync.Mutex
clients map[*websocket.Conn]chan logging.LogEntry
}
// NewLogWSHub creates a LogWSHub and subscribes to the logger.
func NewLogWSHub(logger *logging.Logger) *LogWSHub {
h := &LogWSHub{
clients: make(map[*websocket.Conn]chan logging.LogEntry),
}
logger.OnLog(func(entry logging.LogEntry) {
h.broadcast(entry)
})
return h
}
func (h *LogWSHub) broadcast(entry logging.LogEntry) {
h.mu.Lock()
defer h.mu.Unlock()
for _, ch := range h.clients {
select {
case ch <- entry:
default:
}
}
}
// ServeWS handles WebSocket upgrade and streams log entries to the client.
func (h *LogWSHub) ServeWS(w http.ResponseWriter, r *http.Request) {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
ch := make(chan logging.LogEntry, 64)
h.mu.Lock()
h.clients[conn] = ch
h.mu.Unlock()
// Write goroutine: drains ch until it is closed.
done := make(chan struct{})
go func() {
defer close(done)
for entry := range ch {
data, _ := json.Marshal(entry)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
}
}()
// Read goroutine: detect client disconnect.
// (websocket requires a reader to detect close frames.)
go func() {
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
// Client disconnected — stop broadcasting, close channel.
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
close(ch)
}()
<-done
conn.Close()
}
@@ -18,6 +18,7 @@ type LogEntry struct {
ChannelID string `json:"channel_id"` ChannelID string `json:"channel_id"`
SenderID string `json:"sender_id"` SenderID string `json:"sender_id"`
SenderName string `json:"sender_name"` SenderName string `json:"sender_name"`
GroupName string `json:"group_name,omitempty"`
Content string `json:"content"` Content string `json:"content"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
MessageID string `json:"message_id,omitempty"` MessageID string `json:"message_id,omitempty"`
@@ -25,11 +26,23 @@ type LogEntry struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// LogListener receives log entries as they are written.
type LogListener func(LogEntry)
// Logger writes message logs to per-platform JSONL files. // Logger writes message logs to per-platform JSONL files.
type Logger struct { type Logger struct {
mu sync.Mutex mu sync.Mutex
dir string dir string
files map[string]*os.File files map[string]*os.File
listeners []LogListener
}
// OnLog registers a listener that is called for every log entry written.
// The listener is called synchronously; avoid heavy work in the callback.
func (l *Logger) OnLog(fn LogListener) {
l.mu.Lock()
defer l.mu.Unlock()
l.listeners = append(l.listeners, fn)
} }
// NewLogger creates a Logger, ensuring the log directory exists. // NewLogger creates a Logger, ensuring the log directory exists.
@@ -60,12 +73,23 @@ func (l *Logger) Log(entry LogEntry) error {
} }
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock()
if _, err := f.Write(append(data, '\n')); err != nil { if _, err := f.Write(append(data, '\n')); err != nil {
l.mu.Unlock()
return fmt.Errorf("write log: %w", err) return fmt.Errorf("write log: %w", err)
} }
return f.Sync() if err := f.Sync(); err != nil {
l.mu.Unlock()
return err
}
listeners := make([]LogListener, len(l.listeners))
copy(listeners, l.listeners)
l.mu.Unlock()
// Notify listeners outside the lock.
for _, fn := range listeners {
fn(entry)
}
return nil
} }
// ReadLogs reads the last N log entries for a platform, newest first. // ReadLogs reads the last N log entries for a platform, newest first.
-47
View File
@@ -1,47 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
)
type hostAPI struct {
registry *manager.ToolRegistry
}
func newHostAPI(registry *manager.ToolRegistry) *hostAPI {
return &hostAPI{registry: registry}
}
func (h *hostAPI) CallLLM(_ context.Context, _ []sdk.LLMMessage) (*sdk.LLMResponse, error) {
return nil, fmt.Errorf("LLM call not available in plugin host")
}
func (h *hostAPI) SearchMemory(_ context.Context, _, _ string, _ int) ([]sdk.MemoryEntry, error) {
return nil, fmt.Errorf("memory search not available in plugin host")
}
func (h *hostAPI) StoreMemory(_ context.Context, _ sdk.MemoryEntry) error {
return fmt.Errorf("memory store not available in plugin host")
}
func (h *hostAPI) Logger() sdk.Logger {
return log.Default()
}
func (h *hostAPI) GetConfig(key string) (string, error) {
return "", fmt.Errorf("config key not found: %s", key)
}
func (h *hostAPI) SetConfig(_, _ string) error { return nil }
func (h *hostAPI) PublishEvent(_ context.Context, _ map[string]interface{}) error { return nil }
func (h *hostAPI) HTTPClient() *http.Client {
return http.DefaultClient
}
-112
View File
@@ -1,112 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
iotquery "git.yeij.top/AskaEth/Cyrene/pkg/plugins/iot_query"
)
type iotClient struct {
baseURL string
httpClient *http.Client
}
func newIoTClient(baseURL string) *iotClient {
return &iotClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *iotClient) GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error) {
url := c.baseURL + "/api/v1/devices"
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Devices []sdk.IoTDeviceState `json:"devices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Devices, nil
}
func (c *iotClient) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
url := fmt.Sprintf("%s/api/v1/devices/%s", c.baseURL, deviceID)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var dev sdk.IoTDeviceState
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
return nil, err
}
return &dev, nil
}
// iotControllerAdapter adapts IoTClient to iotcontrol.IoTController.
type iotControllerAdapter struct {
query iotquery.IoTClient
client *http.Client
baseURL string
}
func newIoTControllerAdapter(query iotquery.IoTClient, baseURL string) *iotControllerAdapter {
return &iotControllerAdapter{
query: query,
client: &http.Client{Timeout: 5 * time.Second},
baseURL: baseURL,
}
}
func (a *iotControllerAdapter) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
return a.query.GetDevice(ctx, deviceID)
}
func (a *iotControllerAdapter) SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error {
url := fmt.Sprintf("%s/api/v1/devices/%s/property", a.baseURL, deviceID)
body, _ := json.Marshal(map[string]interface{}{"property": property, "value": value})
req, _ := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("set property failed: HTTP %d - %s", resp.StatusCode, string(msg))
}
return nil
}
func (a *iotControllerAdapter) ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
url := fmt.Sprintf("%s/api/v1/devices/%s/toggle", a.baseURL, deviceID)
req, _ := http.NewRequestWithContext(ctx, "POST", url, nil)
resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var dev sdk.IoTDeviceState
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
return nil, err
}
return &dev, nil
}
-100
View File
@@ -1,100 +0,0 @@
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/calculator"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/crypto"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/datetime"
fileplugin "git.yeij.top/AskaEth/Cyrene/pkg/plugins/file"
httpplugin "git.yeij.top/AskaEth/Cyrene/pkg/plugins/http"
iotcontrol "git.yeij.top/AskaEth/Cyrene/pkg/plugins/iot_control"
iotquery "git.yeij.top/AskaEth/Cyrene/pkg/plugins/iot_query"
jsonplugin "git.yeij.top/AskaEth/Cyrene/pkg/plugins/json"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/markdown"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/random"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/text"
webfetch "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_fetch"
websearch "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_search"
"git.yeij.top/AskaEth/Cyrene/plugin-manager/internal/config"
"git.yeij.top/AskaEth/Cyrene/plugin-manager/internal/handler"
)
func main() {
cfg := config.Load()
var iotAPI iotquery.IoTClient
if cfg.IoTSvcURL != "" {
iotAPI = newIoTClient(cfg.IoTSvcURL)
}
registry := manager.NewToolRegistry()
host := newHostAPI(registry)
mgr := manager.NewPluginManager(registry, host)
builtins := []sdk.Plugin{
&calculator.CalculatorPlugin{},
&datetime.DatetimePlugin{},
&text.TextPlugin{},
&crypto.CryptoPlugin{},
&random.RandomPlugin{},
&markdown.MarkdownPlugin{},
&jsonplugin.JSONPlugin{},
fileplugin.NewFilePlugin(cfg.DataDir),
httpplugin.NewHTTPPlugin(),
websearch.NewWebSearchPlugin(),
webfetch.NewWebFetchPlugin(),
iotquery.NewIoTQueryPlugin(iotAPI),
}
for _, p := range builtins {
if err := mgr.Install(p); err != nil {
println("WARN: install plugin failed:", err.Error())
}
}
if iotAPI != nil {
ctrlPlugin := iotcontrol.NewIoTControlPlugin(newIoTControllerAdapter(iotAPI, cfg.IoTSvcURL))
if err := mgr.Install(ctrlPlugin); err != nil {
println("WARN: install plugin failed:", err.Error())
}
}
ctx := context.Background()
errs := mgr.EnableAll(ctx)
for _, e := range errs {
println("WARN: enable plugin failed:", e.Error())
}
println("Plugin Manager: all built-in plugins enabled")
mux := http.NewServeMux()
ph := handler.NewPluginHandler(mgr)
ph.RegisterRoutes(mux)
println("Plugin Manager listening on port", cfg.Port)
srv := &http.Server{Addr: ":" + cfg.Port, Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
println("FATAL:", err.Error())
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
println("Shutting down Plugin Manager...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
mgr.Shutdown(shutdownCtx)
srv.Shutdown(shutdownCtx)
println("Plugin Manager stopped")
}
-3
View File
@@ -1,3 +0,0 @@
module git.yeij.top/AskaEth/Cyrene/plugin-manager
go 1.26.2
@@ -1,32 +0,0 @@
package config
import "os"
type Config struct {
Port string
Env string
DataDir string
IoTSvcURL string
}
func Load() *Config {
cfg := &Config{
Port: "8094",
Env: "development",
DataDir: "./data",
IoTSvcURL: "http://localhost:8093",
}
if v := os.Getenv("PORT"); v != "" {
cfg.Port = v
}
if v := os.Getenv("ENV"); v != "" {
cfg.Env = v
}
if v := os.Getenv("DATA_DIR"); v != "" {
cfg.DataDir = v
}
if v := os.Getenv("IOT_SERVICE_URL"); v != "" {
cfg.IoTSvcURL = v
}
return cfg
}

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