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:
2026-05-26 20:36:38 +08:00
parent 08687bb13d
commit b14d267642
15 changed files with 574 additions and 137 deletions
+6 -1
View File
@@ -178,7 +178,12 @@ func main() {
registerPluginTools(toolRegistry, &pluginJSON.JSONPlugin{})
registerPluginTools(toolRegistry, pluginFile.NewFilePlugin(dataDir))
registerPluginTools(toolRegistry, pluginHTTP.NewHTTPPlugin())
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
searxngURL := getEnv("SEARXNG_URL", "")
if searxngURL != "" {
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPluginWithURL(searxngURL))
} else {
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
}
registerPluginTools(toolRegistry, pluginWF.NewWebFetchPlugin())
// ai-core 专属工具 — 通过 sdk.Tool 适配器注册
+35 -4
View File
@@ -125,6 +125,9 @@ type Thinker struct {
userOnline bool
lastOnlineChange time.Time
userSessionID string // 当前活跃的 session ID (用于重连)
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
timeLocation *time.Location
}
// AutonomousToolPolicy 自主思考工具调用安全策略
@@ -247,6 +250,17 @@ func NewThinker(
adminSessionID string,
memClient *memory.Client,
) *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{
enabled: cfg.Enabled,
personaLoader: personaLoader,
@@ -261,6 +275,7 @@ func NewThinker(
minThinkGap: cfg.MinThinkGap,
offlineThinkGap: cfg.OfflineThinkGap,
memoryStore: memoryStore,
timeLocation: loc,
toolRegistry: toolRegistry,
convStore: convStore,
@@ -863,14 +878,30 @@ func (t *Thinker) buildThinkingUserPrompt(
var sb strings.Builder
// 注入当前现实时间,让模型对时间有感知
now := time.Now()
now := time.Now().In(t.timeLocation)
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日"),
weekdayNames[now.Weekday()],
now.Hour(), now.Minute()))
ampm, hour, minute,
t.timeLocation.String()))
// 根据触发原因使用不同的开场白
switch triggerReason {
case "post_chat":
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
@@ -303,11 +303,15 @@ func (t *DateTimeTool) handleTimezoneList() (*ToolResult, error) {
}, 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) {
tzName, _ := arguments["timezone"].(string)
if tzName == "" {
return time.Local, nil
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Local, nil
}
return loc, nil
}
loc, err := time.LoadLocation(tzName)
if err != nil {
+102 -38
View File
@@ -11,10 +11,11 @@ import (
"time"
)
// WebSearchTool 网页搜索工具 - 基于 DuckDuckGo Instant Answer API
// WebSearchTool 网页搜索工具 - 基于 SearXNG (或 DuckDuckGo fallback)
type WebSearchTool struct {
client *http.Client
timeout time.Duration
client *http.Client
timeout time.Duration
searxngURL string
}
// 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 返回工具定义
func (t *WebSearchTool) Definition() ToolDefinition {
return ToolDefinition{
@@ -78,66 +90,124 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
}, 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",
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
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
resp, err := t.client.Do(req)
if err != nil {
return &ToolResult{
ToolName: "web_search",
Success: false,
Error: fmt.Sprintf("请求失败: %v", err),
}, nil
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("请求失败: %v", err)}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &ToolResult{
ToolName: "web_search",
Success: false,
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
}, nil
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
if err != nil {
return &ToolResult{
ToolName: "web_search",
Success: false,
Error: fmt.Sprintf("读取响应失败: %v", err),
}, nil
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("读取响应失败: %v", err)}, nil
}
var ddg duckDuckGoResponse
if err := json.Unmarshal(body, &ddg); err != nil {
return &ToolResult{
ToolName: "web_search",
Success: false,
Error: fmt.Sprintf("解析响应失败: %v", err),
}, nil
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("解析响应失败: %v", err)}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("搜索关键词: %s\n\n", query))
// 1. 如果有即时答案
if ddg.Answer != "" {
result.WriteString(fmt.Sprintf("📌 即时答案: %s\n\n", ddg.Answer))
}
// 2. 摘要
if ddg.AbstractText != "" {
abstract := ddg.AbstractText
if len([]rune(abstract)) > 500 {
@@ -151,10 +221,8 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
result.WriteString("\n")
}
// 3. 相关话题
topics := ddg.RelatedTopics
if len(ddg.Results) > 0 {
// 优先用 Results
count := 0
for _, r := range ddg.Results {
if count >= 5 {
@@ -198,11 +266,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
result.WriteString("未找到相关结果。")
}
return &ToolResult{
ToolName: "web_search",
Success: true,
Data: result.String(),
}, nil
return &ToolResult{ToolName: "web_search", Success: true, Data: result.String()}, nil
}
// stripHTML 去除 HTML 标签
+5 -1
View File
@@ -126,7 +126,11 @@ func (t *DatetimeTool) Execute(_ context.Context, args map[string]interface{}) (
func parseLocation(tz string) (*time.Location, error) {
if tz == "" {
return time.UTC, nil
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.UTC, nil
}
return loc, nil
}
return time.LoadLocation(tz)
}
+114 -11
View File
@@ -14,33 +14,62 @@ import (
type WebSearchPlugin struct {
sdk.BasePlugin
client *http.Client
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.0.0",
Description: "Search the internet via DuckDuckGo Instant Answer API",
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}} }
func (p *WebSearchPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}}
}
type WebSearchTool struct {
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 {
Abstract string `json:"Abstract"`
AbstractText string `json:"AbstractText"`
Answer string `json:"Answer"`
Heading string `json:"Heading"`
Abstract string `json:"Abstract"`
AbstractText string `json:"AbstractText"`
Answer string `json:"Answer"`
Heading string `json:"Heading"`
Results []ddgTopic `json:"Results"`
RelatedTopics []ddgTopic `json:"RelatedTopics"`
}
@@ -53,7 +82,7 @@ type ddgTopic struct {
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
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,
Parameters: map[string]interface{}{
"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) {
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))
resp, err := t.client.Get(apiURL)
if err != nil {
@@ -111,11 +205,20 @@ func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{})
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: "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