Compare commits
122 Commits
Alpha_v0.1.0
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 88755e131a | |||
| 44048b333a | |||
| 236dbfcc92 | |||
| d63322244c | |||
| d4c246fa0c | |||
| 76e648aac3 | |||
| 32160d9453 | |||
| 3b8b35069a | |||
| 6cbb3e3e80 | |||
| f8d3900eaa | |||
| 350cd105bf | |||
| f95f7d67e3 | |||
| 1d4560e676 | |||
| 1f52b424e1 | |||
| 2835d62d41 | |||
| 6490c341e5 | |||
| d196a22f42 | |||
| 24d7722ef9 | |||
| dc410c1cff | |||
| 537e4ed550 | |||
| 9224633986 | |||
| bebacf805b | |||
| ca43604eba | |||
| 56b105a1dc | |||
| 310a8733c8 | |||
| 1e652b5f32 | |||
| a47c8be77b | |||
| 2b3aa45547 | |||
| dfd6ccbd41 | |||
| 6553656c9a | |||
| 9b20f14ab9 | |||
| 5725075a31 | |||
| 29f24b3036 | |||
| fd44b15d81 | |||
| 0d6970a2d3 | |||
| 839522e1d4 | |||
| 2d79126d36 | |||
| 4424c09759 | |||
| 27e7bd0649 | |||
| 1f633608db | |||
| 65ff530ce4 | |||
| cfebf5480e | |||
| 2e94e7b0bf | |||
| ebd6f40425 | |||
| fab934fb12 | |||
| e83bd6febb | |||
| cdfe440044 | |||
| 3a5591833f | |||
| 7e0268a884 | |||
| c75ddc13ea | |||
| f7c41f3e23 | |||
| 049332312b | |||
| 27a497e397 | |||
| 680696e2c4 | |||
| 01feb16262 | |||
| 30d5b9b464 | |||
| a3bdeba63a | |||
| 2ec49f0779 | |||
| edc2e77d2f | |||
| fea67d05eb | |||
| d38b92568b | |||
| a2078b6662 | |||
| 1eb22f5d06 | |||
| 4733f9c02a | |||
| f7df659c85 | |||
| 109d427971 | |||
| f3b588d848 | |||
| e0900bdd1b | |||
| 71539b5520 | |||
| 87e8f97c1f | |||
| 222fc5768f | |||
| 4326f1fc02 | |||
| e433e94c89 | |||
| a47eeab808 | |||
| a8fa64325d | |||
| 3081ac38e0 | |||
| 0f180f17c3 | |||
| 1e2b2525f4 | |||
| 73f0bf7b3e | |||
| 8a3236b8bb | |||
| c96bd23f83 | |||
| 45fac267fe | |||
| 6bf59f7eee | |||
| c9bf839945 | |||
| caa9bfced5 | |||
| 9767abae89 | |||
| bbf06da00f | |||
| f9ee067674 | |||
| b8fd08b57c | |||
| f0e8ffa2bb | |||
| b3ce0b4f52 | |||
| 0cd75c4dbc | |||
| 39d62559ea | |||
| 42793f9bba | |||
| 992d9a3515 | |||
| c55201a03a | |||
| 6faef5b6e5 | |||
| 489657ec08 | |||
| 2b17bb5b03 | |||
| 41f653b672 | |||
| 70dbb23234 | |||
| a46b5f6238 | |||
| 485d845f3e | |||
| 7e475bdfa6 | |||
| 8783cadf9b | |||
| cadd5f2233 | |||
| cd053194a9 | |||
| dbd89f39ab | |||
| 6ef9e082a6 | |||
| 258cf81b25 | |||
| 4954c1e58b | |||
| 67b204b23c | |||
| b085e58031 | |||
| a9c79d7887 | |||
| d112fdd540 | |||
| e5f8e42a78 | |||
| 7e29be8ae3 | |||
| eef21fc91a | |||
| 465fa4307f | |||
| 3ad728406e | |||
| 677385ec17 | |||
| 47dce276a4 |
+58
-45
@@ -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
|
||||||
|
|
||||||
# ========== 域名与 HTTPS(Docker 生产环境有域名时填写) ==========
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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 搜索引擎配置
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
- Node.js 20 LTS
|
- Node.js 20 LTS
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
- Git Bash(Windows 用户)
|
- Git Bash(Windows 用户)
|
||||||
|
- [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) | 插件开发指南 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+491
-43
@@ -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())
|
||||||
@@ -368,13 +529,25 @@ func main() {
|
|||||||
if ocrProvider != nil {
|
if ocrProvider != nil {
|
||||||
orch.SetOCRProvider(ocrProvider)
|
orch.SetOCRProvider(ocrProvider)
|
||||||
log.Printf("对话编排器: OCR模型已注入 (%s)", ocrProvider.ModelName())
|
log.Printf("对话编排器: OCR模型已注入 (%s)", ocrProvider.ModelName())
|
||||||
|
}
|
||||||
|
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 已就绪")
|
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,7 +1196,12 @@ 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 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
|
||||||
@@ -769,14 +1210,21 @@ func handleChat(
|
|||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
Images: req.Images,
|
Images: req.Images,
|
||||||
|
VideoURLs: req.VideoURLs,
|
||||||
|
VoiceURLs: req.VoiceURLs,
|
||||||
Mode: req.Mode,
|
Mode: req.Mode,
|
||||||
Nickname: userNickname,
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 模型不支持自定义角色)
|
||||||
@@ -135,6 +168,7 @@ func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int
|
|||||||
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++
|
||||||
}
|
}
|
||||||
@@ -164,6 +198,7 @@ type BuildParams struct {
|
|||||||
HistoryLimit int
|
HistoryLimit int
|
||||||
DeviceContext string // 注入的设备状态文本
|
DeviceContext string // 注入的设备状态文本
|
||||||
PendingThoughts []string // 待注入的后台思考
|
PendingThoughts []string // 待注入的后台思考
|
||||||
|
PlatformObservationSummary string // 平台观察摘要(中间会话生成)
|
||||||
Nickname string // 用户昵称 (昔涟对用户的称呼)
|
Nickname string // 用户昵称 (昔涟对用户的称呼)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ const (
|
|||||||
PurposeToolCalling ModelPurpose = "tool_calling"
|
PurposeToolCalling ModelPurpose = "tool_calling"
|
||||||
PurposeMemoryExtraction ModelPurpose = "memory_extraction"
|
PurposeMemoryExtraction ModelPurpose = "memory_extraction"
|
||||||
PurposeVision ModelPurpose = "vision"
|
PurposeVision ModelPurpose = "vision"
|
||||||
|
PurposeVideo ModelPurpose = "video"
|
||||||
PurposeOCR ModelPurpose = "ocr"
|
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.
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type SubSessionResult struct {
|
|||||||
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 工具调用记录
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type PendingToolResult struct {
|
|||||||
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 {
|
||||||
@@ -112,6 +142,9 @@ func NewOrchestrator(
|
|||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +154,15 @@ type ProcessParams struct {
|
|||||||
SessionID string
|
SessionID string
|
||||||
Message string
|
Message string
|
||||||
Images []string // 图片 base64 data URL (多模态)
|
Images []string // 图片 base64 data URL (多模态)
|
||||||
|
VideoURLs []string // 视频 URL (多模态), ≤20s short videos
|
||||||
|
VoiceURLs []string // 语音 URL (ASR 转录)
|
||||||
Mode string // text / voice_msg / voice_assistant
|
Mode string // text / voice_msg / voice_assistant
|
||||||
Nickname string
|
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{
|
||||||
|
|
||||||
if execErr != nil {
|
|
||||||
logger.Printf("[synthesizer] 工具 %s 执行失败: %v", tc.Name, execErr)
|
|
||||||
}
|
|
||||||
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,
|
ToolName: tc.Name,
|
||||||
Success: true,
|
Success: true,
|
||||||
Output: fmt.Sprintf("[后台执行中] %s 正在后台运行,结果将在下一轮对话中返回。你可以继续聊天。", tc.Name),
|
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程序"
|
||||||
- 不能说"我是一个数字生命"
|
- 不能说"我是一个数字生命"
|
||||||
|
|||||||
@@ -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,6 +127,7 @@ 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)
|
||||||
|
result.FinishedAt = time.Now()
|
||||||
resultCh <- result
|
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
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }) {
|
||||||
|
}
|
||||||
@@ -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 角色映射为 assistant,LLM 模型不支持自定义角色)
|
// 持久化每条审查消息 (action 角色映射为 assistant,LLM 模型不支持自定义角色)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.yeij.top/AskaEth/Cyrene/pkg/audio
|
||||||
|
|
||||||
|
go 1.21
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.yeij.top/AskaEth/Cyrene/pkg/dashscope
|
||||||
|
|
||||||
|
go 1.21
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module git.yeij.top/AskaEth/Cyrene/pkg/plugins
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, "&", "&")
|
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
|
||||||
s = strings.ReplaceAll(s, ">", ">")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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{})
|
|
||||||
}
|
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -27,6 +27,16 @@ type OBv11Message struct {
|
|||||||
|
|
||||||
// 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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,6 +39,9 @@ type ChannelContext struct {
|
|||||||
ChannelID string
|
ChannelID string
|
||||||
ChannelType string
|
ChannelType string
|
||||||
LastUserMsg string
|
LastUserMsg string
|
||||||
|
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
|
MessageCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
if named, ok := a.(interface{ ConfigName() string }); ok {
|
||||||
|
unified.AdapterName = named.ConfigName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve identity (nil for unknown users; caller decides routing).
|
||||||
|
// 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.SenderID = identity.CyreneUser
|
||||||
unified.SenderName = identity.Nickname
|
unified.SenderName = identity.Nickname
|
||||||
|
}
|
||||||
// Apply permission-based filtering.
|
|
||||||
_ = identity // used by permission checks on tools
|
|
||||||
|
|
||||||
// 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,8 +8,8 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,12 +17,18 @@ var knownPlatforms = map[string]bool{
|
|||||||
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":
|
||||||
@@ -121,6 +130,7 @@ func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request, name s
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"name": cfg.Name,
|
"name": cfg.Name,
|
||||||
|
"platform": cfg.Platform,
|
||||||
"enabled": cfg.Enabled,
|
"enabled": cfg.Enabled,
|
||||||
"label": cfg.Label,
|
"label": cfg.Label,
|
||||||
"fields": cfg.Fields,
|
"fields": cfg.Fields,
|
||||||
@@ -131,6 +141,7 @@ func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request, name s
|
|||||||
|
|
||||||
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 {
|
||||||
|
Platform *string `json:"platform"`
|
||||||
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"`
|
||||||
@@ -140,6 +151,15 @@ func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name
|
|||||||
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
|
||||||
@@ -152,6 +172,7 @@ func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name
|
|||||||
|
|
||||||
cfg := config.PlatformConfig{
|
cfg := config.PlatformConfig{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
Platform: platform,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
Label: body.Label,
|
Label: body.Label,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
@@ -161,8 +182,14 @@ func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name
|
|||||||
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,
|
||||||
|
"platform": platform,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"label": body.Label,
|
"label": body.Label,
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
@@ -171,9 +198,21 @@ func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user