feat: SearXNG 搜索集成 + DevTools Docker + PG 备份 + 文档更新
- web_search 工具/插件接入自托管 SearXNG,支持百度/必应/搜狗/360搜索 - DevTools 加入 docker-compose.dev.yml,devtools/Dockerfile - scripts/pg-backup.sh 数据库备份恢复脚本,docs/pg-backup-migration.md - 后台思考 + datetime 插件时区默认 Asia/Shanghai - docker-compose 对齐 volume 名称,清理 tool-engine 残留引用 - README.md / Deploy.md 更新至当前架构(移除简报/tool-engine,新增搜索/跨端同步/DevTools) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -31,12 +31,16 @@ backend/cmd/
|
|||||||
|
|
||||||
# ========== 运行时数据 ==========
|
# ========== 运行时数据 ==========
|
||||||
logs/
|
logs/
|
||||||
|
backups/
|
||||||
*.log
|
*.log
|
||||||
*.pid
|
*.pid
|
||||||
uploads/
|
uploads/
|
||||||
backend/gateway/uploads/
|
backend/gateway/uploads/
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# ========== nginx 部署配置 (仅服务器端使用,不进仓库) ==========
|
||||||
|
nginx-ssl.conf
|
||||||
|
|
||||||
# ========== 环境与敏感配置 ==========
|
# ========== 环境与敏感配置 ==========
|
||||||
.env
|
.env
|
||||||
backend/.env
|
backend/.env
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ cp backend/.env.example backend/.env
|
|||||||
./devtools.sh start --build
|
./devtools.sh start --build
|
||||||
```
|
```
|
||||||
|
|
||||||
首次运行会编译全部后端 Go 服务(约 1-2 分钟),之后按依赖顺序启动全部 9 个服务,每步等待健康检查通过。
|
首次运行会编译全部后端 Go 服务(约 1-2 分钟),之后按依赖顺序启动全部 8 个服务,每步等待健康检查通过。
|
||||||
|
|
||||||
### 4. 打开控制台
|
### 4. 打开控制台
|
||||||
|
|
||||||
@@ -83,28 +83,25 @@ docker compose -f docker-compose.dev.db.yml up -d
|
|||||||
# 1) 记忆服务 (端口 8091)
|
# 1) 记忆服务 (端口 8091)
|
||||||
cd backend/memory-service && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/memory-service && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 2) 工具引擎 (端口 8092)
|
# 2) 插件管理器 (端口 8094)
|
||||||
cd backend/tool-engine && go build -o main.exe ./cmd/main.go && ./main.exe
|
|
||||||
|
|
||||||
# 3) 插件管理器 (端口 8094)
|
|
||||||
cd backend/plugin-manager && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/plugin-manager && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 4) IoT 调试服务 (端口 8083)
|
# 3) IoT 调试服务 (端口 8083)
|
||||||
cd backend/iot-debug-service && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/iot-debug-service && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 5) 语音服务 (端口 8093)
|
# 4) 语音服务 (端口 8093)
|
||||||
cd backend/voice-service && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/voice-service && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 6) AI-Core (端口 8081)
|
# 5) AI-Core (端口 8081)
|
||||||
cd backend/ai-core && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/ai-core && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 7) 多平台桥接 (端口 8095)
|
# 6) 多平台桥接 (端口 8095)
|
||||||
cd backend/platform-bridge && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/platform-bridge && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 8) Gateway (端口 8080)
|
# 7) Gateway (端口 8080)
|
||||||
cd backend/gateway && go build -o main.exe ./cmd/main.go && ./main.exe
|
cd backend/gateway && go build -o main.exe ./cmd/main.go && ./main.exe
|
||||||
|
|
||||||
# 9) 前端 (端口 5173)
|
# 8) 前端 (端口 5173)
|
||||||
cd frontend/web && npm install && npx vite --host 0.0.0.0
|
cd frontend/web && npm install && npx vite --host 0.0.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,7 +117,7 @@ cd frontend/web && npm install && npx vite --host 0.0.0.0
|
|||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动服务:postgres, redis, qdrant, minio, nats, memory-service, tool-engine, voice-service, iot-debug-service, ai-core, gateway。前端需本地启动。
|
启动服务:postgres, redis, qdrant, minio, nats, searxng, memory-service, voice-service, iot-debug-service, ai-core, gateway, devtools。前端需本地启动。
|
||||||
|
|
||||||
> plugin-manager 和 platform-bridge 目前不包含在 docker-compose 中,需本地启动。
|
> plugin-manager 和 platform-bridge 目前不包含在 docker-compose 中,需本地启动。
|
||||||
|
|
||||||
@@ -144,18 +141,19 @@ Cyrene/
|
|||||||
│ ├── ai-core/ # AI 推理核心 (LLM 编排、人设注入、工具调用、后台思考)
|
│ ├── ai-core/ # AI 推理核心 (LLM 编排、人设注入、工具调用、后台思考)
|
||||||
│ ├── gateway/ # API 网关 (JWT、路由、限流、WebSocket Hub)
|
│ ├── gateway/ # API 网关 (JWT、路由、限流、WebSocket Hub)
|
||||||
│ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、自动提取)
|
│ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、自动提取)
|
||||||
│ ├── tool-engine/ # 工具引擎 (13+ 内置工具:计算器/HTTP/IoT/文件等)
|
|
||||||
│ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS)
|
│ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS)
|
||||||
│ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备)
|
│ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备)
|
||||||
│ ├── plugin-manager/ # 插件管理器 (14 个内置插件)
|
│ ├── plugin-manager/ # 插件管理器 (管理 API,插件逻辑在 pkg/plugins)
|
||||||
│ ├── platform-bridge/ # 多平台桥接 (QQ / Telegram / Discord / Webhook)
|
│ ├── platform-bridge/ # 多平台桥接 (QQ / Telegram / Discord / Webhook)
|
||||||
│ └── pkg/ # 共享包 (logger)
|
│ └── pkg/ # 共享包 (logger, plugins — 15 个通用插件/工具)
|
||||||
├── devtools/ # DevTools 管理面板 (Express + WebSocket)
|
├── devtools/ # DevTools 管理面板 (Express + WebSocket)
|
||||||
├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup)
|
├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup / pg-backup)
|
||||||
|
├── searxng/ # SearXNG 搜索引擎配置
|
||||||
|
├── backups/ # 数据库备份文件
|
||||||
├── test/ # E2E 测试
|
├── test/ # E2E 测试
|
||||||
├── docs/ # 文档
|
├── docs/ # 文档
|
||||||
├── docker-compose.dev.db.yml # 开发基础设施
|
├── docker-compose.dev.db.yml # 开发基础设施
|
||||||
├── docker-compose.dev.yml # 开发环境 (DB + 后端)
|
├── docker-compose.dev.yml # 开发环境 (DB + 后端 + DevTools + SearXNG)
|
||||||
├── docker-compose.yml # 生产环境 (+ Caddy)
|
├── docker-compose.yml # 生产环境 (+ Caddy)
|
||||||
└── devtools.sh # DevTools CLI
|
└── devtools.sh # DevTools CLI
|
||||||
```
|
```
|
||||||
@@ -171,7 +169,7 @@ Cyrene/
|
|||||||
| 8081 | AI-Core | 否 |
|
| 8081 | AI-Core | 否 |
|
||||||
| 8083 | IoT Debug | 否 |
|
| 8083 | IoT Debug | 否 |
|
||||||
| 8091 | Memory Service | 否 |
|
| 8091 | Memory Service | 否 |
|
||||||
| 8092 | Tool Engine | 否 |
|
| 8088 | SearXNG | 否 |
|
||||||
| 8093 | Voice Service | 否 |
|
| 8093 | Voice Service | 否 |
|
||||||
| 8094 | Plugin Manager | 否 |
|
| 8094 | Plugin Manager | 否 |
|
||||||
| 8095 | Platform Bridge | 否 |
|
| 8095 | Platform Bridge | 否 |
|
||||||
@@ -233,7 +231,6 @@ Cyrene/
|
|||||||
| 变量 | 默认值 |
|
| 变量 | 默认值 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `MEMORY_SERVICE_URL` | `http://localhost:8091` |
|
| `MEMORY_SERVICE_URL` | `http://localhost:8091` |
|
||||||
| `TOOL_ENGINE_URL` | `http://localhost:8092` |
|
|
||||||
| `VOICE_SERVICE_URL` | `http://localhost:8093` |
|
| `VOICE_SERVICE_URL` | `http://localhost:8093` |
|
||||||
| `IOT_SERVICE_URL` | `http://localhost:8083` |
|
| `IOT_SERVICE_URL` | `http://localhost:8083` |
|
||||||
|
|
||||||
@@ -267,6 +264,20 @@ docker exec -it cyrene_postgres psql -U cyrene -d cyrene_ai
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 数据库备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份数据库
|
||||||
|
./scripts/pg-backup.sh backup
|
||||||
|
|
||||||
|
# 从最新备份恢复
|
||||||
|
./scripts/pg-backup.sh restore
|
||||||
|
```
|
||||||
|
|
||||||
|
备份文件保存在 `backups/` 目录,自动保留最近 7 个。详见 [docs/pg-backup-migration.md](docs/pg-backup-migration.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 平台迁移
|
## 平台迁移
|
||||||
|
|
||||||
从 Linux 迁移到 Windows 的详细指南见 [Migration.md](Migration.md)。
|
从 Linux 迁移到 Windows 的详细指南见 [Migration.md](Migration.md)。
|
||||||
|
|||||||
@@ -16,20 +16,22 @@
|
|||||||
│ Gateway (Go/Gin) │
|
│ Gateway (Go/Gin) │
|
||||||
│ localhost:8080 │
|
│ localhost:8080 │
|
||||||
│ JWT Auth · Rate Limit · WS Hub · API 路由 │
|
│ JWT Auth · Rate Limit · WS Hub · API 路由 │
|
||||||
└──┬───────┬────────┬────────┬────────┬────────┬────────┬─────────┘
|
└──┬───────┬────────┬────────┬────────┬────────┬──────────┘
|
||||||
│ │ │ │ │ │ │
|
│ │ │ │ │ │ │
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
┌─────┐┌─────┐┌──────┐┌──────┐┌──────┐┌──────┐┌──────┐┌──────────┐
|
┌─────┐┌─────┐┌──────┐┌──────┐┌──────┐┌──────┐┌──────────┐
|
||||||
│AI ││Mem- ││Tool ││Voice ││IoT ││Plugin││Plat- ││ Infra │
|
│AI ││Mem- ││Voice ││IoT ││Plugin││Plat- ││ Infra │
|
||||||
│Core ││ory ││Engine││Svc ││Debug ││Mgr ││form ││ │
|
│Core ││ory ││Svc ││Debug ││Mgr ││form ││ │
|
||||||
│:8081││:8091││:8092 ││:8093 ││:8083 ││:8094 ││Bridge││ PG:5432 │
|
│:8081││:8091││:8093 ││:8083 ││:8094 ││Bridge││ PG:5432 │
|
||||||
│ ││ ││ ││ ││ ││ ││:8095 ││ Redis │
|
│ ││ ││ ││ ││ ││:8095 ││ Redis │
|
||||||
│LLM ││CRUD ││工具 ││STT/ ││模拟 ││插件 ││QQ/ ││ :6379 │
|
│LLM ││CRUD ││STT/ ││模拟 ││插件 ││QQ/ ││ :6379 │
|
||||||
│编排 ││检索 ││调用 ││TTS ││设备 ││托管 ││TG/ ││ Qdrant │
|
│编排 ││检索 ││TTS ││设备 ││托管 ││TG/ ││ Qdrant │
|
||||||
│人设 ││衰减 ││链 ││ ││管理 ││沙箱 ││DC/ ││ :6333 │
|
│人设 ││衰减 ││ ││管理 ││沙箱 ││DC/ ││ :6333 │
|
||||||
│后台 ││ ││ ││ ││ ││ ││Webhk ││ MinIO │
|
│后台 ││ ││ ││ ││ ││Webhk ││ MinIO │
|
||||||
│思考 ││ ││ ││ ││ ││ ││ ││ :9000 │
|
│思考 ││ ││ ││ ││ ││ ││ :9000 │
|
||||||
└─────┘└─────┘└──────┘└──────┘└──────┘└──────┘└──────┘└──────────┘
|
│ ││ ││ ││ ││ ││ ││ SearXNG │
|
||||||
|
│ ││ ││ ││ ││ ││ ││ :8088 │
|
||||||
|
└─────┘└─────┘└──────┘└──────┘└──────┘└──────┘└──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**客户端只需连接 Gateway (8080)**。所有后端服务不直接对外暴露。
|
**客户端只需连接 Gateway (8080)**。所有后端服务不直接对外暴露。
|
||||||
@@ -42,13 +44,14 @@
|
|||||||
- **IoT 操控** — 8 个模拟智能家居设备(灯/空调/窗帘/传感器/门锁),语音/文本控制
|
- **IoT 操控** — 8 个模拟智能家居设备(灯/空调/窗帘/传感器/门锁),语音/文本控制
|
||||||
- **记忆管理** — LLM 驱动的长期记忆提取、存储、语义检索、衰减(pgvector)
|
- **记忆管理** — LLM 驱动的长期记忆提取、存储、语义检索、衰减(pgvector)
|
||||||
- **自动化** — 规则引擎 + 场景执行(定时/条件触发/Webhook)
|
- **自动化** — 规则引擎 + 场景执行(定时/条件触发/Webhook)
|
||||||
- **每日简报** — 定时生成当日汇总并推送
|
|
||||||
- **提醒** — 创建/管理定时提醒,到期 WebSocket 推送
|
- **提醒** — 创建/管理定时提醒,到期 WebSocket 推送
|
||||||
- **知识库** — 文档管理 + 向量语义检索
|
- **知识库** — 文档管理 + 向量语义检索
|
||||||
- **文件管理** — 上传/下载/缩略图/图片 AI 分析
|
- **文件管理** — 上传/下载/缩略图/图片 AI 分析
|
||||||
- **语音交互** — 服务端 DashScope STT + Edge-TTS,支持实时流式语音
|
- **语音交互** — 服务端 DashScope STT + Edge-TTS,支持实时流式语音
|
||||||
- **WebSocket** — 实时消息推送、IoT 状态广播、通知、流式响应
|
- **WebSocket** — 实时消息推送、IoT 状态广播、通知、流式响应
|
||||||
- **后台思考** — AI 在对话间隙自主反思和记忆整理
|
- **后台思考** — AI 在对话间隙自主反思和记忆整理
|
||||||
|
- **跨端消息同步** — 多设备实时消息广播、会话隔离与去重
|
||||||
|
- **互联网搜索** — 自托管 SearXNG 搜索引擎,支持百度/必应/搜狗/360
|
||||||
- **PWA** — 可安装为桌面/移动应用
|
- **PWA** — 可安装为桌面/移动应用
|
||||||
- **多平台桥接** — QQ / Telegram / Discord / Webhook 第三方平台接入
|
- **多平台桥接** — QQ / Telegram / Discord / Webhook 第三方平台接入
|
||||||
- **插件系统** — 14 个内置插件(计算器/HTTP/加密/搜索/IoT 等),沙箱隔离
|
- **插件系统** — 14 个内置插件(计算器/HTTP/加密/搜索/IoT 等),沙箱隔离
|
||||||
@@ -92,7 +95,7 @@ docker compose -f docker-compose.dev.db.yml up -d
|
|||||||
devtools.bat start --build
|
devtools.bat start --build
|
||||||
```
|
```
|
||||||
|
|
||||||
按依赖顺序编译并启动全部 9 个服务:memory → tool-engine → plugin-manager → iot-debug → voice → ai-core → platform-bridge → gateway → frontend。
|
按依赖顺序编译并启动全部 8 个服务:memory → plugin-manager → iot-debug → voice → ai-core → platform-bridge → gateway → frontend。
|
||||||
|
|
||||||
启动后访问:
|
启动后访问:
|
||||||
|
|
||||||
@@ -127,17 +130,18 @@ Cyrene/
|
|||||||
│ ├── ai-core/ # AI 推理核心 (LLM 编排、人设注入、工具调用、后台思考)
|
│ ├── ai-core/ # AI 推理核心 (LLM 编排、人设注入、工具调用、后台思考)
|
||||||
│ ├── gateway/ # API 网关 (JWT 认证、路由、限流、WebSocket Hub)
|
│ ├── gateway/ # API 网关 (JWT 认证、路由、限流、WebSocket Hub)
|
||||||
│ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、LLM 提取)
|
│ ├── memory-service/ # 记忆服务 (CRUD、语义检索、衰减、LLM 提取)
|
||||||
│ ├── tool-engine/ # 工具引擎 (13+ 内置工具,支持工具调用链)
|
|
||||||
│ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS)
|
│ ├── voice-service/ # 语音服务 (DashScope STT + Edge-TTS)
|
||||||
│ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备)
|
│ ├── iot-debug-service/ # IoT 调试服务 (8 个模拟智能家居设备)
|
||||||
│ ├── plugin-manager/ # 插件管理器 (14 个内置插件、沙箱隔离)
|
│ ├── plugin-manager/ # 插件管理器 (管理 API,插件逻辑在 pkg/plugins)
|
||||||
│ ├── platform-bridge/ # 多平台桥接 (QQ / Telegram / Discord / Webhook)
|
│ ├── platform-bridge/ # 多平台桥接 (QQ / Telegram / Discord / Webhook)
|
||||||
│ └── pkg/ # 共享包 (logger)
|
│ └── pkg/ # 共享包 (logger, plugins — 15 个通用插件/工具)
|
||||||
├── devtools/ # DevTools 管理面板 (Express + WebSocket)
|
├── devtools/ # DevTools 管理面板 (Express + WebSocket)
|
||||||
├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup)
|
├── scripts/ # 辅助脚本 (migrate / tunnel / whisper-setup / pg-backup)
|
||||||
|
├── backups/ # 数据库备份文件 (.gitignore)
|
||||||
├── test/ # E2E 测试
|
├── test/ # E2E 测试
|
||||||
├── docs/ # 文档与调试记录
|
├── docs/ # 文档与调试记录
|
||||||
│ └── api/ # API 文档
|
│ └── api/ # API 文档
|
||||||
|
├── searxng/ # SearXNG 搜索引擎配置
|
||||||
├── docker-compose.dev.db.yml # 开发基础设施 (仅 DB)
|
├── docker-compose.dev.db.yml # 开发基础设施 (仅 DB)
|
||||||
├── docker-compose.dev.yml # 开发环境一键启动
|
├── docker-compose.dev.yml # 开发环境一键启动
|
||||||
├── docker-compose.yml # 生产环境 (含 Caddy)
|
├── docker-compose.yml # 生产环境 (含 Caddy)
|
||||||
@@ -157,7 +161,7 @@ Cyrene/
|
|||||||
| 8081 | AI-Core | 否 |
|
| 8081 | AI-Core | 否 |
|
||||||
| 8083 | IoT Debug | 否 |
|
| 8083 | IoT Debug | 否 |
|
||||||
| 8091 | Memory Service | 否 |
|
| 8091 | Memory Service | 否 |
|
||||||
| 8092 | Tool Engine | 否 |
|
| 8088 | SearXNG | 否 |
|
||||||
| 8093 | Voice Service | 否 |
|
| 8093 | Voice Service | 否 |
|
||||||
| 8094 | Plugin Manager | 否 |
|
| 8094 | Plugin Manager | 否 |
|
||||||
| 8095 | Platform Bridge | 否 |
|
| 8095 | Platform Bridge | 否 |
|
||||||
@@ -184,6 +188,7 @@ Cyrene/
|
|||||||
| 向量库 | Qdrant |
|
| 向量库 | Qdrant |
|
||||||
| 对象存储 | MinIO |
|
| 对象存储 | MinIO |
|
||||||
| 消息队列 | NATS |
|
| 消息队列 | NATS |
|
||||||
|
| 搜索 | SearXNG (自托管元搜索引擎) |
|
||||||
| 语音 | DashScope STT / Edge-TTS / Whisper.cpp |
|
| 语音 | DashScope STT / Edge-TTS / Whisper.cpp |
|
||||||
| 反向代理 | Caddy (生产环境) |
|
| 反向代理 | Caddy (生产环境) |
|
||||||
|
|
||||||
@@ -198,6 +203,7 @@ Cyrene/
|
|||||||
| [docs/api/devtools.md](docs/api/devtools.md) | DevTools CLI + Web 控制台文档 |
|
| [docs/api/devtools.md](docs/api/devtools.md) | DevTools 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/dev_must_read.md](docs/dev_must_read.md) | 开发者必读 |
|
||||||
|
| [docs/pg-backup-migration.md](docs/pg-backup-migration.md) | PG 备份与迁移指南 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,12 @@ func main() {
|
|||||||
registerPluginTools(toolRegistry, &pluginJSON.JSONPlugin{})
|
registerPluginTools(toolRegistry, &pluginJSON.JSONPlugin{})
|
||||||
registerPluginTools(toolRegistry, pluginFile.NewFilePlugin(dataDir))
|
registerPluginTools(toolRegistry, pluginFile.NewFilePlugin(dataDir))
|
||||||
registerPluginTools(toolRegistry, pluginHTTP.NewHTTPPlugin())
|
registerPluginTools(toolRegistry, pluginHTTP.NewHTTPPlugin())
|
||||||
|
searxngURL := getEnv("SEARXNG_URL", "")
|
||||||
|
if searxngURL != "" {
|
||||||
|
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPluginWithURL(searxngURL))
|
||||||
|
} else {
|
||||||
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
|
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
|
||||||
|
}
|
||||||
registerPluginTools(toolRegistry, pluginWF.NewWebFetchPlugin())
|
registerPluginTools(toolRegistry, pluginWF.NewWebFetchPlugin())
|
||||||
|
|
||||||
// ai-core 专属工具 — 通过 sdk.Tool 适配器注册
|
// ai-core 专属工具 — 通过 sdk.Tool 适配器注册
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ type Thinker struct {
|
|||||||
userOnline bool
|
userOnline bool
|
||||||
lastOnlineChange time.Time
|
lastOnlineChange time.Time
|
||||||
userSessionID string // 当前活跃的 session ID (用于重连)
|
userSessionID string // 当前活跃的 session ID (用于重连)
|
||||||
|
|
||||||
|
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
|
||||||
|
timeLocation *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutonomousToolPolicy 自主思考工具调用安全策略
|
// AutonomousToolPolicy 自主思考工具调用安全策略
|
||||||
@@ -247,6 +250,17 @@ func NewThinker(
|
|||||||
adminSessionID string,
|
adminSessionID string,
|
||||||
memClient *memory.Client,
|
memClient *memory.Client,
|
||||||
) *Thinker {
|
) *Thinker {
|
||||||
|
// 加载时区配置
|
||||||
|
tzName := os.Getenv("TZ")
|
||||||
|
if tzName == "" {
|
||||||
|
tzName = "Asia/Shanghai"
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(tzName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[后台思考] 无效时区 '%s',回退到 Asia/Shanghai: %%v", tzName, err)
|
||||||
|
loc, _ = time.LoadLocation("Asia/Shanghai")
|
||||||
|
}
|
||||||
|
|
||||||
return &Thinker{
|
return &Thinker{
|
||||||
enabled: cfg.Enabled,
|
enabled: cfg.Enabled,
|
||||||
personaLoader: personaLoader,
|
personaLoader: personaLoader,
|
||||||
@@ -261,6 +275,7 @@ func NewThinker(
|
|||||||
minThinkGap: cfg.MinThinkGap,
|
minThinkGap: cfg.MinThinkGap,
|
||||||
offlineThinkGap: cfg.OfflineThinkGap,
|
offlineThinkGap: cfg.OfflineThinkGap,
|
||||||
memoryStore: memoryStore,
|
memoryStore: memoryStore,
|
||||||
|
timeLocation: loc,
|
||||||
|
|
||||||
toolRegistry: toolRegistry,
|
toolRegistry: toolRegistry,
|
||||||
convStore: convStore,
|
convStore: convStore,
|
||||||
@@ -863,14 +878,30 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// 注入当前现实时间,让模型对时间有感知
|
// 注入当前现实时间,让模型对时间有感知
|
||||||
now := time.Now()
|
now := time.Now().In(t.timeLocation)
|
||||||
weekdayNames := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
weekdayNames := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||||
sb.WriteString(fmt.Sprintf("🕐 现在是 %s %s %02d:%02d。\n",
|
hour := now.Hour()
|
||||||
|
minute := now.Minute()
|
||||||
|
ampm := ""
|
||||||
|
if hour >= 0 && hour < 6 {
|
||||||
|
ampm = "凌晨"
|
||||||
|
} else if hour < 9 {
|
||||||
|
ampm = "早上"
|
||||||
|
} else if hour < 12 {
|
||||||
|
ampm = "上午"
|
||||||
|
} else if hour < 14 {
|
||||||
|
ampm = "中午"
|
||||||
|
} else if hour < 18 {
|
||||||
|
ampm = "下午"
|
||||||
|
} else {
|
||||||
|
ampm = "晚上"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("🕐 现在是 %s %s %s%d:%02d (%s)。\n",
|
||||||
now.Format("2006年1月2日"),
|
now.Format("2006年1月2日"),
|
||||||
weekdayNames[now.Weekday()],
|
weekdayNames[now.Weekday()],
|
||||||
now.Hour(), now.Minute()))
|
ampm, hour, minute,
|
||||||
|
t.timeLocation.String()))
|
||||||
|
|
||||||
// 根据触发原因使用不同的开场白
|
|
||||||
switch triggerReason {
|
switch triggerReason {
|
||||||
case "post_chat":
|
case "post_chat":
|
||||||
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
||||||
|
|||||||
@@ -303,12 +303,16 @@ func (t *DateTimeTool) handleTimezoneList() (*ToolResult, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTimezone extracts the timezone from arguments, defaulting to local.
|
// getTimezone extracts the timezone from arguments, defaulting to Asia/Shanghai.
|
||||||
func (t *DateTimeTool) getTimezone(arguments map[string]interface{}) (*time.Location, error) {
|
func (t *DateTimeTool) getTimezone(arguments map[string]interface{}) (*time.Location, error) {
|
||||||
tzName, _ := arguments["timezone"].(string)
|
tzName, _ := arguments["timezone"].(string)
|
||||||
if tzName == "" {
|
if tzName == "" {
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
return time.Local, nil
|
return time.Local, nil
|
||||||
}
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
loc, err := time.LoadLocation(tzName)
|
loc, err := time.LoadLocation(tzName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("无效时区: %s", tzName)
|
return nil, fmt.Errorf("无效时区: %s", tzName)
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSearchTool 网页搜索工具 - 基于 DuckDuckGo Instant Answer API
|
// WebSearchTool 网页搜索工具 - 基于 SearXNG (或 DuckDuckGo fallback)
|
||||||
type WebSearchTool struct {
|
type WebSearchTool struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
|
searxngURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebSearchTool 创建网页搜索工具
|
// NewWebSearchTool 创建网页搜索工具
|
||||||
@@ -27,6 +28,17 @@ func NewWebSearchTool() *WebSearchTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWebSearchToolWithURL 使用 SearXNG 创建搜索工具
|
||||||
|
func NewWebSearchToolWithURL(searxngURL string) *WebSearchTool {
|
||||||
|
return &WebSearchTool{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
timeout: 10 * time.Second,
|
||||||
|
searxngURL: strings.TrimRight(searxngURL, "/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Definition 返回工具定义
|
// Definition 返回工具定义
|
||||||
func (t *WebSearchTool) Definition() ToolDefinition {
|
func (t *WebSearchTool) Definition() ToolDefinition {
|
||||||
return ToolDefinition{
|
return ToolDefinition{
|
||||||
@@ -78,66 +90,124 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 DuckDuckGo Instant Answer API
|
if t.searxngURL != "" {
|
||||||
|
return t.searchViaSearXNG(ctx, query)
|
||||||
|
}
|
||||||
|
return t.searchViaDuckDuckGo(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WebSearchTool) searchViaSearXNG(ctx context.Context, query string) (*ToolResult, error) {
|
||||||
|
apiURL := fmt.Sprintf("%s/search?format=json&engines=baidu,sogou,360search,bing&q=%s",
|
||||||
|
t.searxngURL, url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG 请求失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG HTTP %d", resp.StatusCode)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sr searxngAPIResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
||||||
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG 解析失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("搜索关键词: %s (共%d条结果)\n\n", query, sr.NumberOrResults))
|
||||||
|
|
||||||
|
for _, answer := range sr.Answers {
|
||||||
|
result.WriteString(fmt.Sprintf("📌 %s\n\n", answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, r := range sr.Results {
|
||||||
|
if count >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if r.Title == "" || r.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snippet := cleanSnippet(r.Content)
|
||||||
|
result.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", count+1, r.Title, r.URL, snippet))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Len() == 0 {
|
||||||
|
result.WriteString("未找到相关结果。")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{ToolName: "web_search", Success: true, Data: result.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searxngAPIResponse SearXNG JSON 响应
|
||||||
|
type searxngAPIResponse struct {
|
||||||
|
NumberOrResults int `json:"number_of_results"`
|
||||||
|
Results []searxngResult `json:"results"`
|
||||||
|
Answers []string `json:"answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searxngResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanSnippet(s string) string {
|
||||||
|
text := stripHTML(s)
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > 200 {
|
||||||
|
return string(runes[:200]) + "..."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WebSearchTool) searchViaDuckDuckGo(ctx context.Context, query string) (*ToolResult, error) {
|
||||||
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1&skip_disambig=1",
|
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1&skip_disambig=1",
|
||||||
url.QueryEscape(query))
|
url.QueryEscape(query))
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
|
||||||
ToolName: "web_search",
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("请求失败: %v", err)}, nil
|
||||||
ToolName: "web_search",
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("请求失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return &ToolResult{
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}, nil
|
||||||
ToolName: "web_search",
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("读取响应失败: %v", err)}, nil
|
||||||
ToolName: "web_search",
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("读取响应失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ddg duckDuckGoResponse
|
var ddg duckDuckGoResponse
|
||||||
if err := json.Unmarshal(body, &ddg); err != nil {
|
if err := json.Unmarshal(body, &ddg); err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("解析响应失败: %v", err)}, nil
|
||||||
ToolName: "web_search",
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("解析响应失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
result.WriteString(fmt.Sprintf("搜索关键词: %s\n\n", query))
|
result.WriteString(fmt.Sprintf("搜索关键词: %s\n\n", query))
|
||||||
|
|
||||||
// 1. 如果有即时答案
|
|
||||||
if ddg.Answer != "" {
|
if ddg.Answer != "" {
|
||||||
result.WriteString(fmt.Sprintf("📌 即时答案: %s\n\n", ddg.Answer))
|
result.WriteString(fmt.Sprintf("📌 即时答案: %s\n\n", ddg.Answer))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 摘要
|
|
||||||
if ddg.AbstractText != "" {
|
if ddg.AbstractText != "" {
|
||||||
abstract := ddg.AbstractText
|
abstract := ddg.AbstractText
|
||||||
if len([]rune(abstract)) > 500 {
|
if len([]rune(abstract)) > 500 {
|
||||||
@@ -151,10 +221,8 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
|
|||||||
result.WriteString("\n")
|
result.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 相关话题
|
|
||||||
topics := ddg.RelatedTopics
|
topics := ddg.RelatedTopics
|
||||||
if len(ddg.Results) > 0 {
|
if len(ddg.Results) > 0 {
|
||||||
// 优先用 Results
|
|
||||||
count := 0
|
count := 0
|
||||||
for _, r := range ddg.Results {
|
for _, r := range ddg.Results {
|
||||||
if count >= 5 {
|
if count >= 5 {
|
||||||
@@ -198,11 +266,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
|
|||||||
result.WriteString("未找到相关结果。")
|
result.WriteString("未找到相关结果。")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ToolResult{
|
return &ToolResult{ToolName: "web_search", Success: true, Data: result.String()}, nil
|
||||||
ToolName: "web_search",
|
|
||||||
Success: true,
|
|
||||||
Data: result.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripHTML 去除 HTML 标签
|
// stripHTML 去除 HTML 标签
|
||||||
|
|||||||
@@ -126,8 +126,12 @@ func (t *DatetimeTool) Execute(_ context.Context, args map[string]interface{}) (
|
|||||||
|
|
||||||
func parseLocation(tz string) (*time.Location, error) {
|
func parseLocation(tz string) (*time.Location, error) {
|
||||||
if tz == "" {
|
if tz == "" {
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
return time.UTC, nil
|
return time.UTC, nil
|
||||||
}
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
return time.LoadLocation(tz)
|
return time.LoadLocation(tz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,27 +15,56 @@ import (
|
|||||||
type WebSearchPlugin struct {
|
type WebSearchPlugin struct {
|
||||||
sdk.BasePlugin
|
sdk.BasePlugin
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
searxngURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebSearchPlugin() *WebSearchPlugin {
|
func NewWebSearchPlugin() *WebSearchPlugin {
|
||||||
return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}}
|
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 {
|
func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata {
|
||||||
return sdk.PluginMetadata{
|
return sdk.PluginMetadata{
|
||||||
Name: "web_search", DisplayName: "Web Search", Version: "1.0.0",
|
Name: "web_search", DisplayName: "Web Search", Version: "1.1.0",
|
||||||
Description: "Search the internet via DuckDuckGo Instant Answer API",
|
Description: "Search the internet via SearXNG (or DuckDuckGo fallback)",
|
||||||
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WebSearchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebSearchTool{client: p.client}} }
|
func (p *WebSearchPlugin) Tools() []sdk.Tool {
|
||||||
|
return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}}
|
||||||
|
}
|
||||||
|
|
||||||
type WebSearchTool struct {
|
type WebSearchTool struct {
|
||||||
sdk.BaseTool
|
sdk.BaseTool
|
||||||
client *http.Client
|
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 {
|
type ddgResponse struct {
|
||||||
Abstract string `json:"Abstract"`
|
Abstract string `json:"Abstract"`
|
||||||
AbstractText string `json:"AbstractText"`
|
AbstractText string `json:"AbstractText"`
|
||||||
@@ -53,7 +82,7 @@ type ddgTopic struct {
|
|||||||
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
|
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
|
||||||
return sdk.ToolDefinition{
|
return sdk.ToolDefinition{
|
||||||
ID: "web_search", Name: "web_search", DisplayName: "Web Search",
|
ID: "web_search", Name: "web_search", DisplayName: "Web Search",
|
||||||
Description: "Search the internet using DuckDuckGo Instant Answer API. Returns up to 5 results.",
|
Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.",
|
||||||
Category: "network", Complexity: sdk.ComplexitySimple,
|
Category: "network", Complexity: sdk.ComplexitySimple,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -72,6 +101,71 @@ func (t *WebSearchTool) Validate(args map[string]interface{}) error {
|
|||||||
|
|
||||||
func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||||
query, _ := args["query"].(string)
|
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 = "baidu,sogou,360search,bing"
|
||||||
|
|
||||||
|
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))
|
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query))
|
||||||
resp, err := t.client.Get(apiURL)
|
resp, err := t.client.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,11 +205,20 @@ func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{})
|
|||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
if out.Len() == 0 {
|
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: "No results found for: " + query}, nil
|
||||||
}
|
}
|
||||||
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, 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 {
|
func stripHTML(s string) string {
|
||||||
result := make([]rune, 0, len([]rune(s)))
|
result := make([]rune, 0, len([]rune(s)))
|
||||||
inTag := false
|
inTag := false
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY public/ ./public/
|
||||||
|
|
||||||
|
ENV DEVTOOLS_PORT=9090
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
EXPOSE 9090
|
||||||
|
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
+52
-27
@@ -5,6 +5,7 @@ services:
|
|||||||
# ========== 基础设施 ==========
|
# ========== 基础设施 ==========
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: cyrene_postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: cyrene
|
POSTGRES_USER: cyrene
|
||||||
POSTGRES_PASSWORD: cyrene_pass
|
POSTGRES_PASSWORD: cyrene_pass
|
||||||
@@ -12,7 +13,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- cyrene_pg_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U cyrene -d cyrene_ai"]
|
test: ["CMD-SHELL", "pg_isready -U cyrene -d cyrene_ai"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -21,10 +22,11 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
container_name: cyrene_redis
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- cyrene_redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -33,14 +35,16 @@ services:
|
|||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:latest
|
image: qdrant/qdrant:latest
|
||||||
|
container_name: cyrene_qdrant
|
||||||
ports:
|
ports:
|
||||||
- "6333:6333"
|
- "6333:6333"
|
||||||
- "6334:6334"
|
- "6334:6334"
|
||||||
volumes:
|
volumes:
|
||||||
- qdrant_data:/qdrant/storage
|
- cyrene_qdrant_data:/qdrant/storage
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
|
container_name: cyrene_minio
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: minioadmin
|
||||||
@@ -49,16 +53,51 @@ services:
|
|||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- cyrene_minio_data:/data
|
||||||
|
|
||||||
nats:
|
nats:
|
||||||
image: nats:2-alpine
|
image: nats:2-alpine
|
||||||
|
container_name: cyrene_nats
|
||||||
ports:
|
ports:
|
||||||
- "4222:4222"
|
- "4222:4222"
|
||||||
- "8222:8222"
|
- "8222:8222"
|
||||||
|
|
||||||
|
# ========== 搜索引擎 ==========
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
container_name: cyrene_searxng
|
||||||
|
volumes:
|
||||||
|
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
|
||||||
|
environment:
|
||||||
|
SEARXNG_SETTINGS_PATH: /etc/searxng/settings.yml
|
||||||
|
ports:
|
||||||
|
- "8088:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ========== 开发调试工具 ==========
|
||||||
|
devtools:
|
||||||
|
container_name: cyrene_devtools
|
||||||
|
build:
|
||||||
|
context: ./devtools
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
DEVTOOLS_PORT: "9090"
|
||||||
|
GATEWAY_URL: http://gateway:8080
|
||||||
|
AI_CORE_URL: http://ai-core:8081
|
||||||
|
MEMORY_SERVICE_URL: http://memory-service:8091
|
||||||
|
VOICE_SERVICE_URL: http://voice-service:8093
|
||||||
|
PLATFORM_BRIDGE_URL: http://platform-bridge:8095
|
||||||
|
ADMIN_USERNAME: admin
|
||||||
|
ADMIN_PASSWORD: cyrene-dev-admin
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# ========== 后端服务 ==========
|
# ========== 后端服务 ==========
|
||||||
memory-service:
|
memory-service:
|
||||||
|
container_name: cyrene_memory_service
|
||||||
build:
|
build:
|
||||||
context: ./backend/memory-service
|
context: ./backend/memory-service
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -76,23 +115,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
tool-engine:
|
|
||||||
build:
|
|
||||||
context: ./backend/tool-engine
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
environment:
|
|
||||||
PORT: "8092"
|
|
||||||
IOT_SERVICE_URL: http://iot-debug-service:8083
|
|
||||||
DATA_DIR: /tmp/cyrene_data
|
|
||||||
DB_URL: "postgres://cyrene:cyrene_pass@postgres:5432/cyrene_ai?sslmode=disable"
|
|
||||||
ports:
|
|
||||||
- "8092:8092"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
voice-service:
|
voice-service:
|
||||||
|
container_name: cyrene_voice_service
|
||||||
build:
|
build:
|
||||||
context: ./backend/voice-service
|
context: ./backend/voice-service
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -106,6 +130,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ai-core:
|
ai-core:
|
||||||
|
container_name: cyrene_ai_core
|
||||||
build:
|
build:
|
||||||
context: ./backend/ai-core
|
context: ./backend/ai-core
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -122,6 +147,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: cyrene_pass
|
POSTGRES_PASSWORD: cyrene_pass
|
||||||
POSTGRES_DB: cyrene_ai
|
POSTGRES_DB: cyrene_ai
|
||||||
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
||||||
|
SEARXNG_URL: http://searxng:8080
|
||||||
ENABLE_BACKGROUND_THINKING: ${ENABLE_BACKGROUND_THINKING:-true}
|
ENABLE_BACKGROUND_THINKING: ${ENABLE_BACKGROUND_THINKING:-true}
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
@@ -133,6 +159,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
iot-debug-service:
|
iot-debug-service:
|
||||||
|
container_name: cyrene_iot_debug_service
|
||||||
build:
|
build:
|
||||||
context: ./backend/iot-debug-service
|
context: ./backend/iot-debug-service
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -143,6 +170,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
|
container_name: cyrene_gateway
|
||||||
build:
|
build:
|
||||||
context: ./backend/gateway
|
context: ./backend/gateway
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -153,7 +181,6 @@ services:
|
|||||||
JWT_EXPIRY_HOURS: "720"
|
JWT_EXPIRY_HOURS: "720"
|
||||||
AI_CORE_URL: http://ai-core:8081
|
AI_CORE_URL: http://ai-core:8081
|
||||||
MEMORY_SERVICE_URL: http://memory-service:8091
|
MEMORY_SERVICE_URL: http://memory-service:8091
|
||||||
TOOL_ENGINE_URL: http://tool-engine:8092
|
|
||||||
VOICE_SERVICE_URL: http://voice-service:8093
|
VOICE_SERVICE_URL: http://voice-service:8093
|
||||||
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
@@ -174,14 +201,12 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
memory-service:
|
memory-service:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
tool-engine:
|
|
||||||
condition: service_started
|
|
||||||
voice-service:
|
voice-service:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_data:
|
cyrene_pg_data:
|
||||||
redis_data:
|
cyrene_redis_data:
|
||||||
qdrant_data:
|
cyrene_qdrant_data:
|
||||||
minio_data:
|
cyrene_minio_data:
|
||||||
|
|||||||
+10
-13
@@ -2,6 +2,15 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ========== 搜索引擎 ==========
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
volumes:
|
||||||
|
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
|
||||||
|
environment:
|
||||||
|
SEARXNG_SETTINGS_PATH: /etc/searxng/settings.yml
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# ========== 反向代理 ==========
|
# ========== 反向代理 ==========
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
@@ -25,7 +34,6 @@ services:
|
|||||||
JWT_EXPIRY_HOURS: "720"
|
JWT_EXPIRY_HOURS: "720"
|
||||||
AI_CORE_URL: http://ai-core:8081
|
AI_CORE_URL: http://ai-core:8081
|
||||||
MEMORY_SERVICE_URL: http://memory-service:8091
|
MEMORY_SERVICE_URL: http://memory-service:8091
|
||||||
TOOL_ENGINE_URL: http://tool-engine:8092
|
|
||||||
VOICE_SERVICE_URL: http://voice-service:8093
|
VOICE_SERVICE_URL: http://voice-service:8093
|
||||||
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
@@ -52,6 +60,7 @@ services:
|
|||||||
LLM_API_KEY: ${LLM_API_KEY}
|
LLM_API_KEY: ${LLM_API_KEY}
|
||||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
|
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
|
||||||
LLM_FALLBACK_MODEL: ${LLM_FALLBACK_MODEL:-gpt-4o-mini}
|
LLM_FALLBACK_MODEL: ${LLM_FALLBACK_MODEL:-gpt-4o-mini}
|
||||||
|
SEARXNG_URL: http://searxng:8080
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
POSTGRES_PORT: "5432"
|
POSTGRES_PORT: "5432"
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-cyrene}
|
POSTGRES_USER: ${POSTGRES_USER:-cyrene}
|
||||||
@@ -76,18 +85,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
tool-engine:
|
|
||||||
build: ./backend/tool-engine
|
|
||||||
environment:
|
|
||||||
PORT: "8092"
|
|
||||||
IOT_SERVICE_URL: http://iot-debug-service:8083
|
|
||||||
DATA_DIR: /tmp/cyrene_data
|
|
||||||
DB_URL: "postgres://${POSTGRES_USER:-cyrene}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-cyrene_ai}?sslmode=disable"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
voice-service:
|
voice-service:
|
||||||
build: ./backend/voice-service
|
build: ./backend/voice-service
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PostgreSQL 备份与迁移
|
||||||
|
|
||||||
|
## 备份脚本
|
||||||
|
|
||||||
|
`scripts/pg-backup.sh` 封装了 `pg_dump` / `psql`,跨任何 PG 版本兼容。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/pg-backup.sh backup # 备份
|
||||||
|
./scripts/pg-backup.sh restore # 从最新备份恢复
|
||||||
|
./scripts/pg-backup.sh restore <文件路径> # 恢复指定备份
|
||||||
|
```
|
||||||
|
|
||||||
|
备份文件写入 `backups/cyrene_YYYYMMDD_HHMMSS.sql`,自动保留最近 7 个。
|
||||||
|
|
||||||
|
## PG 大版本升级(16 → 17 等)
|
||||||
|
|
||||||
|
生产环境用 `docker-compose.yml`,开发环境用 `docker-compose.dev.yml`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === 步骤 1:备份 ===
|
||||||
|
./scripts/pg-backup.sh backup
|
||||||
|
|
||||||
|
# === 步骤 2:停服并清理 ===
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
# 删旧 volume,确保新 PG 从干净状态初始化(可选但推荐)
|
||||||
|
docker volume rm cyrene_cyrene_pg_data
|
||||||
|
|
||||||
|
# === 步骤 3:修改镜像版本 ===
|
||||||
|
# docker-compose.dev.yml 中:
|
||||||
|
# image: pgvector/pgvector:pg16 → image: pgvector/pgvector:pg17
|
||||||
|
|
||||||
|
# === 步骤 4:启动新 PG ===
|
||||||
|
docker compose -f docker-compose.dev.yml up -d postgres
|
||||||
|
|
||||||
|
# 等 healthcheck 通过
|
||||||
|
docker ps --filter name=cyrene_postgres
|
||||||
|
|
||||||
|
# === 步骤 5:恢复数据 ===
|
||||||
|
./scripts/pg-backup.sh restore
|
||||||
|
|
||||||
|
# === 步骤 6:启动全栈 ===
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 版本间切换(dev.db.yml ↔ dev.yml)
|
||||||
|
|
||||||
|
两个文件的 volume 名和容器名已对齐,直接切换即可,数据自动继承:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从轻量版切到全服务版
|
||||||
|
docker compose -f docker-compose.dev.db.yml down
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 反向切回
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
docker compose -f docker-compose.dev.db.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
原有的 `cyrene_*_data` volume 会被新容器直接挂载,无需恢复。
|
||||||
|
|
||||||
|
## 跨服务器迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 源服务器
|
||||||
|
./scripts/pg-backup.sh backup
|
||||||
|
scp backups/cyrene_*.sql user@target:/path/to/backups/
|
||||||
|
|
||||||
|
# 目标服务器
|
||||||
|
./scripts/pg-backup.sh restore backups/cyrene_*.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 新版 PG 启动后不接受旧 volume
|
||||||
|
|
||||||
|
`pg_upgrade` 要求 in-place 升级必须用 PG 自带的升级工具,不能直接挂旧数据目录。如果没删旧 volume 导致启动失败:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs cyrene_postgres # 会提示 data directory incompatible
|
||||||
|
docker compose down
|
||||||
|
docker volume rm cyrene_cyrene_pg_data
|
||||||
|
docker compose up -d
|
||||||
|
./scripts/pg-backup.sh restore
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份文件过大
|
||||||
|
|
||||||
|
`pg_dump` 产生的 `.sql` 文件包含全部数据,如果数据库过大可以考虑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 仅导出结构,不导出日志/缓存表
|
||||||
|
docker exec cyrene_postgres pg_dump -U cyrene -d cyrene_ai \
|
||||||
|
--schema-only > schema.sql
|
||||||
|
|
||||||
|
# 按表分别导出
|
||||||
|
docker exec cyrene_postgres pg_dump -U cyrene -d cyrene_ai \
|
||||||
|
-t memories -t sessions -t users > core_tables.sql
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Cyrene PostgreSQL 备份/恢复
|
||||||
|
# 用法: ./pg-backup.sh backup # 备份到 ./backups/
|
||||||
|
# ./pg-backup.sh restore # 从最新备份恢复
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONTAINER="cyrene_postgres"
|
||||||
|
DB="cyrene_ai"
|
||||||
|
USER="cyrene"
|
||||||
|
BACKUP_DIR="$(cd "$(dirname "$0")/../backups" && pwd)"
|
||||||
|
|
||||||
|
backup() {
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
local file="$BACKUP_DIR/cyrene_$(date +%Y%m%d_%H%M%S).sql"
|
||||||
|
echo "正在备份 $DB → $file ..."
|
||||||
|
docker exec "$CONTAINER" pg_dump -U "$USER" -d "$DB" \
|
||||||
|
--no-owner --no-acl --clean --if-exists > "$file"
|
||||||
|
ls -lh "$file"
|
||||||
|
echo "备份完成: $file"
|
||||||
|
|
||||||
|
# 只保留最近 7 个备份
|
||||||
|
local count=$(ls -1 "$BACKUP_DIR"/*.sql 2>/dev/null | wc -l)
|
||||||
|
if [ "$count" -gt 7 ]; then
|
||||||
|
ls -1t "$BACKUP_DIR"/*.sql | tail -n +8 | xargs rm -f
|
||||||
|
echo "已清理旧备份 (保留最近 7 个)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
local file
|
||||||
|
if [ -n "${1:-}" ]; then
|
||||||
|
file="$1"
|
||||||
|
else
|
||||||
|
file=$(ls -1t "$BACKUP_DIR"/*.sql 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$file" ] || [ ! -f "$file" ]; then
|
||||||
|
echo "错误: 找不到备份文件" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "正在从 $file 恢复..."
|
||||||
|
docker exec -i "$CONTAINER" psql -U "$USER" -d "$DB" < "$file"
|
||||||
|
echo "恢复完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
backup) backup ;;
|
||||||
|
restore) restore "${2:-}" ;;
|
||||||
|
*)
|
||||||
|
echo "用法: $0 backup|restore [文件路径]"
|
||||||
|
echo " backup 备份到 $BACKUP_DIR"
|
||||||
|
echo " restore [file] 恢复备份 (默认最新)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# SearXNG settings for Cyrene AI
|
||||||
|
use_default_settings: true
|
||||||
|
|
||||||
|
server:
|
||||||
|
secret_key: "cyrene-searxng-09dc71af7e2b4f6d"
|
||||||
|
limiter: false
|
||||||
|
|
||||||
|
search:
|
||||||
|
formats:
|
||||||
|
- html
|
||||||
|
- json
|
||||||
|
|
||||||
|
redis:
|
||||||
|
url: redis://redis:6379/1
|
||||||
Reference in New Issue
Block a user