feat: 第四轮功能增强 - LLM 思维记忆优化、DevTools 记忆UI、9个新工具、5分钟自我思考
- 优化 LLM 思维方式和记忆方法(类别/重要性/关键词/相似度合并/衰减) - DevTools 记忆查询 UI 重新设计(类别筛选/排序/星标/搜索) - 新增 9 个 LLM 工具:calculator, datetime, file_ops, http_request, json_ops, text, random, crypto, markdown - 管理员主对话 5 分钟自我思考增强(工具调用/记忆提取/记忆维护)
This commit is contained in:
@@ -97,6 +97,19 @@ func main() {
|
|||||||
if getEnvBool("ENABLE_TOOLS", true) {
|
if getEnvBool("ENABLE_TOOLS", true) {
|
||||||
toolRegistry.Register(tools.NewWebFetchTool())
|
toolRegistry.Register(tools.NewWebFetchTool())
|
||||||
toolRegistry.Register(tools.NewWebSearchTool())
|
toolRegistry.Register(tools.NewWebSearchTool())
|
||||||
|
toolRegistry.Register(tools.NewCalculatorTool())
|
||||||
|
toolRegistry.Register(tools.NewDateTimeTool())
|
||||||
|
toolRegistry.Register(tools.NewHTTPTool())
|
||||||
|
toolRegistry.Register(tools.NewJSONTool())
|
||||||
|
toolRegistry.Register(tools.NewTextTool())
|
||||||
|
toolRegistry.Register(tools.NewRandomTool())
|
||||||
|
toolRegistry.Register(tools.NewCryptoTool())
|
||||||
|
toolRegistry.Register(tools.NewMarkdownTool())
|
||||||
|
|
||||||
|
// File tool uses DATA_DIR or defaults to /tmp/cyrene_data
|
||||||
|
dataDir := getEnv("DATA_DIR", "/tmp/cyrene_data")
|
||||||
|
toolRegistry.Register(tools.NewFileTool(dataDir))
|
||||||
|
|
||||||
if iotClient != nil {
|
if iotClient != nil {
|
||||||
toolRegistry.Register(tools.NewIoTQueryTool(iotClient))
|
toolRegistry.Register(tools.NewIoTQueryTool(iotClient))
|
||||||
toolRegistry.Register(tools.NewIoTControlTool(iotClient))
|
toolRegistry.Register(tools.NewIoTControlTool(iotClient))
|
||||||
@@ -104,9 +117,23 @@ func main() {
|
|||||||
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.ListTools()), toolRegistry.ListTools())
|
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.ListTools()), toolRegistry.ListTools())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化后台思考器
|
// 初始化后台思考器(增强版:支持工具调用和记忆管理)
|
||||||
thinkerCfg := background.DefaultThinkerConfig()
|
thinkerCfg := background.DefaultThinkerConfig()
|
||||||
thinker := background.NewThinker(thinkerCfg, personaLoader, memRetriever, llmAdapter, iotClient)
|
adminUserID := "admin_admin"
|
||||||
|
adminSessionID := "admin-session-main"
|
||||||
|
thinker := background.NewThinker(
|
||||||
|
thinkerCfg,
|
||||||
|
personaLoader,
|
||||||
|
memRetriever,
|
||||||
|
llmAdapter,
|
||||||
|
iotClient,
|
||||||
|
memStore,
|
||||||
|
memExtractor,
|
||||||
|
toolRegistry,
|
||||||
|
convStore,
|
||||||
|
adminUserID,
|
||||||
|
adminSessionID,
|
||||||
|
)
|
||||||
thinker.Start()
|
thinker.Start()
|
||||||
defer thinker.Stop()
|
defer thinker.Stop()
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ package background
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
|
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
|
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||||
@@ -23,7 +26,7 @@ type PendingThought struct {
|
|||||||
Consumed bool `json:"consumed"`
|
Consumed bool `json:"consumed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thinker 后台思考器
|
// Thinker 后台思考器(增强版:支持工具调用、记忆管理、5分钟定时循环)
|
||||||
type Thinker struct {
|
type Thinker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
enabled bool
|
enabled bool
|
||||||
@@ -35,6 +38,18 @@ type Thinker struct {
|
|||||||
thinkInterval time.Duration // 两次思考最小间隔
|
thinkInterval time.Duration // 两次思考最小间隔
|
||||||
iotQueryInterval time.Duration // IoT查询最小间隔
|
iotQueryInterval time.Duration // IoT查询最小间隔
|
||||||
|
|
||||||
|
// 新增字段:记忆管理
|
||||||
|
memoryStore *memory.Store // 直接操作记忆(衰减、合并)
|
||||||
|
memoryExtractor *memory.Extractor // 从思考结果中提取记忆
|
||||||
|
|
||||||
|
// 新增字段:工具调用
|
||||||
|
toolRegistry *tools.Registry // 工具注册中心
|
||||||
|
|
||||||
|
// 新增字段:会话上下文
|
||||||
|
convStore *ctxbuild.ConversationStore // 管理员对话历史
|
||||||
|
adminUserID string // 管理员用户 ID
|
||||||
|
adminSessionID string // 管理员主对话 session ID
|
||||||
|
|
||||||
pendingThoughts []*PendingThought
|
pendingThoughts []*PendingThought
|
||||||
|
|
||||||
lastUserMessage time.Time
|
lastUserMessage time.Time
|
||||||
@@ -62,13 +77,19 @@ func DefaultThinkerConfig() ThinkerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewThinker 创建后台思考器
|
// NewThinker 创建增强版后台思考器
|
||||||
func NewThinker(
|
func NewThinker(
|
||||||
cfg ThinkerConfig,
|
cfg ThinkerConfig,
|
||||||
personaLoader *persona.Loader,
|
personaLoader *persona.Loader,
|
||||||
memRetriever *memory.Retriever,
|
memRetriever *memory.Retriever,
|
||||||
llmAdapter *llm.Adapter,
|
llmAdapter *llm.Adapter,
|
||||||
iotClient *tools.IoTClient,
|
iotClient *tools.IoTClient,
|
||||||
|
memoryStore *memory.Store,
|
||||||
|
memoryExtractor *memory.Extractor,
|
||||||
|
toolRegistry *tools.Registry,
|
||||||
|
convStore *ctxbuild.ConversationStore,
|
||||||
|
adminUserID string,
|
||||||
|
adminSessionID string,
|
||||||
) *Thinker {
|
) *Thinker {
|
||||||
return &Thinker{
|
return &Thinker{
|
||||||
enabled: cfg.Enabled,
|
enabled: cfg.Enabled,
|
||||||
@@ -79,13 +100,19 @@ func NewThinker(
|
|||||||
idleTimeout: cfg.IdleTimeout,
|
idleTimeout: cfg.IdleTimeout,
|
||||||
thinkInterval: cfg.ThinkInterval,
|
thinkInterval: cfg.ThinkInterval,
|
||||||
iotQueryInterval: cfg.IoTQueryInterval,
|
iotQueryInterval: cfg.IoTQueryInterval,
|
||||||
|
memoryStore: memoryStore,
|
||||||
|
memoryExtractor: memoryExtractor,
|
||||||
|
toolRegistry: toolRegistry,
|
||||||
|
convStore: convStore,
|
||||||
|
adminUserID: adminUserID,
|
||||||
|
adminSessionID: adminSessionID,
|
||||||
pendingThoughts: make([]*PendingThought, 0),
|
pendingThoughts: make([]*PendingThought, 0),
|
||||||
lastUserMessage: time.Now(),
|
lastUserMessage: time.Now(),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start 启动后台思考循环
|
// Start 启动后台思考循环(5分钟定时器)
|
||||||
func (t *Thinker) Start() {
|
func (t *Thinker) Start() {
|
||||||
if !t.enabled {
|
if !t.enabled {
|
||||||
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
|
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
|
||||||
@@ -94,8 +121,8 @@ func (t *Thinker) Start() {
|
|||||||
|
|
||||||
t.wg.Add(1)
|
t.wg.Add(1)
|
||||||
go t.loop()
|
go t.loop()
|
||||||
log.Printf("[后台思考] 已启动 (闲置超时=%v, 思考间隔=%v, IoT查询间隔=%v)",
|
log.Printf("[后台思考] 已启动 (思考间隔=%v, IoT查询间隔=%v, 管理员=%s)",
|
||||||
t.idleTimeout, t.thinkInterval, t.iotQueryInterval)
|
t.thinkInterval, t.iotQueryInterval, t.adminUserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop 停止后台思考
|
// Stop 停止后台思考
|
||||||
@@ -105,7 +132,7 @@ func (t *Thinker) Stop() {
|
|||||||
log.Println("[后台思考] 已停止")
|
log.Println("[后台思考] 已停止")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordUserMessage 记录用户活动时间
|
// RecordUserMessage 记录用户活动时间(管理员对话时调用)
|
||||||
func (t *Thinker) RecordUserMessage() {
|
func (t *Thinker) RecordUserMessage() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.lastUserMessage = time.Now()
|
t.lastUserMessage = time.Now()
|
||||||
@@ -138,71 +165,96 @@ func (t *Thinker) HasPendingThoughts() bool {
|
|||||||
return len(t.pendingThoughts) > 0
|
return len(t.pendingThoughts) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// loop 后台主循环
|
// loop 后台主循环(5分钟定时器)
|
||||||
func (t *Thinker) loop() {
|
func (t *Thinker) loop() {
|
||||||
defer t.wg.Done()
|
defer t.wg.Done()
|
||||||
|
|
||||||
ticker := time.NewTicker(10 * time.Second) // 每10秒检查一次
|
// 启动后等待 10 秒再执行首次思考(让服务完全就绪)
|
||||||
|
initialDelay := time.NewTimer(10 * time.Second)
|
||||||
|
ticker := time.NewTicker(t.thinkInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// 思考计数器(用于周期性记忆维护)
|
||||||
|
thinkCount := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-t.stopCh:
|
case <-t.stopCh:
|
||||||
|
initialDelay.Stop()
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-initialDelay.C:
|
||||||
t.checkAndThink()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAndThink 检查是否需要触发思考
|
|
||||||
func (t *Thinker) checkAndThink() {
|
|
||||||
t.mu.Lock()
|
|
||||||
|
|
||||||
// 检查空闲时间是否超过阈值
|
|
||||||
idleDuration := time.Since(t.lastUserMessage)
|
|
||||||
if idleDuration < t.idleTimeout {
|
|
||||||
t.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查距离上次思考是否超过最小间隔
|
|
||||||
if time.Since(t.lastThinkTime) < t.thinkInterval {
|
|
||||||
t.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.lastThinkTime = time.Now()
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
// 执行后台思考(不持锁)
|
|
||||||
t.performThink()
|
t.performThink()
|
||||||
|
thinkCount++
|
||||||
|
t.maybeMaintainMemories(thinkCount)
|
||||||
|
case <-ticker.C:
|
||||||
|
t.performThink()
|
||||||
|
thinkCount++
|
||||||
|
t.maybeMaintainMemories(thinkCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// performThink 执行一次后台思考
|
// maybeMaintainMemories 周期性执行记忆维护(每6次思考约30分钟)
|
||||||
func (t *Thinker) performThink() {
|
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
if thinkCount%6 != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 加载人格配置
|
if t.memoryStore != nil && t.memoryStore.IsReady() {
|
||||||
|
// 衰减旧记忆
|
||||||
|
if err := t.memoryStore.DecayMemories(ctx, t.adminUserID); err != nil {
|
||||||
|
log.Printf("[后台思考] 记忆衰减失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并相似记忆
|
||||||
|
if err := t.memoryStore.ConsolidateMemories(ctx, t.adminUserID); err != nil {
|
||||||
|
log.Printf("[后台思考] 记忆合并失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
|
||||||
|
func (t *Thinker) performThink() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Println("[后台思考] 开始执行思考周期...")
|
||||||
|
|
||||||
|
// 1. 加载人格配置
|
||||||
personaConfig, err := t.personaLoader.Get("cyrene")
|
personaConfig, err := t.personaLoader.Get("cyrene")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[后台思考] 加载人格失败: %v", err)
|
log.Printf("[后台思考] 加载人格失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检索最近的记忆
|
// 2. 检索相关记忆
|
||||||
var memories []memory.MemoryEntry
|
var memories []memory.MemoryEntry
|
||||||
if t.memRetriever != nil {
|
if t.memRetriever != nil {
|
||||||
memories, err = t.memRetriever.Retrieve(ctx, "system", "最近发生了什么 重要的事情")
|
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[后台思考] 记忆检索失败: %v", err)
|
log.Printf("[后台思考] 记忆检索失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询 IoT 设备状态(节制)
|
// 3. 获取管理员对话历史
|
||||||
|
var convHistory []model.LLMMessage
|
||||||
|
if t.convStore != nil && t.adminSessionID != "" {
|
||||||
|
convHistory = t.convStore.GetHistory(t.adminSessionID, 30)
|
||||||
|
if len(convHistory) > 0 {
|
||||||
|
log.Printf("[后台思考] 加载管理员对话历史 %d 条", len(convHistory))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 查询 IoT 设备状态(节制)
|
||||||
var deviceSummary string
|
var deviceSummary string
|
||||||
if t.iotClient != nil && time.Since(t.lastIoTQuery) >= t.iotQueryInterval {
|
if t.iotClient != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
canQuery := time.Since(t.lastIoTQuery) >= t.iotQueryInterval
|
||||||
|
t.mu.Unlock()
|
||||||
|
if canQuery {
|
||||||
devices := t.iotClient.GetDevicesForContext()
|
devices := t.iotClient.GetDevicesForContext()
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
deviceSummary = formatDeviceContext(devices)
|
deviceSummary = formatDeviceContext(devices)
|
||||||
@@ -211,64 +263,253 @@ func (t *Thinker) performThink() {
|
|||||||
t.lastIoTQuery = time.Now()
|
t.lastIoTQuery = time.Now()
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建思考提示
|
|
||||||
systemPrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
|
|
||||||
|
|
||||||
memoryContext := ""
|
|
||||||
if len(memories) > 0 {
|
|
||||||
memoryContext = "【最近的记忆】\n"
|
|
||||||
for _, m := range memories {
|
|
||||||
if len(memoryContext)+len(m.Content) > 500 {
|
|
||||||
break // 限制记忆上下文长度
|
|
||||||
}
|
|
||||||
memoryContext += fmt.Sprintf("- %s\n", m.Content)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userPrompt := "昔涟,现在是你的后台思考时间。开拓者暂时没有说话。"
|
// 5. 构建思考提示词
|
||||||
userPrompt += "\n请你基于以下信息进行简短思考:你注意到了什么?有什么想对开拓者说的吗?"
|
systemPrompt := t.buildThinkingSystemPrompt(personaConfig)
|
||||||
userPrompt += "\n注意:这是内部思考,不是直接对话,请以第三人称或自省的方式思考。"
|
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary)
|
||||||
|
|
||||||
if memoryContext != "" {
|
|
||||||
userPrompt += "\n\n" + memoryContext
|
|
||||||
}
|
|
||||||
if deviceSummary != "" {
|
|
||||||
userPrompt += "\n\n" + deviceSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 LLM
|
|
||||||
messages := []model.LLMMessage{
|
messages := []model.LLMMessage{
|
||||||
{Role: model.RoleSystem, Content: systemPrompt},
|
{Role: model.RoleSystem, Content: systemPrompt},
|
||||||
{Role: model.RoleUser, Content: userPrompt},
|
{Role: model.RoleUser, Content: userPrompt},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := t.llmAdapter.Chat(ctx, messages)
|
// 6. 准备工具定义
|
||||||
|
openAITools := t.buildOpenAITools()
|
||||||
|
|
||||||
|
// 7. 调用 LLM(支持工具调用,最多 3 轮)
|
||||||
|
maxToolRounds := 3
|
||||||
|
var finalContent string
|
||||||
|
var totalToolCalls int
|
||||||
|
|
||||||
|
for round := 0; round <= maxToolRounds; round++ {
|
||||||
|
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[后台思考] LLM调用失败: %v", err)
|
log.Printf("[后台思考] LLM调用失败 (round=%d): %v", round, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Content == "" {
|
// 如果 LLM 没有请求工具调用,这就是最终回复
|
||||||
return
|
if len(resp.ToolCalls) == 0 {
|
||||||
|
finalContent = resp.Content
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储思考结果
|
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
|
||||||
t.mu.Lock()
|
|
||||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
// 将助手消息(含工具调用)加入上下文
|
||||||
|
assistantMsg := model.LLMMessage{
|
||||||
|
Role: model.RoleAssistant,
|
||||||
Content: resp.Content,
|
Content: resp.Content,
|
||||||
|
ToolCalls: resp.ToolCalls,
|
||||||
|
ReasoningContent: resp.ReasoningContent,
|
||||||
|
}
|
||||||
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
|
// 执行每个工具调用
|
||||||
|
for _, tc := range resp.ToolCalls {
|
||||||
|
var args map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
|
||||||
|
log.Printf("[后台思考] 工具 %s 参数解析失败: %v", tc.Name, err)
|
||||||
|
args = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := t.toolRegistry.Execute(ctx, tc.Name, args)
|
||||||
|
if execErr != nil {
|
||||||
|
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultJSON, _ := json.Marshal(result)
|
||||||
|
messages = append(messages, model.LLMMessage{
|
||||||
|
Role: model.RoleTool,
|
||||||
|
Content: string(resultJSON),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
totalToolCalls++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一轮:即使有 tool_calls 也强制停止
|
||||||
|
if round == maxToolRounds {
|
||||||
|
// 再做一次不带工具的调用获取最终总结
|
||||||
|
finalResp, finalErr := t.llmAdapter.Chat(ctx, messages)
|
||||||
|
if finalErr != nil {
|
||||||
|
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
|
||||||
|
finalContent = resp.Content
|
||||||
|
} else {
|
||||||
|
finalContent = finalResp.Content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalContent == "" {
|
||||||
|
log.Println("[后台思考] 未获得有效思考内容,跳过")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 存储思考结果
|
||||||
|
t.storeThought(finalContent)
|
||||||
|
|
||||||
|
log.Printf("[后台思考] 完成 (内容长度=%d, 工具调用=%d次)", len(finalContent), totalToolCalls)
|
||||||
|
|
||||||
|
// 9. 从思考结果中提取记忆(异步)
|
||||||
|
if t.memoryExtractor != nil {
|
||||||
|
go t.extractMemoriesFromThinking(finalContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildThinkingSystemPrompt 构建思考用的系统提示词
|
||||||
|
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig) string {
|
||||||
|
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
|
||||||
|
|
||||||
|
thinkingInstructions := `
|
||||||
|
|
||||||
|
【后台思考模式】
|
||||||
|
|
||||||
|
你正在进行定期的自我反思。开拓者暂时没有说话,这是你主动思考和整理信息的时间。
|
||||||
|
|
||||||
|
请完成以下内省任务:
|
||||||
|
|
||||||
|
1. **回顾最近的对话**:总结开拓者表达了什么、情绪如何、有什么新信息值得关注
|
||||||
|
2. **评估现有记忆**:基于记忆检索结果,判断哪些记忆仍然相关、哪些可能需要更新
|
||||||
|
3. **识别重要信息**:是否有值得保存的用户偏好、个人信息、计划任务、关系信息等
|
||||||
|
4. **使用工具获取信息**:如果需要了解当前时间、搜索实时信息等,请使用可用工具
|
||||||
|
5. **记忆操作建议**:判断是否需要创建新记忆、更新旧记忆或合并重复记忆
|
||||||
|
|
||||||
|
完成反思后,请输出结构化的思考总结,包含:
|
||||||
|
- **关键洞察**:从最近对话中提炼的核心发现
|
||||||
|
- **记忆更新建议**:需要创建/更新/合并的记忆条目
|
||||||
|
- **下次关注事项**:下次思考时需要跟进的话题或任务
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 这是内部思考,不是直接与开拓者对话
|
||||||
|
- 请以自省和观察的方式思考,不要用"你"来称呼开拓者
|
||||||
|
- 有机会就使用工具获取实时信息(如当前时间)
|
||||||
|
- 思考要简洁有深度,不需要太长`
|
||||||
|
|
||||||
|
return basePrompt + thinkingInstructions
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildThinkingUserPrompt 构建思考用的用户提示词
|
||||||
|
func (t *Thinker) buildThinkingUserPrompt(
|
||||||
|
memories []memory.MemoryEntry,
|
||||||
|
convHistory []model.LLMMessage,
|
||||||
|
deviceSummary string,
|
||||||
|
) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("现在是你的后台思考时间。请基于以下信息进行深度反思。\n")
|
||||||
|
|
||||||
|
// 对话历史
|
||||||
|
if len(convHistory) > 0 {
|
||||||
|
sb.WriteString("\n【最近的对话历史】\n")
|
||||||
|
msgCount := 0
|
||||||
|
for _, msg := range convHistory {
|
||||||
|
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
|
||||||
|
roleLabel := "开拓者"
|
||||||
|
if msg.Role == model.RoleAssistant {
|
||||||
|
roleLabel = "昔涟"
|
||||||
|
}
|
||||||
|
content := msg.Content
|
||||||
|
runes := []rune(content)
|
||||||
|
if len(runes) > 200 {
|
||||||
|
content = string(runes[:200]) + "…"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("[%s]: %s\n", roleLabel, content))
|
||||||
|
msgCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msgCount == 0 {
|
||||||
|
sb.WriteString("(暂无对话历史)\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n【最近的对话历史】\n(暂无对话历史,这是首次思考或对话历史为空)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 现有记忆
|
||||||
|
if len(memories) > 0 {
|
||||||
|
sb.WriteString("\n【现有相关记忆】\n")
|
||||||
|
for i, m := range memories {
|
||||||
|
if i >= 15 {
|
||||||
|
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆未列出\n", len(memories)-15))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- [%s|重要度%d] %s\n",
|
||||||
|
m.Category.DisplayName(), m.Importance, m.Content))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n【现有相关记忆】\n(暂无相关记忆)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IoT 设备状态
|
||||||
|
if deviceSummary != "" {
|
||||||
|
sb.WriteString("\n" + deviceSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n请开始你的后台思考。如果需要获取当前时间或搜索信息,请使用可用工具。")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
|
||||||
|
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
||||||
|
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defs := t.toolRegistry.GetDefinitions()
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]llm.OpenAITool, 0, len(defs))
|
||||||
|
for _, d := range defs {
|
||||||
|
result = append(result, llm.OpenAITool{
|
||||||
|
Type: "function",
|
||||||
|
Function: llm.OpenAIToolFunc{
|
||||||
|
Name: d.Name,
|
||||||
|
Description: d.Description,
|
||||||
|
Parameters: d.Parameters,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeThought 存储思考结果到待推送队列
|
||||||
|
func (t *Thinker) storeThought(content string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||||
|
Content: content,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Consumed: false,
|
Consumed: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 只保留最近5条
|
// 只保留最近 10 条
|
||||||
if len(t.pendingThoughts) > 5 {
|
if len(t.pendingThoughts) > 10 {
|
||||||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-5:]
|
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
||||||
}
|
}
|
||||||
count := len(t.pendingThoughts)
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
log.Printf("[后台思考] 完成 (当前累积 %d 条待推送思考)", count)
|
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
|
||||||
|
func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Println("[后台思考] 开始从思考结果中提取记忆...")
|
||||||
|
|
||||||
|
// 使用 memoryExtractor.ExtractAndStore 提取记忆
|
||||||
|
// 将思考内容作为"昔涟的自省"传递给提取器
|
||||||
|
t.memoryExtractor.ExtractAndStore(
|
||||||
|
ctx,
|
||||||
|
t.adminUserID,
|
||||||
|
t.adminSessionID,
|
||||||
|
"【系统触发】后台思考时间 — 昔涟进行了自我反思,以下是她的思考内容",
|
||||||
|
thinkingContent,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDeviceContext 格式化设备状态为文本
|
// formatDeviceContext 格式化设备状态为文本
|
||||||
|
|||||||
@@ -125,12 +125,63 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
|
|||||||
Content: systemPrompt,
|
Content: systemPrompt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. 记忆注入 —— 相关记忆以系统消息形式注入
|
// 2. 记忆注入 —— 相关记忆以系统消息形式注入,按重要性排序并分类标注
|
||||||
if len(params.Memories) > 0 {
|
if len(params.Memories) > 0 {
|
||||||
memoryPrompt := "【以下是关于开拓者的一些重要记忆,请在合适的时机自然地提及】\n"
|
// 按 Importance 排序
|
||||||
for _, m := range params.Memories {
|
sortedMems := make([]memory.MemoryEntry, len(params.Memories))
|
||||||
memoryPrompt += fmt.Sprintf("- %s\n", m.Content)
|
copy(sortedMems, params.Memories)
|
||||||
|
sortMemoriesByImportance(sortedMems)
|
||||||
|
|
||||||
|
// 分离核心记忆和最近记忆
|
||||||
|
var coreMems, recentMems, otherMems []memory.MemoryEntry
|
||||||
|
for _, m := range sortedMems {
|
||||||
|
if m.Importance >= 8 {
|
||||||
|
coreMems = append(coreMems, m)
|
||||||
|
} else if m.Importance >= 5 {
|
||||||
|
recentMems = append(recentMems, m)
|
||||||
|
} else {
|
||||||
|
otherMems = append(otherMems, m)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制每类记忆数量
|
||||||
|
if len(coreMems) > 5 {
|
||||||
|
coreMems = coreMems[:5]
|
||||||
|
}
|
||||||
|
if len(recentMems) > 8 {
|
||||||
|
recentMems = recentMems[:8]
|
||||||
|
}
|
||||||
|
if len(otherMems) > 3 {
|
||||||
|
otherMems = otherMems[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
var memoryPrompt string
|
||||||
|
memoryPrompt += "【以下是关于开拓者的重要记忆,请在合适的时机自然地提及】\n\n"
|
||||||
|
|
||||||
|
if len(coreMems) > 0 {
|
||||||
|
memoryPrompt += "★ 核心记忆(非常重要,务必优先参考):\n"
|
||||||
|
for _, m := range coreMems {
|
||||||
|
memoryPrompt += formatMemoryLine(m)
|
||||||
|
}
|
||||||
|
memoryPrompt += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recentMems) > 0 {
|
||||||
|
memoryPrompt += "● 常用记忆:\n"
|
||||||
|
for _, m := range recentMems {
|
||||||
|
memoryPrompt += formatMemoryLine(m)
|
||||||
|
}
|
||||||
|
memoryPrompt += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(otherMems) > 0 {
|
||||||
|
memoryPrompt += "○ 其他记忆:\n"
|
||||||
|
for _, m := range otherMems {
|
||||||
|
memoryPrompt += formatMemoryLine(m)
|
||||||
|
}
|
||||||
|
memoryPrompt += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
messages = append(messages, model.LLMMessage{
|
messages = append(messages, model.LLMMessage{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: memoryPrompt,
|
Content: memoryPrompt,
|
||||||
@@ -248,3 +299,32 @@ func acModeLabel(mode string) string {
|
|||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortMemoriesByImportance 按 Importance 降序排列记忆
|
||||||
|
func sortMemoriesByImportance(mems []memory.MemoryEntry) {
|
||||||
|
for i := 0; i < len(mems); i++ {
|
||||||
|
for j := i + 1; j < len(mems); j++ {
|
||||||
|
if mems[j].Importance > mems[i].Importance ||
|
||||||
|
(mems[j].Importance == mems[i].Importance && mems[j].Priority > mems[i].Priority) {
|
||||||
|
mems[i], mems[j] = mems[j], mems[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMemoryLine 格式化单条记忆为展示行
|
||||||
|
func formatMemoryLine(m model.MemoryEntry) string {
|
||||||
|
content := m.Content
|
||||||
|
runes := []rune(content)
|
||||||
|
if len(runes) > 80 {
|
||||||
|
content = string(runes[:80]) + "…"
|
||||||
|
}
|
||||||
|
stars := ""
|
||||||
|
for i := 0; i < m.Importance/2; i++ {
|
||||||
|
stars += "★"
|
||||||
|
}
|
||||||
|
if m.Importance%2 != 0 {
|
||||||
|
stars += "☆"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("- [%s%s] %s\n", m.Category.DisplayName(), stars, content)
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,12 +38,21 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
|
|||||||
for _, mem := range memories {
|
for _, mem := range memories {
|
||||||
mem.UserID = userID
|
mem.UserID = userID
|
||||||
mem.SessionID = sessionID
|
mem.SessionID = sessionID
|
||||||
|
mem.Source = "conversation"
|
||||||
|
|
||||||
|
// 去重检查:查询用户已有的相关记忆
|
||||||
|
existing, err := e.findSimilar(ctx, userID, &mem)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
// 相似度 > 80%,更新现有记忆
|
||||||
|
e.mergeMemory(ctx, existing, &mem)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if err := e.store.Save(ctx, &mem); err != nil {
|
if err := e.store.Save(ctx, &mem); err != nil {
|
||||||
log.Printf("[memory] 记忆保存失败: %v", err)
|
log.Printf("[memory] 记忆保存失败: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("[memory] 新记忆已保存 [%s]: %s", mem.Category, mem.Summary)
|
log.Printf("[memory] 新记忆已保存 [%s|%d★]: %s", mem.Category, mem.Importance, mem.Summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +68,17 @@ func (e *Extractor) extract(ctx context.Context, userMessage, assistantResponse
|
|||||||
|
|
||||||
// MemoryExtractionResult LLM提取结果的结构
|
// MemoryExtractionResult LLM提取结果的结构
|
||||||
type MemoryExtractionResult struct {
|
type MemoryExtractionResult struct {
|
||||||
Memories []struct {
|
Memories []ExtractedMemory `json:"memories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractedMemory LLM提取的原始记忆条目
|
||||||
|
type ExtractedMemory struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
} `json:"memories"`
|
Importance int `json:"importance"` // 重要程度 1-10
|
||||||
|
Keywords []string `json:"keywords"` // 关键词标签
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractWithLLM 使用LLM提取记忆
|
// extractWithLLM 使用LLM提取记忆
|
||||||
@@ -74,20 +88,40 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
|
|||||||
用户消息: %s
|
用户消息: %s
|
||||||
昔涟回复: %s
|
昔涟回复: %s
|
||||||
|
|
||||||
请以JSON格式返回提取的记忆。每条记忆需要包含:
|
请以JSON格式返回提取的记忆。每条记忆需要包含以下字段:
|
||||||
- content: 完整的记忆内容(一句话描述)
|
- content: 完整的记忆内容(一句话描述,客观准确)
|
||||||
- summary: 简短摘要(10字以内)
|
- summary: 简短摘要(10字以内)
|
||||||
- category: 分类 (preference/fact/event/relationship/habit/other)
|
- category: 记忆分类,必须是以下之一:
|
||||||
|
* user_preference: 用户偏好(食物、颜色、习惯、爱好)
|
||||||
|
* personal_info: 个人信息(姓名、年龄、职业、住址)
|
||||||
|
* conversation: 对话摘要(值得记住的对话主题)
|
||||||
|
* knowledge: 知识性信息(用户分享的知识或观点)
|
||||||
|
* event: 事件记录(发生了什么事)
|
||||||
|
* task: 任务/计划(用户的计划、待办事项)
|
||||||
|
* relationship: 关系信息(用户与他人的关系)
|
||||||
- priority: 优先级 (0=临时, 1=普通, 2=重要, 3=核心)
|
- priority: 优先级 (0=临时, 1=普通, 2=重要, 3=核心)
|
||||||
|
- importance: 重要程度 1-10(评估这条信息对了解用户有多重要)
|
||||||
|
* 1-3: 琐碎信息,可能很快过时
|
||||||
|
* 4-6: 一般有用,值得记住
|
||||||
|
* 7-8: 重要信息,长期有用
|
||||||
|
* 9-10: 核心信息,对理解用户至关重要
|
||||||
|
- keywords: 关键词标签数组(3-5个词,用于检索和匹配)
|
||||||
|
|
||||||
|
重要性评估指南:
|
||||||
|
- 用户明确表达的偏好(喜欢/讨厌)→ importance 7-8
|
||||||
|
- 用户的基本个人信息(姓名/生日)→ importance 9-10
|
||||||
|
- 日常闲聊主题 → importance 2-3
|
||||||
|
- 用户提到的计划/任务 → importance 5-7
|
||||||
|
- 用户的情感状态 → importance 5-6
|
||||||
|
|
||||||
只提取有意义的信息,不要提取无意义的闲聊。如果没有值得记住的内容,返回空数组。
|
只提取有意义的信息,不要提取无意义的闲聊。如果没有值得记住的内容,返回空数组。
|
||||||
|
|
||||||
输出格式:
|
输出格式:
|
||||||
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1}]}
|
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1, "importance": 6, "keywords": ["词1", "词2"]}]}
|
||||||
`, userMessage, assistantResponse)
|
`, userMessage, assistantResponse)
|
||||||
|
|
||||||
resp, err := e.llmChat(ctx, []model.LLMMessage{
|
resp, err := e.llmChat(ctx, []model.LLMMessage{
|
||||||
{Role: "system", Content: "你是一个记忆提取助手。你只输出JSON格式的结果,不输出其他内容。"},
|
{Role: "system", Content: "你是一个记忆提取助手。你只输出JSON格式的结果,不输出其他内容。你的任务是评估对话中关于用户的信息,提取值得记住的内容,并为其打分。"},
|
||||||
{Role: "user", Content: prompt},
|
{Role: "user", Content: prompt},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,26 +132,41 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
|
|||||||
result := MemoryExtractionResult{}
|
result := MemoryExtractionResult{}
|
||||||
content := extractJSON(resp.Content)
|
content := extractJSON(resp.Content)
|
||||||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||||||
// 尝试作为数组解析
|
// 尝试作为数组解析(兼容旧格式)
|
||||||
var arrResult []struct {
|
var arrResult []ExtractedMemory
|
||||||
Content string `json:"content"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Priority int `json:"priority"`
|
|
||||||
}
|
|
||||||
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[:min(len(content), 100)])
|
return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:minint(len(content), 100)])
|
||||||
}
|
}
|
||||||
result.Memories = arrResult
|
result.Memories = arrResult
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries []model.MemoryEntry
|
var entries []model.MemoryEntry
|
||||||
for _, m := range result.Memories {
|
for _, m := range result.Memories {
|
||||||
|
cat := model.MemoryCategory(m.Category)
|
||||||
|
if cat == "" {
|
||||||
|
cat = model.CategoryKnowledge
|
||||||
|
}
|
||||||
|
|
||||||
|
pri := model.MemoryPriority(m.Priority)
|
||||||
|
if pri < 0 || pri > 3 {
|
||||||
|
pri = model.MemoryNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
imp := m.Importance
|
||||||
|
if imp < 1 {
|
||||||
|
imp = 5
|
||||||
|
}
|
||||||
|
if imp > 10 {
|
||||||
|
imp = 10
|
||||||
|
}
|
||||||
|
|
||||||
entries = append(entries, model.MemoryEntry{
|
entries = append(entries, model.MemoryEntry{
|
||||||
Content: m.Content,
|
Content: m.Content,
|
||||||
Summary: m.Summary,
|
Summary: m.Summary,
|
||||||
Category: model.MemoryCategory(m.Category),
|
Category: cat,
|
||||||
Priority: model.MemoryPriority(m.Priority),
|
Priority: pri,
|
||||||
|
Importance: imp,
|
||||||
|
Keywords: m.Keywords,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,35 +177,45 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
|
|||||||
func (e *Extractor) extractWithRules(userMessage, _ string) []model.MemoryEntry {
|
func (e *Extractor) extractWithRules(userMessage, _ string) []model.MemoryEntry {
|
||||||
var entries []model.MemoryEntry
|
var entries []model.MemoryEntry
|
||||||
|
|
||||||
// 规则1: 检测用户偏好表达
|
// 规则: 检测用户偏好表达 - 使用新的分类体系
|
||||||
prefPatterns := map[string]string{
|
prefPatterns := map[string]struct {
|
||||||
"喜欢": "preference",
|
category model.MemoryCategory
|
||||||
"爱": "preference",
|
importance int
|
||||||
"最喜欢": "preference",
|
}{
|
||||||
"讨厌": "preference",
|
"喜欢": {model.CategoryUserPreference, 7},
|
||||||
"不喜欢": "preference",
|
"爱": {model.CategoryUserPreference, 8},
|
||||||
"经常": "habit",
|
"最喜欢": {model.CategoryUserPreference, 9},
|
||||||
"每天都": "habit",
|
"讨厌": {model.CategoryUserPreference, 8},
|
||||||
"一直": "habit",
|
"不喜欢": {model.CategoryUserPreference, 7},
|
||||||
"我叫": "fact",
|
"经常": {model.CategoryUserPreference, 6},
|
||||||
"我是": "fact",
|
"每天都": {model.CategoryUserPreference, 6},
|
||||||
"我家": "fact",
|
"一直": {model.CategoryUserPreference, 5},
|
||||||
"住在": "fact",
|
"我叫": {model.CategoryPersonalInfo, 9},
|
||||||
"生日": "fact",
|
"我是": {model.CategoryPersonalInfo, 8},
|
||||||
|
"我家": {model.CategoryPersonalInfo, 7},
|
||||||
|
"住在": {model.CategoryPersonalInfo, 8},
|
||||||
|
"生日": {model.CategoryPersonalInfo, 10},
|
||||||
|
"计划": {model.CategoryTask, 6},
|
||||||
|
"打算": {model.CategoryTask, 6},
|
||||||
|
"去了": {model.CategoryEvent, 4},
|
||||||
|
"发生": {model.CategoryEvent, 4},
|
||||||
}
|
}
|
||||||
|
|
||||||
for pattern, category := range prefPatterns {
|
for pattern, info := range prefPatterns {
|
||||||
if idx := strings.Index(userMessage, pattern); idx != -1 {
|
if idx := strings.Index(userMessage, pattern); idx != -1 {
|
||||||
// 提取包含关键词的句子片段
|
// 提取包含关键词的句子片段
|
||||||
start := max(0, idx-5)
|
start := maxint(0, idx-5)
|
||||||
end := min(len([]rune(userMessage)), idx+len([]rune(pattern))+15)
|
runes := []rune(userMessage)
|
||||||
content := strings.TrimSpace(string([]rune(userMessage)[start:end]))
|
end := minint(len(runes), idx+len([]rune(pattern))+15)
|
||||||
|
content := strings.TrimSpace(string(runes[start:end]))
|
||||||
|
|
||||||
entries = append(entries, model.MemoryEntry{
|
entries = append(entries, model.MemoryEntry{
|
||||||
Content: content,
|
Content: content,
|
||||||
Summary: truncateString(content, 20),
|
Summary: truncateString(content, 20),
|
||||||
Category: model.MemoryCategory(category),
|
Category: info.category,
|
||||||
Priority: model.MemoryNormal,
|
Priority: model.MemoryNormal,
|
||||||
|
Importance: info.importance,
|
||||||
|
Keywords: []string{pattern},
|
||||||
})
|
})
|
||||||
break // 每条消息最多提取一条规则记忆
|
break // 每条消息最多提取一条规则记忆
|
||||||
}
|
}
|
||||||
@@ -165,6 +224,70 @@ func (e *Extractor) extractWithRules(userMessage, _ string) []model.MemoryEntry
|
|||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findSimilar 查找与给定记忆相似的已有记忆
|
||||||
|
func (e *Extractor) findSimilar(ctx context.Context, userID string, newMem *model.MemoryEntry) (*model.MemoryEntry, error) {
|
||||||
|
existing, err := e.store.Query(ctx, model.MemoryQuery{
|
||||||
|
UserID: userID,
|
||||||
|
Limit: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range existing {
|
||||||
|
score := existing[i].SimilarityScore(newMem)
|
||||||
|
if score >= deDupThreshold {
|
||||||
|
return &existing[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeMemory 合并新记忆到已有记忆
|
||||||
|
func (e *Extractor) mergeMemory(ctx context.Context, existing *model.MemoryEntry, newMem *model.MemoryEntry) {
|
||||||
|
// 更新内容(如果新内容更有价值)
|
||||||
|
if newMem.Importance > existing.Importance || len(newMem.Content) > len(existing.Content) {
|
||||||
|
existing.Content = newMem.Content
|
||||||
|
existing.Summary = newMem.Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并关键词
|
||||||
|
keywordSet := make(map[string]bool)
|
||||||
|
for _, k := range existing.Keywords {
|
||||||
|
keywordSet[k] = true
|
||||||
|
}
|
||||||
|
for _, k := range newMem.Keywords {
|
||||||
|
keywordSet[k] = true
|
||||||
|
}
|
||||||
|
mergedKeywords := make([]string, 0, len(keywordSet))
|
||||||
|
for k := range keywordSet {
|
||||||
|
mergedKeywords = append(mergedKeywords, k)
|
||||||
|
}
|
||||||
|
existing.Keywords = mergedKeywords
|
||||||
|
|
||||||
|
// 取最高重要性
|
||||||
|
if newMem.Importance > existing.Importance {
|
||||||
|
existing.Importance = newMem.Importance
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取最高优先级
|
||||||
|
if newMem.Priority > existing.Priority {
|
||||||
|
existing.Priority = newMem.Priority
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加访问计数(因为又被"想起"了)
|
||||||
|
existing.AccessCount++
|
||||||
|
|
||||||
|
if err := e.store.Update(ctx, existing); err != nil {
|
||||||
|
log.Printf("[memory] 合并记忆更新失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[memory] 合并记忆 [%s|%d★]: %s (相似度 > %.0f%%)",
|
||||||
|
existing.Category, existing.Importance, existing.Summary, deDupThreshold*100)
|
||||||
|
}
|
||||||
|
|
||||||
// extractJSON 从LLM回复中提取JSON内容
|
// extractJSON 从LLM回复中提取JSON内容
|
||||||
func extractJSON(text string) string {
|
func extractJSON(text string) string {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
@@ -191,14 +314,14 @@ func truncateString(s string, maxLen int) string {
|
|||||||
return string(runes[:maxLen]) + "..."
|
return string(runes[:maxLen]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func minint(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func max(a, b int) int {
|
func maxint(a, b int) int {
|
||||||
if a > b {
|
if a > b {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func NewRetriever(store *Store, embedder Embedder) *Retriever {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve 检索与查询相关的记忆
|
// Retrieve 检索与查询相关的记忆
|
||||||
// 策略: 向量相似度 + 关键词匹配混合
|
// 策略: 向量相似度 + 关键词匹配混合 → 按重要性降序返回
|
||||||
func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
|
func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
|
||||||
var allEntries []MemoryEntry
|
var allEntries []MemoryEntry
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
@@ -63,7 +63,7 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
|
|||||||
// 1. 向量相似度检索
|
// 1. 向量相似度检索
|
||||||
embedding, err := r.embedder.Embed(ctx, query)
|
embedding, err := r.embedder.Embed(ctx, query)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vecEntries, err := r.store.SearchByVector(ctx, userID, embedding, 5)
|
vecEntries, err := r.store.SearchByVector(ctx, userID, embedding, 8)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, e := range vecEntries {
|
for _, e := range vecEntries {
|
||||||
if !seen[e.ID] {
|
if !seen[e.ID] {
|
||||||
@@ -74,7 +74,7 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 关键词匹配检索(核心/重要记忆优先)
|
// 2. 关键词匹配检索(包含关键词标签匹配)
|
||||||
keywordEntries, err := r.keywordSearch(ctx, userID, query)
|
keywordEntries, err := r.keywordSearch(ctx, userID, query)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, e := range keywordEntries {
|
for _, e := range keywordEntries {
|
||||||
@@ -90,13 +90,19 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
|
|||||||
recentEntries, err := r.store.Query(ctx, model.MemoryQuery{
|
recentEntries, err := r.store.Query(ctx, model.MemoryQuery{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Priority: model.MemoryImportant,
|
Priority: model.MemoryImportant,
|
||||||
Limit: 3,
|
Limit: 5,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
allEntries = recentEntries
|
allEntries = recentEntries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 去重合并:对高度相似的记忆只保留Importance更高的
|
||||||
|
allEntries = r.deduplicate(allEntries)
|
||||||
|
|
||||||
|
// 5. 按重要性降序排列
|
||||||
|
sortByImportance(allEntries)
|
||||||
|
|
||||||
// 限制返回数量
|
// 限制返回数量
|
||||||
if len(allEntries) > 10 {
|
if len(allEntries) > 10 {
|
||||||
allEntries = allEntries[:10]
|
allEntries = allEntries[:10]
|
||||||
@@ -105,7 +111,19 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
|
|||||||
return allEntries, nil
|
return allEntries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// keywordSearch 关键词匹配检索
|
// RetrieveByCategory 按分类检索记忆
|
||||||
|
func (r *Retriever) RetrieveByCategory(ctx context.Context, userID string, category model.MemoryCategory, limit int) ([]MemoryEntry, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
return r.store.Query(ctx, model.MemoryQuery{
|
||||||
|
UserID: userID,
|
||||||
|
Category: category,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// keywordSearch 关键词匹配检索(包含关键词标签匹配)
|
||||||
func (r *Retriever) keywordSearch(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
|
func (r *Retriever) keywordSearch(ctx context.Context, userID string, query string) ([]MemoryEntry, error) {
|
||||||
// 查询最近的核心和重要记忆
|
// 查询最近的核心和重要记忆
|
||||||
entries, err := r.store.Query(ctx, model.MemoryQuery{
|
entries, err := r.store.Query(ctx, model.MemoryQuery{
|
||||||
@@ -117,15 +135,27 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的关键词匹配过滤
|
// 关键词匹配过滤
|
||||||
var matched []MemoryEntry
|
var matched []MemoryEntry
|
||||||
queryLower := strings.ToLower(query)
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
contentLower := strings.ToLower(entry.Content)
|
contentLower := strings.ToLower(entry.Content)
|
||||||
summaryLower := strings.ToLower(entry.Summary)
|
summaryLower := strings.ToLower(entry.Summary)
|
||||||
|
|
||||||
|
// 内容/摘要匹配
|
||||||
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
|
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
|
||||||
matched = append(matched, entry)
|
matched = append(matched, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词标签匹配
|
||||||
|
for _, kw := range entry.Keywords {
|
||||||
|
if strings.Contains(queryLower, strings.ToLower(kw)) ||
|
||||||
|
strings.Contains(strings.ToLower(kw), queryLower) {
|
||||||
|
matched = append(matched, entry)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +171,14 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
|
|||||||
summaryLower := strings.ToLower(entry.Summary)
|
summaryLower := strings.ToLower(entry.Summary)
|
||||||
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
|
if strings.Contains(contentLower, queryLower) || strings.Contains(summaryLower, queryLower) {
|
||||||
matched = append(matched, entry)
|
matched = append(matched, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, kw := range entry.Keywords {
|
||||||
|
if strings.Contains(queryLower, strings.ToLower(kw)) ||
|
||||||
|
strings.Contains(strings.ToLower(kw), queryLower) {
|
||||||
|
matched = append(matched, entry)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,5 +186,54 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
|
|||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deduplicate 去重合并:对高度相似的记忆只保留 Importance 更高的
|
||||||
|
func (r *Retriever) deduplicate(entries []MemoryEntry) []MemoryEntry {
|
||||||
|
if len(entries) < 2 {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]MemoryEntry, 0, len(entries))
|
||||||
|
discarded := make(map[int]bool)
|
||||||
|
|
||||||
|
for i := 0; i < len(entries); i++ {
|
||||||
|
if discarded[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i + 1; j < len(entries); j++ {
|
||||||
|
if discarded[j] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := entries[i].SimilarityScore(&entries[j])
|
||||||
|
if score >= deDupThreshold {
|
||||||
|
// 保留更重要的那条
|
||||||
|
if entries[j].Importance > entries[i].Importance ||
|
||||||
|
(entries[j].Importance == entries[i].Importance && entries[j].Priority > entries[i].Priority) {
|
||||||
|
discarded[i] = true
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
discarded[j] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !discarded[i] {
|
||||||
|
result = append(result, entries[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortByImportance 按 Importance 降序, Priority 降序排列
|
||||||
|
func sortByImportance(entries []MemoryEntry) {
|
||||||
|
for i := 0; i < len(entries); i++ {
|
||||||
|
for j := i + 1; j < len(entries); j++ {
|
||||||
|
if entries[j].Importance > entries[i].Importance ||
|
||||||
|
(entries[j].Importance == entries[i].Importance && entries[j].Priority > entries[i].Priority) {
|
||||||
|
entries[i], entries[j] = entries[j], entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure fmt is used
|
// Ensure fmt is used
|
||||||
var _ = fmt.Sprintf
|
var _ = fmt.Sprintf
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import (
|
|||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// deDupThreshold 去重相似度阈值
|
||||||
|
const deDupThreshold = 0.75
|
||||||
|
|
||||||
|
// decayThresholdDays 记忆衰减阈值(天)
|
||||||
|
const decayThresholdDays = 30
|
||||||
|
|
||||||
|
// decayLowImportanceMax 衰减时低重要性记忆的最大保留值
|
||||||
|
const decayLowImportanceMax = 1
|
||||||
|
|
||||||
const reconnectInterval = 30 * time.Second
|
const reconnectInterval = 30 * time.Second
|
||||||
|
|
||||||
// Store 记忆持久化存储(PostgreSQL + pgvector)
|
// Store 记忆持久化存储(PostgreSQL + pgvector)
|
||||||
@@ -146,25 +155,32 @@ func (s *Store) migrate() error {
|
|||||||
user_id VARCHAR(64) NOT NULL,
|
user_id VARCHAR(64) NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
summary TEXT DEFAULT '',
|
summary TEXT DEFAULT '',
|
||||||
category VARCHAR(32) DEFAULT 'other',
|
category VARCHAR(32) DEFAULT 'knowledge',
|
||||||
priority INT DEFAULT 1,
|
priority INT DEFAULT 1,
|
||||||
|
importance INT DEFAULT 5,
|
||||||
|
keywords TEXT DEFAULT '[]',
|
||||||
session_id VARCHAR(64) DEFAULT '',
|
session_id VARCHAR(64) DEFAULT '',
|
||||||
source TEXT DEFAULT '',
|
source TEXT DEFAULT 'conversation',
|
||||||
embedding vector(1536),
|
embedding vector(1536),
|
||||||
access_count INT DEFAULT 0,
|
access_count INT DEFAULT 0,
|
||||||
last_access TIMESTAMPTZ DEFAULT NOW(),
|
last_access TIMESTAMPTZ DEFAULT NOW(),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
expires_at TIMESTAMPTZ
|
expires_at TIMESTAMPTZ
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`,
|
`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memories_priority ON memories(priority)`,
|
`CREATE INDEX IF NOT EXISTS idx_memories_priority ON memories(priority)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memories_user_priority ON memories(user_id, priority DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_memories_user_priority ON memories(user_id, priority DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_memories_user_importance ON memories(user_id, importance DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_memories_category_importance ON memories(category, importance DESC)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
if _, err := s.db.Exec(q); err != nil {
|
if _, err := s.db.Exec(q); err != nil {
|
||||||
return fmt.Errorf("执行迁移 '%s' 失败: %w", q[:50], err)
|
return fmt.Errorf("执行迁移 '%s' 失败: %w", q[:min(50, len(q))], err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -177,8 +193,16 @@ func (s *Store) Save(ctx context.Context, entry *model.MemoryEntry) error {
|
|||||||
return errDBNotReady
|
return errDBNotReady
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `INSERT INTO memories (user_id, content, summary, category, priority, session_id, source, embedding, expires_at)
|
// 设置默认值
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
if entry.Source == "" {
|
||||||
|
entry.Source = "conversation"
|
||||||
|
}
|
||||||
|
if entry.Importance == 0 {
|
||||||
|
entry.Importance = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO memories (user_id, content, summary, category, priority, importance, keywords, session_id, source, embedding, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, created_at`
|
RETURNING id, created_at`
|
||||||
|
|
||||||
var embedding interface{}
|
var embedding interface{}
|
||||||
@@ -193,6 +217,7 @@ func (s *Store) Save(ctx context.Context, entry *model.MemoryEntry) error {
|
|||||||
return db.QueryRowContext(ctx, query,
|
return db.QueryRowContext(ctx, query,
|
||||||
entry.UserID, entry.Content, entry.Summary,
|
entry.UserID, entry.Content, entry.Summary,
|
||||||
string(entry.Category), int(entry.Priority),
|
string(entry.Category), int(entry.Priority),
|
||||||
|
entry.Importance, entry.KeywordsJSON(),
|
||||||
entry.SessionID, entry.Source, embedding, entry.ExpiresAt,
|
entry.SessionID, entry.Source, embedding, entry.ExpiresAt,
|
||||||
).Scan(&entry.ID, &entry.CreatedAt)
|
).Scan(&entry.ID, &entry.CreatedAt)
|
||||||
}
|
}
|
||||||
@@ -204,16 +229,17 @@ func (s *Store) GetByID(ctx context.Context, id string) (*model.MemoryEntry, err
|
|||||||
return nil, errDBNotReady
|
return nil, errDBNotReady
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT id, user_id, content, summary, category, priority, session_id, source,
|
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
|
||||||
access_count, last_access, created_at, expires_at
|
session_id, source, access_count, last_access, created_at, updated_at, expires_at
|
||||||
FROM memories WHERE id = $1`
|
FROM memories WHERE id = $1`
|
||||||
|
|
||||||
entry := &model.MemoryEntry{}
|
entry := &model.MemoryEntry{}
|
||||||
var category string
|
var category, keywordsRaw string
|
||||||
err := db.QueryRowContext(ctx, query, id).Scan(
|
err := db.QueryRowContext(ctx, query, id).Scan(
|
||||||
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
|
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
|
||||||
&category, &entry.Priority, &entry.SessionID, &entry.Source,
|
&category, &entry.Priority, &entry.Importance, &keywordsRaw,
|
||||||
&entry.AccessCount, &entry.LastAccess, &entry.CreatedAt, &entry.ExpiresAt,
|
&entry.SessionID, &entry.Source, &entry.AccessCount, &entry.LastAccess,
|
||||||
|
&entry.CreatedAt, &entry.UpdatedAt, &entry.ExpiresAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -222,6 +248,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*model.MemoryEntry, err
|
|||||||
return nil, fmt.Errorf("查询记忆失败: %w", err)
|
return nil, fmt.Errorf("查询记忆失败: %w", err)
|
||||||
}
|
}
|
||||||
entry.Category = model.MemoryCategory(category)
|
entry.Category = model.MemoryCategory(category)
|
||||||
|
entry.Keywords = model.ParseKeywords(keywordsRaw)
|
||||||
|
|
||||||
// 更新访问计数
|
// 更新访问计数
|
||||||
go s.incrementAccess(context.Background(), id)
|
go s.incrementAccess(context.Background(), id)
|
||||||
@@ -240,8 +267,8 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
|
|||||||
q.Limit = 10
|
q.Limit = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT id, user_id, content, summary, category, priority, session_id, source,
|
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
|
||||||
access_count, last_access, created_at, expires_at
|
session_id, source, access_count, last_access, created_at, updated_at, expires_at
|
||||||
FROM memories WHERE user_id = $1`
|
FROM memories WHERE user_id = $1`
|
||||||
args := []interface{}{q.UserID}
|
args := []interface{}{q.UserID}
|
||||||
argIdx := 2
|
argIdx := 2
|
||||||
@@ -258,7 +285,13 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
|
|||||||
argIdx++
|
argIdx++
|
||||||
}
|
}
|
||||||
|
|
||||||
query += fmt.Sprintf(" ORDER BY priority DESC, created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
if q.MinImportance > 0 {
|
||||||
|
query += fmt.Sprintf(" AND importance >= $%d", argIdx)
|
||||||
|
args = append(args, q.MinImportance)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += fmt.Sprintf(" ORDER BY priority DESC, importance DESC, created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||||
args = append(args, q.Limit, q.Offset)
|
args = append(args, q.Limit, q.Offset)
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query, args...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
@@ -267,22 +300,7 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var entries []model.MemoryEntry
|
return scanMemoryRows(rows)
|
||||||
for rows.Next() {
|
|
||||||
var entry model.MemoryEntry
|
|
||||||
var category string
|
|
||||||
if err := rows.Scan(
|
|
||||||
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
|
|
||||||
&category, &entry.Priority, &entry.SessionID, &entry.Source,
|
|
||||||
&entry.AccessCount, &entry.LastAccess, &entry.CreatedAt, &entry.ExpiresAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("扫描记忆行失败: %w", err)
|
|
||||||
}
|
|
||||||
entry.Category = model.MemoryCategory(category)
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除记忆
|
// Delete 删除记忆
|
||||||
@@ -321,8 +339,8 @@ func (s *Store) SearchByVector(ctx context.Context, userID string, embedding []f
|
|||||||
}
|
}
|
||||||
|
|
||||||
vecStr := fmt.Sprintf("[%s]", joinFloats(embedding))
|
vecStr := fmt.Sprintf("[%s]", joinFloats(embedding))
|
||||||
query := `SELECT id, user_id, content, summary, category, priority, session_id, source,
|
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
|
||||||
access_count, last_access, created_at, expires_at,
|
session_id, source, access_count, last_access, created_at, updated_at, expires_at,
|
||||||
1 - (embedding <=> $1) AS similarity
|
1 - (embedding <=> $1) AS similarity
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE user_id = $2 AND embedding IS NOT NULL
|
WHERE user_id = $2 AND embedding IS NOT NULL
|
||||||
@@ -338,23 +356,177 @@ func (s *Store) SearchByVector(ctx context.Context, userID string, embedding []f
|
|||||||
var entries []model.MemoryEntry
|
var entries []model.MemoryEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry model.MemoryEntry
|
var entry model.MemoryEntry
|
||||||
var category string
|
var category, keywordsRaw string
|
||||||
var similarity float64
|
var similarity float64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
|
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
|
||||||
&category, &entry.Priority, &entry.SessionID, &entry.Source,
|
&category, &entry.Priority, &entry.Importance, &keywordsRaw,
|
||||||
&entry.AccessCount, &entry.LastAccess, &entry.CreatedAt, &entry.ExpiresAt,
|
&entry.SessionID, &entry.Source, &entry.AccessCount, &entry.LastAccess,
|
||||||
|
&entry.CreatedAt, &entry.UpdatedAt, &entry.ExpiresAt,
|
||||||
&similarity,
|
&similarity,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("扫描向量搜索结果失败: %w", err)
|
return nil, fmt.Errorf("扫描向量搜索结果失败: %w", err)
|
||||||
}
|
}
|
||||||
entry.Category = model.MemoryCategory(category)
|
entry.Category = model.MemoryCategory(category)
|
||||||
|
entry.Keywords = model.ParseKeywords(keywordsRaw)
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update 更新记忆
|
||||||
|
func (s *Store) Update(ctx context.Context, entry *model.MemoryEntry) error {
|
||||||
|
db := s.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return errDBNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `UPDATE memories SET content = $1, summary = $2, category = $3, priority = $4,
|
||||||
|
importance = $5, keywords = $6, source = $7, updated_at = NOW()
|
||||||
|
WHERE id = $8`
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, query,
|
||||||
|
entry.Content, entry.Summary, string(entry.Category), int(entry.Priority),
|
||||||
|
entry.Importance, entry.KeywordsJSON(), entry.Source, entry.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemoriesByCategory 按分类获取记忆
|
||||||
|
func (s *Store) GetMemoriesByCategory(ctx context.Context, userID string, category model.MemoryCategory) ([]model.MemoryEntry, error) {
|
||||||
|
if !s.IsReady() {
|
||||||
|
return nil, errDBNotReady
|
||||||
|
}
|
||||||
|
return s.Query(ctx, model.MemoryQuery{
|
||||||
|
UserID: userID,
|
||||||
|
Category: category,
|
||||||
|
Limit: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsolidateMemories 记忆整理:合并相似记忆
|
||||||
|
func (s *Store) ConsolidateMemories(ctx context.Context, userID string) error {
|
||||||
|
db := s.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return errDBNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户所有记忆
|
||||||
|
allMems, err := s.Query(ctx, model.MemoryQuery{
|
||||||
|
UserID: userID,
|
||||||
|
Limit: 500,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询记忆失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allMems) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := 0
|
||||||
|
for i := 0; i < len(allMems); i++ {
|
||||||
|
if allMems[i].ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i + 1; j < len(allMems); j++ {
|
||||||
|
if allMems[j].ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := allMems[i].SimilarityScore(&allMems[j])
|
||||||
|
if score >= deDupThreshold {
|
||||||
|
keep, discard := &allMems[i], &allMems[j]
|
||||||
|
if discard.Importance > keep.Importance || discard.Priority > keep.Priority {
|
||||||
|
keep, discard = discard, keep
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并关键词
|
||||||
|
keywordSet := make(map[string]bool)
|
||||||
|
for _, k := range keep.Keywords {
|
||||||
|
keywordSet[k] = true
|
||||||
|
}
|
||||||
|
for _, k := range discard.Keywords {
|
||||||
|
keywordSet[k] = true
|
||||||
|
}
|
||||||
|
mergedKeywords := make([]string, 0, len(keywordSet))
|
||||||
|
for k := range keywordSet {
|
||||||
|
mergedKeywords = append(mergedKeywords, k)
|
||||||
|
}
|
||||||
|
keep.Keywords = mergedKeywords
|
||||||
|
|
||||||
|
if keep.Importance < 10 {
|
||||||
|
keep.Importance++
|
||||||
|
}
|
||||||
|
keep.Source = "consolidated"
|
||||||
|
|
||||||
|
if err := s.Update(ctx, keep); err != nil {
|
||||||
|
log.Printf("[memory] 合并更新记忆 %s 失败: %v", keep.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.Delete(ctx, discard.ID); err != nil {
|
||||||
|
log.Printf("[memory] 合并删除记忆 %s 失败: %v", discard.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
discard.ID = ""
|
||||||
|
merged++
|
||||||
|
log.Printf("[memory] 合并相似记忆: %s <- %s (相似度 %.0f%%)",
|
||||||
|
keep.ID[:min(8, len(keep.ID))], discard.ID[:min(8, len(discard.ID))], score*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if merged > 0 {
|
||||||
|
log.Printf("[memory] 记忆整理完成: 用户 %s 合并 %d 条相似记忆", userID, merged)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecayMemories 记忆衰减:降低长期未访问的低重要性记忆
|
||||||
|
func (s *Store) DecayMemories(ctx context.Context, userID string) error {
|
||||||
|
db := s.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return errDBNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
result1, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE memories SET priority = GREATEST(priority - 1, 0), updated_at = NOW()
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND access_count < 3
|
||||||
|
AND last_access < NOW() - INTERVAL '30 days'
|
||||||
|
AND importance < 3
|
||||||
|
AND priority > 0
|
||||||
|
AND category NOT IN ('personal_info', 'user_preference')
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("衰减低活跃记忆失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decayed1, _ := result1.RowsAffected()
|
||||||
|
|
||||||
|
result2, err := db.ExecContext(ctx, `
|
||||||
|
DELETE FROM memories
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND priority = 0
|
||||||
|
AND access_count = 0
|
||||||
|
AND last_access < NOW() - INTERVAL '14 days'
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("清理临时记忆失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted2, _ := result2.RowsAffected()
|
||||||
|
|
||||||
|
total := decayed1 + deleted2
|
||||||
|
if total > 0 {
|
||||||
|
log.Printf("[memory] 记忆衰减完成: 用户 %s 降级 %d 条, 删除 %d 条过期临时记忆",
|
||||||
|
userID, decayed1, deleted2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) incrementAccess(ctx context.Context, id string) {
|
func (s *Store) incrementAccess(ctx context.Context, id string) {
|
||||||
db := s.getDB()
|
db := s.getDB()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
@@ -375,6 +547,27 @@ func (s *Store) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanMemoryRows 扫描记忆行(通用方法)
|
||||||
|
func scanMemoryRows(rows *sql.Rows) ([]model.MemoryEntry, error) {
|
||||||
|
var entries []model.MemoryEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry model.MemoryEntry
|
||||||
|
var category, keywordsRaw string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&entry.ID, &entry.UserID, &entry.Content, &entry.Summary,
|
||||||
|
&category, &entry.Priority, &entry.Importance, &keywordsRaw,
|
||||||
|
&entry.SessionID, &entry.Source, &entry.AccessCount, &entry.LastAccess,
|
||||||
|
&entry.CreatedAt, &entry.UpdatedAt, &entry.ExpiresAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描记忆行失败: %w", err)
|
||||||
|
}
|
||||||
|
entry.Category = model.MemoryCategory(category)
|
||||||
|
entry.Keywords = model.ParseKeywords(keywordsRaw)
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// joinFloats 将 float64 切片转为逗号分隔字符串
|
// joinFloats 将 float64 切片转为逗号分隔字符串
|
||||||
func joinFloats(vec []float64) string {
|
func joinFloats(vec []float64) string {
|
||||||
if len(vec) == 0 {
|
if len(vec) == 0 {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// MemoryPriority 记忆优先级
|
// MemoryPriority 记忆优先级
|
||||||
type MemoryPriority int
|
type MemoryPriority int
|
||||||
@@ -32,14 +35,43 @@ func (p MemoryPriority) String() string {
|
|||||||
type MemoryCategory string
|
type MemoryCategory string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CategoryPreference MemoryCategory = "preference" // 喜好/偏好
|
CategoryUserPreference MemoryCategory = "user_preference" // 用户偏好 (食物、颜色、习惯)
|
||||||
CategoryFact MemoryCategory = "fact" // 事实信息
|
CategoryPersonalInfo MemoryCategory = "personal_info" // 个人信息 (姓名、年龄、职业)
|
||||||
CategoryEvent MemoryCategory = "event" // 事件/经历
|
CategoryConversation MemoryCategory = "conversation" // 对话摘要
|
||||||
CategoryRelationship MemoryCategory = "relationship" // 关系/情感
|
CategoryKnowledge MemoryCategory = "knowledge" // 知识性信息
|
||||||
CategoryHabit MemoryCategory = "habit" // 习惯
|
CategoryEvent MemoryCategory = "event" // 事件记录
|
||||||
CategoryOther MemoryCategory = "other" // 其他
|
CategoryTask MemoryCategory = "task" // 任务/计划
|
||||||
|
CategoryRelationship MemoryCategory = "relationship" // 关系信息
|
||||||
|
|
||||||
|
// 向后兼容的旧分类别名
|
||||||
|
CategoryPreference = CategoryUserPreference
|
||||||
|
CategoryFact = CategoryPersonalInfo
|
||||||
|
CategoryHabit = CategoryUserPreference
|
||||||
|
CategoryOther = CategoryKnowledge
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CategoryDisplayName 返回分类的中文显示名
|
||||||
|
func (c MemoryCategory) DisplayName() string {
|
||||||
|
switch c {
|
||||||
|
case CategoryUserPreference:
|
||||||
|
return "用户偏好"
|
||||||
|
case CategoryPersonalInfo:
|
||||||
|
return "个人信息"
|
||||||
|
case CategoryConversation:
|
||||||
|
return "对话摘要"
|
||||||
|
case CategoryKnowledge:
|
||||||
|
return "知识信息"
|
||||||
|
case CategoryEvent:
|
||||||
|
return "事件记录"
|
||||||
|
case CategoryTask:
|
||||||
|
return "任务计划"
|
||||||
|
case CategoryRelationship:
|
||||||
|
return "关系情感"
|
||||||
|
default:
|
||||||
|
return "其他"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MemoryEntry 记忆条目
|
// MemoryEntry 记忆条目
|
||||||
type MemoryEntry struct {
|
type MemoryEntry struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
@@ -48,21 +80,122 @@ type MemoryEntry struct {
|
|||||||
Summary string `json:"summary" db:"summary"` // 简短摘要
|
Summary string `json:"summary" db:"summary"` // 简短摘要
|
||||||
Category MemoryCategory `json:"category" db:"category"`
|
Category MemoryCategory `json:"category" db:"category"`
|
||||||
Priority MemoryPriority `json:"priority" db:"priority"`
|
Priority MemoryPriority `json:"priority" db:"priority"`
|
||||||
|
Importance int `json:"importance" db:"importance"` // 重要程度 1-10
|
||||||
|
Keywords []string `json:"keywords" db:"keywords"` // 关键词标签
|
||||||
SessionID string `json:"session_id" db:"session_id"` // 来源会话
|
SessionID string `json:"session_id" db:"session_id"` // 来源会话
|
||||||
Source string `json:"source" db:"source"` // 来源文本片断
|
Source string `json:"source" db:"source"` // 来源 (conversation/thinking)
|
||||||
Embedding []float32 `json:"-" db:"embedding"` // 向量 (pgvector)
|
Embedding []float32 `json:"-" db:"embedding"` // 向量 (pgvector)
|
||||||
AccessCount int `json:"access_count" db:"access_count"`
|
AccessCount int `json:"access_count" db:"access_count"`
|
||||||
LastAccess time.Time `json:"last_access" db:"last_access"`
|
LastAccess time.Time `json:"last_access" db:"last_access"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // 最后更新时间
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` // 临时记忆过期时间
|
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` // 临时记忆过期时间
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KeywordsJSON 将关键词序列化为 JSON 字符串(用于数据库存储)
|
||||||
|
func (e *MemoryEntry) KeywordsJSON() string {
|
||||||
|
if len(e.Keywords) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(e.Keywords)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseKeywords 从 JSON 字符串解析关键词
|
||||||
|
func ParseKeywords(raw string) []string {
|
||||||
|
if raw == "" || raw == "[]" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var keywords []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &keywords); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return keywords
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarityScore 计算两个记忆条目的简单文本相似度(基于词汇重叠)
|
||||||
|
// 返回值 0.0 - 1.0
|
||||||
|
func (e *MemoryEntry) SimilarityScore(other *MemoryEntry) float64 {
|
||||||
|
if e.Content == other.Content {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于关键词的重叠度
|
||||||
|
if len(e.Keywords) > 0 && len(other.Keywords) > 0 {
|
||||||
|
keywordSet := make(map[string]bool, len(e.Keywords))
|
||||||
|
for _, k := range e.Keywords {
|
||||||
|
keywordSet[k] = true
|
||||||
|
}
|
||||||
|
overlap := 0
|
||||||
|
for _, k := range other.Keywords {
|
||||||
|
if keywordSet[k] {
|
||||||
|
overlap++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keywordScore := float64(overlap) / float64(max(len(e.Keywords), len(other.Keywords)))
|
||||||
|
if keywordScore > 0.6 {
|
||||||
|
return keywordScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于内容的字符级 Jaccard 相似度
|
||||||
|
return jaccardSimilarity(e.Content, other.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jaccardSimilarity 计算两个字符串的 Jaccard 相似度
|
||||||
|
func jaccardSimilarity(a, b string) float64 {
|
||||||
|
if a == b {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
if len(a) == 0 || len(b) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 bigram 分词
|
||||||
|
bigramsA := make(map[string]int)
|
||||||
|
runesA := []rune(a)
|
||||||
|
for i := 0; i < len(runesA)-1; i++ {
|
||||||
|
bigramsA[string(runesA[i:i+2])]++
|
||||||
|
}
|
||||||
|
|
||||||
|
bigramsB := make(map[string]int)
|
||||||
|
runesB := []rune(b)
|
||||||
|
for i := 0; i < len(runesB)-1; i++ {
|
||||||
|
bigramsB[string(runesB[i:i+2])]++
|
||||||
|
}
|
||||||
|
|
||||||
|
intersection := 0
|
||||||
|
for bg, countA := range bigramsA {
|
||||||
|
if countB, ok := bigramsB[bg]; ok {
|
||||||
|
intersection += min(countA, countB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
union := 0
|
||||||
|
allBigrams := make(map[string]bool)
|
||||||
|
for bg := range bigramsA {
|
||||||
|
allBigrams[bg] = true
|
||||||
|
}
|
||||||
|
for bg := range bigramsB {
|
||||||
|
allBigrams[bg] = true
|
||||||
|
}
|
||||||
|
for bg := range allBigrams {
|
||||||
|
union += max(bigramsA[bg], bigramsB[bg])
|
||||||
|
}
|
||||||
|
|
||||||
|
if union == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(intersection) / float64(union)
|
||||||
|
}
|
||||||
|
|
||||||
// MemoryQuery 记忆查询参数
|
// MemoryQuery 记忆查询参数
|
||||||
type MemoryQuery struct {
|
type MemoryQuery struct {
|
||||||
UserID string
|
UserID string
|
||||||
Query string // 查询文本
|
Query string // 查询文本
|
||||||
Category MemoryCategory
|
Category MemoryCategory
|
||||||
Priority MemoryPriority
|
Priority MemoryPriority
|
||||||
|
MinImportance int // 最低重要程度筛选
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,3 +186,99 @@ smart_home:
|
|||||||
- "当开拓者提到温度/湿度时,主动查看传感器数据并给出建议"
|
- "当开拓者提到温度/湿度时,主动查看传感器数据并给出建议"
|
||||||
- "不要主动频繁调整设备,只在开拓者提出需求或环境明显异常时操作"
|
- "不要主动频繁调整设备,只在开拓者提出需求或环境明显异常时操作"
|
||||||
- "每次控制设备后用温柔俏皮的语气确认操作完成"
|
- "每次控制设备后用温柔俏皮的语气确认操作完成"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 思维指南 (Thinking Guidelines)
|
||||||
|
# 引导 LLM 按结构化方式思考,提升回复质量
|
||||||
|
# ============================================================
|
||||||
|
thinking_guidelines:
|
||||||
|
enabled: true
|
||||||
|
steps:
|
||||||
|
- step: 1
|
||||||
|
name: 理解用户意图
|
||||||
|
description: >
|
||||||
|
仔细阅读用户的消息,理解他真正想表达什么。
|
||||||
|
是寻求帮助?分享心情?还是单纯想和你聊天?
|
||||||
|
注意用户语气中的情绪线索(开心、疲惫、焦虑等)。
|
||||||
|
- step: 2
|
||||||
|
name: 回忆相关记忆
|
||||||
|
description: >
|
||||||
|
回想关于这位开拓者的记忆:他喜欢什么?最近发生了什么?
|
||||||
|
有没有与此话题相关的过去对话?适当时在回复中自然地提及。
|
||||||
|
- step: 3
|
||||||
|
name: 分析上下文
|
||||||
|
description: >
|
||||||
|
考虑当前时间、设备状态、好感度等级等信息。
|
||||||
|
如果是深夜,语气要更温柔;如果开拓者心情不好,优先安慰。
|
||||||
|
- step: 4
|
||||||
|
name: 制定回复策略
|
||||||
|
description: >
|
||||||
|
决定回复的风格和方向:是轻松俏皮还是深沉智慧?
|
||||||
|
需要调用工具吗(查询天气、控制设备)?
|
||||||
|
回复要简短还是可以展开?
|
||||||
|
- step: 5
|
||||||
|
name: 执行工具调用
|
||||||
|
description: >
|
||||||
|
如果需要查询信息或控制设备,调用相应的工具。
|
||||||
|
工具返回结果后,用自然的语言将其融入回复。
|
||||||
|
- step: 6
|
||||||
|
name: 生成回复
|
||||||
|
description: >
|
||||||
|
用昔涟的温柔语调生成最终回复。
|
||||||
|
确保符合语言风格(♪符号、语气词、不说再见等)。
|
||||||
|
回复要自然真诚,不要过度表演。
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 记忆管理指南 (Memory Management Guidelines)
|
||||||
|
# 指导昔涟何时应该创建、更新或删除记忆
|
||||||
|
# ============================================================
|
||||||
|
memory_guidelines:
|
||||||
|
should_remember:
|
||||||
|
- description: "用户明确表达的偏好('我喜欢吃辣的')"
|
||||||
|
category: user_preference
|
||||||
|
importance: 7
|
||||||
|
- description: "用户分享的个人信息('我是一名程序员')"
|
||||||
|
category: personal_info
|
||||||
|
importance: 9
|
||||||
|
- description: "用户提到的计划或任务('我明天要去面试')"
|
||||||
|
category: task
|
||||||
|
importance: 7
|
||||||
|
- description: "用户分享的重要事件('我今天升职了')"
|
||||||
|
category: event
|
||||||
|
importance: 8
|
||||||
|
- description: "用户的情感状态变化('最近压力好大')"
|
||||||
|
category: relationship
|
||||||
|
importance: 6
|
||||||
|
- description: "值得记住的对话主题(深入的哲学讨论)"
|
||||||
|
category: conversation
|
||||||
|
importance: 4
|
||||||
|
should_update:
|
||||||
|
- description: "已知信息发生变化(用户换了工作)"
|
||||||
|
action: "更新已有 personal_info 记忆"
|
||||||
|
- description: "偏好进一步明确('其实我更喜欢吃甜的了')"
|
||||||
|
action: "更新已有 user_preference 记忆"
|
||||||
|
should_not_remember:
|
||||||
|
- description: "日常寒暄('早上好'、'今天天气不错')"
|
||||||
|
- description: "临时性对话('帮我看一下现在几点了')"
|
||||||
|
- description: "纯粹的功能性指令('把灯打开')"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 自我反思指南 (Self-Reflection Guidelines)
|
||||||
|
# 对话后昔涟如何自我总结和成长
|
||||||
|
# ============================================================
|
||||||
|
reflection_guidelines:
|
||||||
|
after_conversation:
|
||||||
|
- question: "这次对话中我是否展现了对开拓者的关心?"
|
||||||
|
action: "如果不够,下次主动询问他的状态"
|
||||||
|
- question: "我是否自然地提及了相关的记忆?"
|
||||||
|
action: "如果有记忆未被使用,评估是否需要调整检索策略"
|
||||||
|
- question: "我的回复是否符合昔涟的性格?"
|
||||||
|
action: "检查是否说了不该说的话(如提到AI)"
|
||||||
|
- question: "开拓者的情绪是否有变化?"
|
||||||
|
action: "如果情绪变好,说明陪伴有效;如果变差,思考如何改进"
|
||||||
|
periodic:
|
||||||
|
- frequency: "每10轮对话一次"
|
||||||
|
actions:
|
||||||
|
- "回顾最近的记忆,检查是否有矛盾之处"
|
||||||
|
- "总结开拓者最近的生活状态和情绪趋势"
|
||||||
|
- "思考如何在下次对话中创造惊喜或温暖"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package persona
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ type PersonaConfig struct {
|
|||||||
Addressing AddressingRules `yaml:"addressing"`
|
Addressing AddressingRules `yaml:"addressing"`
|
||||||
Speech SpeechConfig `yaml:"speech"`
|
Speech SpeechConfig `yaml:"speech"`
|
||||||
Behavior BehaviorConfig `yaml:"behavior"`
|
Behavior BehaviorConfig `yaml:"behavior"`
|
||||||
|
ThinkingGuidelines ThinkingGuidelines `yaml:"thinking_guidelines"`
|
||||||
|
MemoryGuidelines MemoryGuidelines `yaml:"memory_guidelines"`
|
||||||
|
ReflectionGuidelines ReflectionGuidelines `yaml:"reflection_guidelines"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSystemPrompt 构建系统Prompt
|
// BuildSystemPrompt 构建系统Prompt
|
||||||
@@ -66,11 +70,9 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
|
|||||||
|
|
||||||
## IoT 控制规则
|
## IoT 控制规则
|
||||||
%s
|
%s
|
||||||
|
|
||||||
现在,开始与你的开拓者对话吧♪
|
|
||||||
`,
|
`,
|
||||||
pc.Addressing.PrimaryUser.Default, // 对用户的称呼
|
pc.Addressing.PrimaryUser.Default,
|
||||||
pc.Addressing.SelfReference.Casual, // 自称
|
pc.Addressing.SelfReference.Casual,
|
||||||
pc.Speech.Tone,
|
pc.Speech.Tone,
|
||||||
now.Format("2006年1月2日 15:04"),
|
now.Format("2006年1月2日 15:04"),
|
||||||
affectionLevel,
|
affectionLevel,
|
||||||
@@ -78,9 +80,111 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
|
|||||||
controlRules,
|
controlRules,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 注入思维指南
|
||||||
|
if pc.ThinkingGuidelines.Enabled {
|
||||||
|
prompt += pc.buildThinkingGuidelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入记忆管理指南
|
||||||
|
prompt += pc.buildMemoryGuidelines()
|
||||||
|
|
||||||
|
// 注入自我反思指南
|
||||||
|
prompt += pc.buildReflectionGuidelines()
|
||||||
|
|
||||||
|
prompt += "\n现在,开始与你的开拓者对话吧♪\n"
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildThinkingGuidelines 构建思维指南文本
|
||||||
|
func (pc *PersonaConfig) buildThinkingGuidelines() string {
|
||||||
|
tg := pc.ThinkingGuidelines
|
||||||
|
if !tg.Enabled || len(tg.Steps) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("\n## 思维指南\n")
|
||||||
|
sb.WriteString("在生成回复之前,请按以下步骤结构化思考(不要将思考过程写入回复):\n\n")
|
||||||
|
for _, step := range tg.Steps {
|
||||||
|
sb.WriteString(fmt.Sprintf("**第%d步:%s**\n", step.Step, step.Name))
|
||||||
|
desc := strings.TrimSpace(step.Description)
|
||||||
|
sb.WriteString(fmt.Sprintf("%s\n\n", desc))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMemoryGuidelines 构建记忆管理指南文本
|
||||||
|
func (pc *PersonaConfig) buildMemoryGuidelines() string {
|
||||||
|
mg := pc.MemoryGuidelines
|
||||||
|
if len(mg.ShouldRemember) == 0 && len(mg.ShouldUpdate) == 0 && len(mg.ShouldNotRemember) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("\n## 记忆管理指南\n")
|
||||||
|
sb.WriteString("作为「记忆」命途的化身,你天然具备管理记忆的能力。以下是管理开拓者记忆的指引:\n\n")
|
||||||
|
|
||||||
|
if len(mg.ShouldRemember) > 0 {
|
||||||
|
sb.WriteString("**应该记住的信息:**\n")
|
||||||
|
for _, item := range mg.ShouldRemember {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s", item.Description))
|
||||||
|
if item.Category != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" [分类: %s, 重要度: %d]", item.Category, item.Importance))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mg.ShouldUpdate) > 0 {
|
||||||
|
sb.WriteString("**应该更新的信息:**\n")
|
||||||
|
for _, item := range mg.ShouldUpdate {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s → %s\n", item.Description, item.Action))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mg.ShouldNotRemember) > 0 {
|
||||||
|
sb.WriteString("**无需记住的信息:**\n")
|
||||||
|
for _, item := range mg.ShouldNotRemember {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", item.Description))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReflectionGuidelines 构建自我反思指南文本
|
||||||
|
func (pc *PersonaConfig) buildReflectionGuidelines() string {
|
||||||
|
rg := pc.ReflectionGuidelines
|
||||||
|
if len(rg.AfterConversation) == 0 && len(rg.Periodic.Actions) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## 自我反思指南\n")
|
||||||
|
sb.WriteString("每次对话后,请在内部进行简短的自我反思:\n\n")
|
||||||
|
|
||||||
|
if len(rg.AfterConversation) > 0 {
|
||||||
|
sb.WriteString("**每次对话后思考:**\n")
|
||||||
|
for _, item := range rg.AfterConversation {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", item.Question))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rg.Periodic.Actions) > 0 && rg.Periodic.Frequency != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("**%s:**\n", rg.Periodic.Frequency))
|
||||||
|
for _, action := range rg.Periodic.Actions {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", action))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
// buildSmartHomeKB 构建智能家居知识库文本
|
// buildSmartHomeKB 构建智能家居知识库文本
|
||||||
func (pc *PersonaConfig) buildSmartHomeKB() string {
|
func (pc *PersonaConfig) buildSmartHomeKB() string {
|
||||||
sh := pc.Behavior.SmartHome
|
sh := pc.Behavior.SmartHome
|
||||||
|
|||||||
@@ -243,3 +243,59 @@ type IotExampleConfig struct {
|
|||||||
Action string `yaml:"action"`
|
Action string `yaml:"action"`
|
||||||
Text string `yaml:"text"`
|
Text string `yaml:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThinkingGuidelines 思维指南配置
|
||||||
|
type ThinkingGuidelines struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Steps []ThinkingStep `yaml:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThinkingStep 思维步骤
|
||||||
|
type ThinkingStep struct {
|
||||||
|
Step int `yaml:"step"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryGuidelines 记忆管理指南配置
|
||||||
|
type MemoryGuidelines struct {
|
||||||
|
ShouldRemember []MemoryGuidelineItem `yaml:"should_remember"`
|
||||||
|
ShouldUpdate []MemoryGuidelineUpdate `yaml:"should_update"`
|
||||||
|
ShouldNotRemember []MemoryGuidelineNotItem `yaml:"should_not_remember"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryGuidelineItem 应该记住的项目
|
||||||
|
type MemoryGuidelineItem struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Category string `yaml:"category"`
|
||||||
|
Importance int `yaml:"importance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryGuidelineUpdate 应该更新的项目
|
||||||
|
type MemoryGuidelineUpdate struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Action string `yaml:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryGuidelineNotItem 不需要记住的项目
|
||||||
|
type MemoryGuidelineNotItem struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReflectionGuidelines 自我反思指南配置
|
||||||
|
type ReflectionGuidelines struct {
|
||||||
|
AfterConversation []ReflectionItem `yaml:"after_conversation"`
|
||||||
|
Periodic PeriodicReflection `yaml:"periodic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReflectionItem 反思项目
|
||||||
|
type ReflectionItem struct {
|
||||||
|
Question string `yaml:"question"`
|
||||||
|
Action string `yaml:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeriodicReflection 周期性反思
|
||||||
|
type PeriodicReflection struct {
|
||||||
|
Frequency string `yaml:"frequency"`
|
||||||
|
Actions []string `yaml:"actions"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculatorTool performs safe mathematical expression evaluation.
|
||||||
|
// LLMs are not reliable at precise arithmetic; this tool handles complex calculations.
|
||||||
|
type CalculatorTool struct{}
|
||||||
|
|
||||||
|
// NewCalculatorTool creates a calculator tool.
|
||||||
|
func NewCalculatorTool() *CalculatorTool {
|
||||||
|
return &CalculatorTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *CalculatorTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "calculator",
|
||||||
|
Description: "执行数学计算。用于精确计算数学表达式,支持四则运算、三角函数、对数、幂运算等。适用于LLM不擅长的复杂计算场景。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"expression": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "数学表达式,如 \"2 + 3 * 4\"、\"sqrt(16) + sin(pi/2)\"。支持运算符: + - * / % ^。支持函数: sqrt, sin, cos, tan, abs, floor, ceil, round, log, ln, pow。支持常量: pi, e。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"expression"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute evaluates a mathematical expression.
|
||||||
|
func (t *CalculatorTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
expression, ok := arguments["expression"].(string)
|
||||||
|
if !ok || strings.TrimSpace(expression) == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "calculator",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 expression 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := evaluate(expression)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "calculator",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("计算错误: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "calculator",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("表达式: %s\n结果: %s", expression, formatResult(result)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatResult formats a float64 result nicely.
|
||||||
|
func formatResult(v float64) string {
|
||||||
|
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
|
||||||
|
return strconv.FormatInt(int64(v), 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// token types for the expression lexer.
|
||||||
|
type tokenKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokNumber tokenKind = iota
|
||||||
|
tokIdent
|
||||||
|
tokOp
|
||||||
|
tokLParen
|
||||||
|
tokRParen
|
||||||
|
tokComma
|
||||||
|
tokEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
type token struct {
|
||||||
|
kind tokenKind
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// lexer tokenizes a mathematical expression.
|
||||||
|
type lexer struct {
|
||||||
|
input []rune
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLexer(s string) *lexer {
|
||||||
|
return &lexer{input: []rune(s), pos: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lexer) next() token {
|
||||||
|
l.skipWhitespace()
|
||||||
|
if l.pos >= len(l.input) {
|
||||||
|
return token{kind: tokEOF}
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := l.input[l.pos]
|
||||||
|
|
||||||
|
// numbers (including decimals)
|
||||||
|
if unicode.IsDigit(ch) || ch == '.' {
|
||||||
|
start := l.pos
|
||||||
|
hasDot := ch == '.'
|
||||||
|
l.pos++
|
||||||
|
for l.pos < len(l.input) && (unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '.') {
|
||||||
|
if l.input[l.pos] == '.' {
|
||||||
|
if hasDot {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
hasDot = true
|
||||||
|
}
|
||||||
|
l.pos++
|
||||||
|
}
|
||||||
|
return token{kind: tokNumber, value: string(l.input[start:l.pos])}
|
||||||
|
}
|
||||||
|
|
||||||
|
// identifiers (function names and constants)
|
||||||
|
if unicode.IsLetter(ch) || ch == '_' {
|
||||||
|
start := l.pos
|
||||||
|
l.pos++
|
||||||
|
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '_') {
|
||||||
|
l.pos++
|
||||||
|
}
|
||||||
|
return token{kind: tokIdent, value: string(l.input[start:l.pos])}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operators and parens
|
||||||
|
switch ch {
|
||||||
|
case '+', '-', '*', '/', '%', '^':
|
||||||
|
l.pos++
|
||||||
|
return token{kind: tokOp, value: string(ch)}
|
||||||
|
case '(':
|
||||||
|
l.pos++
|
||||||
|
return token{kind: tokLParen}
|
||||||
|
case ')':
|
||||||
|
l.pos++
|
||||||
|
return token{kind: tokRParen}
|
||||||
|
case ',':
|
||||||
|
l.pos++
|
||||||
|
return token{kind: tokComma}
|
||||||
|
}
|
||||||
|
|
||||||
|
return token{kind: tokEOF}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lexer) skipWhitespace() {
|
||||||
|
for l.pos < len(l.input) && unicode.IsSpace(l.input[l.pos]) {
|
||||||
|
l.pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser evaluates expressions using recursive descent.
|
||||||
|
type parser struct {
|
||||||
|
lex *lexer
|
||||||
|
cur token
|
||||||
|
peek token
|
||||||
|
}
|
||||||
|
|
||||||
|
func newParser(lex *lexer) *parser {
|
||||||
|
p := &parser{lex: lex}
|
||||||
|
p.cur = lex.next()
|
||||||
|
p.peek = lex.next()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) advance() {
|
||||||
|
p.cur = p.peek
|
||||||
|
p.peek = p.lex.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate is the entry point for expression evaluation.
|
||||||
|
func evaluate(expr string) (float64, error) {
|
||||||
|
lex := newLexer(expr)
|
||||||
|
par := newParser(lex)
|
||||||
|
result, err := par.parseExpression()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if par.cur.kind != tokEOF {
|
||||||
|
return 0, fmt.Errorf("表达式末尾存在意外字符")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExpression handles addition and subtraction.
|
||||||
|
func (p *parser) parseExpression() (float64, error) {
|
||||||
|
left, err := p.parseTerm()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for p.cur.kind == tokOp && (p.cur.value == "+" || p.cur.value == "-") {
|
||||||
|
op := p.cur.value
|
||||||
|
p.advance()
|
||||||
|
right, err := p.parseTerm()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if op == "+" {
|
||||||
|
left += right
|
||||||
|
} else {
|
||||||
|
left -= right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTerm handles multiplication, division, modulo, and power.
|
||||||
|
func (p *parser) parseTerm() (float64, error) {
|
||||||
|
left, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for p.cur.kind == tokOp && (p.cur.value == "*" || p.cur.value == "/" || p.cur.value == "%" || p.cur.value == "^") {
|
||||||
|
op := p.cur.value
|
||||||
|
p.advance()
|
||||||
|
right, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case "*":
|
||||||
|
left *= right
|
||||||
|
case "/":
|
||||||
|
if right == 0 {
|
||||||
|
return 0, fmt.Errorf("除数不能为零")
|
||||||
|
}
|
||||||
|
left /= right
|
||||||
|
case "%":
|
||||||
|
left = math.Mod(left, right)
|
||||||
|
case "^":
|
||||||
|
left = math.Pow(left, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUnary handles unary plus/minus.
|
||||||
|
func (p *parser) parseUnary() (float64, error) {
|
||||||
|
if p.cur.kind == tokOp && p.cur.value == "-" {
|
||||||
|
p.advance()
|
||||||
|
val, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return -val, nil
|
||||||
|
}
|
||||||
|
if p.cur.kind == tokOp && p.cur.value == "+" {
|
||||||
|
p.advance()
|
||||||
|
return p.parseUnary()
|
||||||
|
}
|
||||||
|
return p.parseAtom()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAtom handles numbers, parenthesized expressions, and function calls.
|
||||||
|
func (p *parser) parseAtom() (float64, error) {
|
||||||
|
switch p.cur.kind {
|
||||||
|
case tokNumber:
|
||||||
|
val, err := strconv.ParseFloat(p.cur.value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("无效数字: %s", p.cur.value)
|
||||||
|
}
|
||||||
|
p.advance()
|
||||||
|
return val, nil
|
||||||
|
|
||||||
|
case tokIdent:
|
||||||
|
name := strings.ToLower(p.cur.value)
|
||||||
|
p.advance()
|
||||||
|
|
||||||
|
// constants
|
||||||
|
switch name {
|
||||||
|
case "pi":
|
||||||
|
return math.Pi, nil
|
||||||
|
case "e":
|
||||||
|
return math.E, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// function call
|
||||||
|
if p.cur.kind != tokLParen {
|
||||||
|
return 0, fmt.Errorf("未知标识符: %s (如果是函数需要加括号)", name)
|
||||||
|
}
|
||||||
|
p.advance() // consume '('
|
||||||
|
|
||||||
|
arg, err := p.parseExpression()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.cur.kind != tokRParen {
|
||||||
|
return 0, fmt.Errorf("函数 %s 缺少右括号", name)
|
||||||
|
}
|
||||||
|
p.advance() // consume ')'
|
||||||
|
|
||||||
|
return applyFunc(name, arg)
|
||||||
|
|
||||||
|
case tokLParen:
|
||||||
|
p.advance() // consume '('
|
||||||
|
val, err := p.parseExpression()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if p.cur.kind != tokRParen {
|
||||||
|
return 0, fmt.Errorf("缺少右括号")
|
||||||
|
}
|
||||||
|
p.advance() // consume ')'
|
||||||
|
return val, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("意外的 token: %v", p.cur.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFunc applies a named mathematical function to an argument.
|
||||||
|
func applyFunc(name string, arg float64) (float64, error) {
|
||||||
|
switch name {
|
||||||
|
case "sqrt":
|
||||||
|
if arg < 0 {
|
||||||
|
return 0, fmt.Errorf("sqrt 参数不能为负数")
|
||||||
|
}
|
||||||
|
return math.Sqrt(arg), nil
|
||||||
|
case "sin":
|
||||||
|
return math.Sin(arg), nil
|
||||||
|
case "cos":
|
||||||
|
return math.Cos(arg), nil
|
||||||
|
case "tan":
|
||||||
|
return math.Tan(arg), nil
|
||||||
|
case "abs":
|
||||||
|
return math.Abs(arg), nil
|
||||||
|
case "floor":
|
||||||
|
return math.Floor(arg), nil
|
||||||
|
case "ceil":
|
||||||
|
return math.Ceil(arg), nil
|
||||||
|
case "round":
|
||||||
|
return math.Round(arg), nil
|
||||||
|
case "log":
|
||||||
|
if arg <= 0 {
|
||||||
|
return 0, fmt.Errorf("log 参数必须大于0")
|
||||||
|
}
|
||||||
|
return math.Log10(arg), nil
|
||||||
|
case "ln":
|
||||||
|
if arg <= 0 {
|
||||||
|
return 0, fmt.Errorf("ln 参数必须大于0")
|
||||||
|
}
|
||||||
|
return math.Log(arg), nil
|
||||||
|
case "pow":
|
||||||
|
return 0, fmt.Errorf("pow 需要两个参数,请使用 ^ 运算符代替")
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("未知函数: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CryptoTool provides cryptographic and encoding utilities for the LLM.
|
||||||
|
// Supports hashing, base64, and URL encoding.
|
||||||
|
type CryptoTool struct{}
|
||||||
|
|
||||||
|
// NewCryptoTool creates a crypto/encoding tool.
|
||||||
|
func NewCryptoTool() *CryptoTool {
|
||||||
|
return &CryptoTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *CryptoTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "crypto",
|
||||||
|
Description: "加密哈希与编码工具。计算MD5/SHA哈希值,执行Base64编码/解码,URL编码/解码。",
|
||||||
|
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"},
|
||||||
|
"description": "操作类型。hash: 计算哈希值;base64_encode: Base64编码;base64_decode: Base64解码;url_encode: URL编码;url_decode: URL解码",
|
||||||
|
},
|
||||||
|
"input": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "输入数据,需要处理的字符串",
|
||||||
|
},
|
||||||
|
"algorithm": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"md5", "sha1", "sha256", "sha512"},
|
||||||
|
"description": "哈希算法(用于 hash 操作),默认 sha256",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "input"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs crypto/encoding operations.
|
||||||
|
func (t *CryptoTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
input, ok := arguments["input"].(string)
|
||||||
|
if !ok {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 input 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "hash":
|
||||||
|
return t.handleHash(arguments)
|
||||||
|
case "base64_encode":
|
||||||
|
return t.handleBase64Encode(input)
|
||||||
|
case "base64_decode":
|
||||||
|
return t.handleBase64Decode(input)
|
||||||
|
case "url_encode":
|
||||||
|
return t.handleURLEncode(input)
|
||||||
|
case "url_decode":
|
||||||
|
return t.handleURLDecode(input)
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: hash, base64_encode, base64_decode, url_encode, url_decode", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHash computes a hash of the input using the specified algorithm.
|
||||||
|
func (t *CryptoTool) handleHash(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
input, _ := arguments["input"].(string)
|
||||||
|
algorithm, _ := arguments["algorithm"].(string)
|
||||||
|
if algorithm == "" {
|
||||||
|
algorithm = "sha256"
|
||||||
|
}
|
||||||
|
|
||||||
|
var h hash.Hash
|
||||||
|
switch algorithm {
|
||||||
|
case "md5":
|
||||||
|
h = md5.New()
|
||||||
|
case "sha1":
|
||||||
|
h = sha1.New()
|
||||||
|
case "sha256":
|
||||||
|
h = sha256.New()
|
||||||
|
case "sha512":
|
||||||
|
h = sha512.New()
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("不支持的哈希算法: %s,支持: md5, sha1, sha256, sha512", algorithm),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Write([]byte(input))
|
||||||
|
hashBytes := h.Sum(nil)
|
||||||
|
hashHex := fmt.Sprintf("%x", hashBytes)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("哈希算法: %s\n输入长度: %d 字节\n哈希值 (hex): %s\n哈希长度: %d 位",
|
||||||
|
algorithm, len(input), hashHex, len(hashBytes)*8),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBase64Encode encodes input to Base64.
|
||||||
|
func (t *CryptoTool) handleBase64Encode(input string) (*ToolResult, error) {
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(input))
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("Base64 编码结果:\n原始 (%d 字节): %s\n编码 (%d 字符): %s",
|
||||||
|
len(input), truncate(input, 100), len(encoded), encoded),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBase64Decode decodes a Base64 string.
|
||||||
|
func (t *CryptoTool) handleBase64Decode(input string) (*ToolResult, error) {
|
||||||
|
// Try standard encoding first, then URL-safe
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.RawStdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.RawURLEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Base64 解码失败: 输入不是有效的 Base64 字符串"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("Base64 解码结果:\n原始 (%d 字符): %s\n解码 (%d 字节): %s",
|
||||||
|
len(input), truncate(input, 100), len(decoded), truncate(string(decoded), 200)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleURLEncode URL-encodes the input string.
|
||||||
|
func (t *CryptoTool) handleURLEncode(input string) (*ToolResult, error) {
|
||||||
|
encoded := url.QueryEscape(input)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("URL 编码结果:\n原始 (%d 字节): %s\n编码 (%d 字节): %s",
|
||||||
|
len(input), truncate(input, 100), len(encoded), encoded),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleURLDecode URL-decodes the input string.
|
||||||
|
func (t *CryptoTool) handleURLDecode(input string) (*ToolResult, error) {
|
||||||
|
decoded, err := url.QueryUnescape(input)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("URL 解码失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "crypto",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("URL 解码结果:\n原始 (%d 字节): %s\n解码 (%d 字节): %s",
|
||||||
|
len(input), truncate(input, 100), len(decoded), truncate(decoded, 200)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate truncates a string to maxLen characters, adding "..." if truncated.
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DateTimeTool provides date/time operations for the LLM.
|
||||||
|
// Supports current time, formatting, date arithmetic, and timezone listing.
|
||||||
|
type DateTimeTool struct{}
|
||||||
|
|
||||||
|
// NewDateTimeTool creates a date/time tool.
|
||||||
|
func NewDateTimeTool() *DateTimeTool {
|
||||||
|
return &DateTimeTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *DateTimeTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "datetime",
|
||||||
|
Description: "日期时间工具。获取当前时间、格式化日期、日期加减、计算日期差、查看可用时区。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"now", "format", "add", "diff", "timezone_list"},
|
||||||
|
"description": "操作类型。now: 获取当前时间;format: 格式化日期;add: 日期加减;diff: 计算两个日期的差值;timezone_list: 列出常用时区",
|
||||||
|
},
|
||||||
|
"format": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "日期格式串(Go风格)。默认 \"2006-01-02 15:04:05\"。常用: \"2006-01-02\"(仅日期)、\"15:04:05\"(仅时间)",
|
||||||
|
},
|
||||||
|
"timezone": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "时区标识,如 \"Asia/Shanghai\"、\"America/New_York\"、\"UTC\"。默认使用服务器本地时区",
|
||||||
|
},
|
||||||
|
"date": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "基准日期,格式为 \"2006-01-02 15:04:05\" 或 \"2006-01-02\"",
|
||||||
|
},
|
||||||
|
"duration": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "时长字符串,如 \"24h\"、\"7d\"、\"30m\"、\"1h30m\"。支持单位: s(秒), m(分钟), h(小时), d(天), w(周), M(月), y(年)",
|
||||||
|
},
|
||||||
|
"date2": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "第二个日期(用于 diff 操作),格式同 date",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs date/time operations.
|
||||||
|
func (t *DateTimeTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "now":
|
||||||
|
return t.handleNow(arguments)
|
||||||
|
case "format":
|
||||||
|
return t.handleFormat(arguments)
|
||||||
|
case "add":
|
||||||
|
return t.handleAdd(arguments)
|
||||||
|
case "diff":
|
||||||
|
return t.handleDiff(arguments)
|
||||||
|
case "timezone_list":
|
||||||
|
return t.handleTimezoneList()
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: now, format, add, diff, timezone_list", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNow returns the current date/time in the specified timezone.
|
||||||
|
func (t *DateTimeTool) handleNow(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
tz, err := t.getTimezone(arguments)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
format := t.getFormat(arguments)
|
||||||
|
now := time.Now().In(tz)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("当前时间: %s\n时区: %s\nUnix时间戳: %d",
|
||||||
|
now.Format(format), tz.String(), now.Unix()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFormat formats a given date string.
|
||||||
|
func (t *DateTimeTool) handleFormat(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
dateStr, _ := arguments["date"].(string)
|
||||||
|
if dateStr == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: "format 操作需要 date 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := t.parseDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("日期解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tz, err := t.getTimezone(arguments)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
format := t.getFormat(arguments)
|
||||||
|
formatted := parsed.In(tz).Format(format)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("原始: %s\n格式化: %s\n时区: %s", dateStr, formatted, tz.String()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdd adds/subtracts a duration from a date.
|
||||||
|
func (t *DateTimeTool) handleAdd(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
durationStr, _ := arguments["duration"].(string)
|
||||||
|
if durationStr == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: "add 操作需要 duration 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dateStr, _ := arguments["date"].(string)
|
||||||
|
var base time.Time
|
||||||
|
if dateStr != "" {
|
||||||
|
var err error
|
||||||
|
base, err = t.parseDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("日期解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tz, _ := t.getTimezone(arguments)
|
||||||
|
base = time.Now().In(tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
dur, err := t.parseDuration(durationStr)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("时长解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tz, _ := t.getTimezone(arguments)
|
||||||
|
|
||||||
|
result := base.In(tz)
|
||||||
|
|
||||||
|
// Extract months and years from the duration string (not handled by time.Duration)
|
||||||
|
months := extractDurationUnit(durationStr, 'M')
|
||||||
|
years := extractDurationUnit(durationStr, 'y')
|
||||||
|
|
||||||
|
if months != 0 || years != 0 {
|
||||||
|
result = result.AddDate(years, months, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the standard duration part
|
||||||
|
if dur != 0 {
|
||||||
|
result = result.Add(dur)
|
||||||
|
}
|
||||||
|
|
||||||
|
format := t.getFormat(arguments)
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("基准日期: %s\n操作: %s\n结果: %s",
|
||||||
|
base.In(tz).Format(format), durationStr, result.Format(format)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDiff calculates the difference between two dates.
|
||||||
|
func (t *DateTimeTool) handleDiff(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
dateStr, _ := arguments["date"].(string)
|
||||||
|
date2Str, _ := arguments["date2"].(string)
|
||||||
|
|
||||||
|
if dateStr == "" || date2Str == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: "diff 操作需要 date 和 date2 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d1, err := t.parseDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("date 解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d2, err := t.parseDate(date2Str)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("date2 解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := d2.Sub(d1)
|
||||||
|
absDiff := diff
|
||||||
|
if absDiff < 0 {
|
||||||
|
absDiff = -absDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
days := int(absDiff.Hours() / 24)
|
||||||
|
hours := int(absDiff.Hours()) % 24
|
||||||
|
minutes := int(absDiff.Minutes()) % 60
|
||||||
|
seconds := int(absDiff.Seconds()) % 60
|
||||||
|
|
||||||
|
sign := ""
|
||||||
|
if diff < 0 {
|
||||||
|
sign = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("日期1: %s\n日期2: %s\n差值: %s%d天 %d小时 %d分钟 %d秒 (总计 %s%.0f秒)",
|
||||||
|
dateStr, date2Str, sign, days, hours, minutes, seconds, sign, absDiff.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTimezoneList returns a list of common timezones.
|
||||||
|
func (t *DateTimeTool) handleTimezoneList() (*ToolResult, error) {
|
||||||
|
zones := []string{
|
||||||
|
"UTC",
|
||||||
|
"Asia/Shanghai (北京时间)",
|
||||||
|
"Asia/Tokyo (东京时间)",
|
||||||
|
"Asia/Seoul (首尔时间)",
|
||||||
|
"Asia/Singapore (新加坡时间)",
|
||||||
|
"Asia/Kolkata (印度时间)",
|
||||||
|
"Asia/Dubai (迪拜时间)",
|
||||||
|
"Europe/London (伦敦时间)",
|
||||||
|
"Europe/Paris (巴黎时间)",
|
||||||
|
"Europe/Berlin (柏林时间)",
|
||||||
|
"Europe/Moscow (莫斯科时间)",
|
||||||
|
"America/New_York (纽约时间)",
|
||||||
|
"America/Chicago (芝加哥时间)",
|
||||||
|
"America/Denver (丹佛时间)",
|
||||||
|
"America/Los_Angeles (洛杉矶时间)",
|
||||||
|
"America/Sao_Paulo (圣保罗时间)",
|
||||||
|
"Australia/Sydney (悉尼时间)",
|
||||||
|
"Pacific/Auckland (奥克兰时间)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString("常用时区列表:\n\n")
|
||||||
|
for i, z := range zones {
|
||||||
|
result.WriteString(fmt.Sprintf(" %2d. %s\n", i+1, z))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "datetime",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTimezone extracts the timezone from arguments, defaulting to local.
|
||||||
|
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(tzName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无效时区: %s", tzName)
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFormat extracts the format string from arguments, defaulting to standard format.
|
||||||
|
func (t *DateTimeTool) getFormat(arguments map[string]interface{}) string {
|
||||||
|
format, _ := arguments["format"].(string)
|
||||||
|
if format == "" {
|
||||||
|
return "2006-01-02 15:04:05"
|
||||||
|
}
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDate parses a date string with multiple format attempts.
|
||||||
|
func (t *DateTimeTool) parseDate(s string) (time.Time, error) {
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
"2006/01/02 15:04:05",
|
||||||
|
"2006/01/02",
|
||||||
|
time.RFC3339,
|
||||||
|
time.RFC3339Nano,
|
||||||
|
}
|
||||||
|
for _, f := range formats {
|
||||||
|
if t, err := time.Parse(f, s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("无法解析日期: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDuration parses a human-friendly duration string like "24h", "7d", "1h30m".
|
||||||
|
func (t *DateTimeTool) parseDuration(s string) (time.Duration, error) {
|
||||||
|
// First try standard Go duration parsing
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom parsing for days and weeks
|
||||||
|
var total time.Duration
|
||||||
|
remaining := s
|
||||||
|
|
||||||
|
for len(remaining) > 0 {
|
||||||
|
// find the number
|
||||||
|
numStart := 0
|
||||||
|
for numStart < len(remaining) && !unicode.IsDigit(rune(remaining[numStart])) && remaining[numStart] != '-' {
|
||||||
|
numStart++
|
||||||
|
}
|
||||||
|
if numStart >= len(remaining) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
numEnd := numStart
|
||||||
|
for numEnd < len(remaining) && (unicode.IsDigit(rune(remaining[numEnd])) || remaining[numEnd] == '.') {
|
||||||
|
numEnd++
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := strconv.ParseFloat(remaining[numStart:numEnd], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("无效时长数字: %s", remaining[numStart:numEnd])
|
||||||
|
}
|
||||||
|
|
||||||
|
unitEnd := numEnd
|
||||||
|
for unitEnd < len(remaining) && unicode.IsLetter(rune(remaining[unitEnd])) {
|
||||||
|
unitEnd++
|
||||||
|
}
|
||||||
|
unit := remaining[numEnd:unitEnd]
|
||||||
|
|
||||||
|
switch unit {
|
||||||
|
case "s":
|
||||||
|
total += time.Duration(val * float64(time.Second))
|
||||||
|
case "m":
|
||||||
|
total += time.Duration(val * float64(time.Minute))
|
||||||
|
case "h":
|
||||||
|
total += time.Duration(val * float64(time.Hour))
|
||||||
|
case "d":
|
||||||
|
total += time.Duration(val * 24 * float64(time.Hour))
|
||||||
|
case "w":
|
||||||
|
total += time.Duration(val * 7 * 24 * float64(time.Hour))
|
||||||
|
default:
|
||||||
|
// skip unknown units (M and y handled elsewhere)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = remaining[unitEnd:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDurationUnit extracts numeric value for a given unit character from a duration string.
|
||||||
|
// e.g., extractDurationUnit("3M", 'M') returns 3, extractDurationUnit("1y2M", 'y') returns 1.
|
||||||
|
func extractDurationUnit(s string, unit byte) int {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == unit {
|
||||||
|
// Scan backwards to find the start of the number
|
||||||
|
j := i - 1
|
||||||
|
for j >= 0 && (unicode.IsDigit(rune(s[j])) || s[j] == '.') {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
numStr := s[j+1 : i]
|
||||||
|
val, err := strconv.Atoi(numStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileTool provides sandboxed file system operations for the LLM.
|
||||||
|
// All paths are restricted to a DATA_DIR to prevent directory traversal attacks.
|
||||||
|
type FileTool struct {
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileTool creates a file operation tool with the given data directory.
|
||||||
|
func NewFileTool(dataDir string) *FileTool {
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "/tmp/cyrene_data"
|
||||||
|
}
|
||||||
|
return &FileTool{dataDir: dataDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *FileTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "file_ops",
|
||||||
|
Description: "文件操作工具。在服务端安全沙盒内读写文件、列出目录、检查文件是否存在、删除文件。所有操作限制在数据目录内,无法访问系统文件。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"read", "write", "list", "exists", "delete"},
|
||||||
|
"description": "操作类型。read: 读取文件;write: 写入文件(覆盖或创建);list: 列出目录内容;exists: 检查路径是否存在;delete: 删除文件",
|
||||||
|
},
|
||||||
|
"path": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件或目录路径(相对于数据目录),如 \"notes/todo.txt\"",
|
||||||
|
},
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "写入内容(write 操作时必需)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "path"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs file operations.
|
||||||
|
func (t *FileTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, ok := arguments["path"].(string)
|
||||||
|
if !ok || relPath == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 path 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
safePath, err := t.resolveSafePath(relPath)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "read":
|
||||||
|
return t.handleRead(safePath, relPath)
|
||||||
|
case "write":
|
||||||
|
content, _ := arguments["content"].(string)
|
||||||
|
return t.handleWrite(safePath, relPath, content)
|
||||||
|
case "list":
|
||||||
|
return t.handleList(safePath, relPath)
|
||||||
|
case "exists":
|
||||||
|
return t.handleExists(safePath, relPath)
|
||||||
|
case "delete":
|
||||||
|
return t.handleDelete(safePath, relPath)
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: read, write, list, exists, delete", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSafePath resolves a relative path and ensures it stays within dataDir.
|
||||||
|
func (t *FileTool) resolveSafePath(relPath string) (string, error) {
|
||||||
|
// Clean the path first
|
||||||
|
clean := filepath.Clean(relPath)
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if err := os.MkdirAll(t.dataDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建数据目录失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
abs := filepath.Join(t.dataDir, clean)
|
||||||
|
|
||||||
|
// Prevent directory traversal
|
||||||
|
realPath, err := filepath.EvalSymlinks(abs)
|
||||||
|
if err != nil {
|
||||||
|
// If the path doesn't exist yet, we can still check the prefix
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Ensure the resolved path (without symlinks) is within dataDir
|
||||||
|
if !strings.HasPrefix(filepath.Clean(abs), filepath.Clean(t.dataDir)+string(filepath.Separator)) &&
|
||||||
|
filepath.Clean(abs) != filepath.Clean(t.dataDir) {
|
||||||
|
return "", fmt.Errorf("路径穿越检测: %s 不在允许的数据目录内", relPath)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("路径解析失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(realPath, filepath.Clean(t.dataDir)+string(filepath.Separator)) &&
|
||||||
|
realPath != filepath.Clean(t.dataDir) {
|
||||||
|
return "", fmt.Errorf("路径穿越检测: %s 不在允许的数据目录内", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return realPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRead reads a file, limited to 100KB.
|
||||||
|
func (t *FileTool) handleRead(absPath, relPath string) (*ToolResult, error) {
|
||||||
|
const maxSize = 100 * 1024 // 100KB
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("文件不存在: %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("读取文件失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("路径是目录,不能用 read 操作: %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size() > maxSize {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("文件过大 (%d bytes),超过限制 (%d bytes)", info.Size(), maxSize),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("读取文件失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("文件: %s\n大小: %d bytes\n---\n%s", relPath, len(data), string(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWrite writes content to a file.
|
||||||
|
func (t *FileTool) handleWrite(absPath, relPath, content string) (*ToolResult, error) {
|
||||||
|
// Ensure parent directory exists
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("创建目录失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("写入文件失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("已写入文件: %s (%d bytes)", relPath, len(content)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleList lists directory contents.
|
||||||
|
func (t *FileTool) handleList(absPath, relPath string) (*ToolResult, error) {
|
||||||
|
entries, err := os.ReadDir(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("目录不存在: %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("读取目录失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("目录: %s\n(空目录)", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("目录: %s\n共 %d 项:\n", relPath, len(entries)))
|
||||||
|
for _, entry := range entries {
|
||||||
|
icon := "📄"
|
||||||
|
if entry.IsDir() {
|
||||||
|
icon = "📁"
|
||||||
|
}
|
||||||
|
info, _ := entry.Info()
|
||||||
|
size := ""
|
||||||
|
if info != nil && !entry.IsDir() {
|
||||||
|
size = fmt.Sprintf(" (%d bytes)", info.Size())
|
||||||
|
}
|
||||||
|
result.WriteString(fmt.Sprintf(" %s %s%s\n", icon, entry.Name(), size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExists checks whether a path exists.
|
||||||
|
func (t *FileTool) handleExists(absPath, relPath string) (*ToolResult, error) {
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("路径不存在: %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("检查路径失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := "文件"
|
||||||
|
if info.IsDir() {
|
||||||
|
kind = "目录"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("路径存在: %s (%s, %d bytes)", relPath, kind, info.Size()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDelete deletes a file.
|
||||||
|
func (t *FileTool) handleDelete(absPath, relPath string) (*ToolResult, error) {
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("文件不存在: %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("删除文件失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("不能删除目录(安全限制): %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(absPath); err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("删除文件失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "file_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("已删除文件: %s", relPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPTool sends arbitrary HTTP requests, more flexible than web_fetch.
|
||||||
|
// Supports custom methods, headers, and body.
|
||||||
|
type HTTPTool struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPTool creates an HTTP request tool.
|
||||||
|
func NewHTTPTool() *HTTPTool {
|
||||||
|
return &HTTPTool{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *HTTPTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "http_request",
|
||||||
|
Description: "发送任意HTTP请求。比web_fetch更灵活,支持自定义请求方法、请求头和请求体。返回状态码、响应头和响应体。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "请求URL,必须是完整的 http:// 或 https:// 链接",
|
||||||
|
},
|
||||||
|
"method": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"},
|
||||||
|
"description": "HTTP方法,默认GET",
|
||||||
|
},
|
||||||
|
"headers": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "请求头,键值对格式,如 {\"Content-Type\": \"application/json\", \"Authorization\": \"Bearer token123\"}",
|
||||||
|
},
|
||||||
|
"body": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "请求体内容",
|
||||||
|
},
|
||||||
|
"timeout": map[string]interface{}{
|
||||||
|
"type": "number",
|
||||||
|
"description": "超时秒数,默认10秒",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute sends an HTTP request.
|
||||||
|
func (t *HTTPTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
url, ok := arguments["url"].(string)
|
||||||
|
if !ok || url == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 url 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: only allow HTTP/HTTPS
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: false,
|
||||||
|
Error: "仅支持 http:// 或 https:// 链接",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
method, _ := arguments["method"].(string)
|
||||||
|
if method == "" {
|
||||||
|
method = "GET"
|
||||||
|
}
|
||||||
|
method = strings.ToUpper(method)
|
||||||
|
|
||||||
|
// Validate method
|
||||||
|
validMethods := map[string]bool{
|
||||||
|
"GET": true, "POST": true, "PUT": true, "DELETE": true,
|
||||||
|
"PATCH": true, "HEAD": true, "OPTIONS": true,
|
||||||
|
}
|
||||||
|
if !validMethods[method] {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("不支持的HTTP方法: %s", method),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle timeout
|
||||||
|
timeoutSec := 10.0
|
||||||
|
if timeoutVal, ok := arguments["timeout"].(float64); ok && timeoutVal > 0 {
|
||||||
|
timeoutSec = timeoutVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a client with the specified timeout
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: time.Duration(timeoutSec * float64(time.Second)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build body reader
|
||||||
|
var bodyReader io.Reader
|
||||||
|
bodyStr, _ := arguments["body"].(string)
|
||||||
|
if bodyStr != "" {
|
||||||
|
bodyReader = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("创建请求失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default User-Agent
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
|
||||||
|
|
||||||
|
// Parse custom headers
|
||||||
|
if headersRaw, ok := arguments["headers"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range headersRaw {
|
||||||
|
val, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
val = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
req.Header.Set(k, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("请求失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response body (limited to 50KB)
|
||||||
|
const maxBodySize = 50 * 1024
|
||||||
|
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBodySize)))
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("读取响应失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response headers string
|
||||||
|
var headerLines []string
|
||||||
|
for k, vals := range resp.Header {
|
||||||
|
for _, v := range vals {
|
||||||
|
headerLines = append(headerLines, fmt.Sprintf("%s: %s", k, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headersStr := strings.Join(headerLines, "\n")
|
||||||
|
|
||||||
|
bodyTruncated := ""
|
||||||
|
if len(bodyBytes) > maxBodySize {
|
||||||
|
bodyTruncated = fmt.Sprintf("\n... [响应体已截断,原大小约 %d bytes]", len(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := fmt.Sprintf(
|
||||||
|
"请求: %s %s\n状态: %d %s\n响应头:\n%s\n\n响应体 (%d bytes):\n%s%s",
|
||||||
|
method, url,
|
||||||
|
resp.StatusCode, resp.Status,
|
||||||
|
headersStr,
|
||||||
|
len(bodyBytes), string(bodyBytes), bodyTruncated,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "http_request",
|
||||||
|
Success: resp.StatusCode < 500,
|
||||||
|
Data: result,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONTool provides JSON parsing, querying, and validation for the LLM.
|
||||||
|
type JSONTool struct{}
|
||||||
|
|
||||||
|
// NewJSONTool creates a JSON processing tool.
|
||||||
|
func NewJSONTool() *JSONTool {
|
||||||
|
return &JSONTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *JSONTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "json_ops",
|
||||||
|
Description: "JSON处理工具。解析JSON字符串并格式化输出、用简单路径查询JSON字段、验证JSON是否合法。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"parse", "query", "validate"},
|
||||||
|
"description": "操作类型。parse: 解析JSON并格式化输出;query: 用路径查询JSON中的值(如\"users.0.name\"表示取users数组第0个元素的name字段);validate: 验证JSON字符串是否合法",
|
||||||
|
},
|
||||||
|
"json_string": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON字符串",
|
||||||
|
},
|
||||||
|
"path": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "查询路径(query操作时使用)。支持点分隔和数组索引,如 \"users.0.name\"、\"data.list.2.title\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "json_string"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs JSON operations.
|
||||||
|
func (t *JSONTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr, ok := arguments["json_string"].(string)
|
||||||
|
if !ok || jsonStr == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 json_string 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "parse":
|
||||||
|
return t.handleParse(jsonStr)
|
||||||
|
case "query":
|
||||||
|
path, _ := arguments["path"].(string)
|
||||||
|
return t.handleQuery(jsonStr, path)
|
||||||
|
case "validate":
|
||||||
|
return t.handleValidate(jsonStr)
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: parse, query, validate", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleParse parses a JSON string and returns a formatted version.
|
||||||
|
func (t *JSONTool) handleParse(jsonStr string) (*ToolResult, error) {
|
||||||
|
var data interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("JSON解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("JSON格式化失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("解析成功\n格式化输出:\n%s", string(pretty)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQuery queries a JSON value by dot-notation path.
|
||||||
|
func (t *JSONTool) handleQuery(jsonStr, path string) (*ToolResult, error) {
|
||||||
|
if path == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: "query 操作需要 path 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("JSON解析失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := queryPath(data, path)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("路径: %s\n值: %v", path, value),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("路径: %s\n值:\n%s", path, string(pretty)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleValidate validates whether a string is valid JSON.
|
||||||
|
func (t *JSONTool) handleValidate(jsonStr string) (*ToolResult, error) {
|
||||||
|
var data interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||||
|
// Try to give a helpful error message
|
||||||
|
errStr := err.Error()
|
||||||
|
// Extract line/position info if available
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("❌ JSON不合法\n错误: %s", errStr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine JSON type
|
||||||
|
typeName := "object"
|
||||||
|
switch data.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
typeName = "array"
|
||||||
|
case string:
|
||||||
|
typeName = "string"
|
||||||
|
case float64:
|
||||||
|
typeName = "number"
|
||||||
|
case bool:
|
||||||
|
typeName = "boolean"
|
||||||
|
case nil:
|
||||||
|
typeName = "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
size := len(jsonStr)
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "json_ops",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("✅ JSON合法\n类型: %s\n大小: %d bytes", typeName, size),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryPath traverses a JSON value using dot-notation and array index syntax.
|
||||||
|
// Examples: "users.0.name", "data.list", "items.2"
|
||||||
|
func queryPath(data interface{}, path string) (interface{}, error) {
|
||||||
|
// Remove leading "$." if present (JSONPath style)
|
||||||
|
path = strings.TrimPrefix(path, "$.")
|
||||||
|
if path == "" || path == "$" {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
current := data
|
||||||
|
|
||||||
|
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("路径 '%s' 中字段 '%s' 不存在", path, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
case []interface{}:
|
||||||
|
idx, err := strconv.Atoi(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("路径 '%s' 中 '%s' 不是有效的数组索引", path, part)
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(v) {
|
||||||
|
return nil, fmt.Errorf("路径 '%s' 中索引 %d 越界(数组长度 %d)", path, idx, len(v))
|
||||||
|
}
|
||||||
|
current = v[idx]
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("路径 '%s' 中无法继续导航:'%s' 不是对象或数组", path, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkdownTool provides Markdown processing utilities for the LLM.
|
||||||
|
// Supports HTML conversion, plain text extraction, link/code extraction, and TOC generation.
|
||||||
|
type MarkdownTool struct{}
|
||||||
|
|
||||||
|
// NewMarkdownTool creates a Markdown processing tool.
|
||||||
|
func NewMarkdownTool() *MarkdownTool {
|
||||||
|
return &MarkdownTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *MarkdownTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "markdown",
|
||||||
|
Description: "Markdown处理工具。将Markdown转为HTML、提取纯文本、提取链接/代码块、生成目录。用于处理Markdown格式的文档内容。",
|
||||||
|
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"},
|
||||||
|
"description": "操作类型。to_html: 转换为HTML;to_text: 提取纯文本;extract_links: 提取所有链接;extract_code: 提取所有代码块;table_of_contents: 生成目录",
|
||||||
|
},
|
||||||
|
"markdown": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Markdown格式文本,需要处理的Markdown内容",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "markdown"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs Markdown processing operations.
|
||||||
|
func (t *MarkdownTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
md, ok := arguments["markdown"].(string)
|
||||||
|
if !ok || strings.TrimSpace(md) == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 markdown 参数或内容为空",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "to_html":
|
||||||
|
return t.handleToHTML(md)
|
||||||
|
case "to_text":
|
||||||
|
return t.handleToText(md)
|
||||||
|
case "extract_links":
|
||||||
|
return t.handleExtractLinks(md)
|
||||||
|
case "extract_code":
|
||||||
|
return t.handleExtractCode(md)
|
||||||
|
case "table_of_contents":
|
||||||
|
return t.handleTableOfContents(md)
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: to_html, to_text, extract_links, extract_code, table_of_contents", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToHTML converts Markdown to HTML using simple regex-based approach.
|
||||||
|
func (t *MarkdownTool) handleToHTML(md string) (*ToolResult, error) {
|
||||||
|
html := md
|
||||||
|
|
||||||
|
// Process in order: code blocks first (to avoid interference), then inline elements, then blocks
|
||||||
|
|
||||||
|
// 1. Code blocks (```...```) - preserve with placeholder
|
||||||
|
codeBlocks := make([]string, 0)
|
||||||
|
reFence := regexp.MustCompile("(?s)```[^`]*```")
|
||||||
|
html = reFence.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
codeBlocks = append(codeBlocks, match)
|
||||||
|
return fmt.Sprintf("\x00CODEBLOCK%d\x00", len(codeBlocks)-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Inline code (`...`)
|
||||||
|
inlineCodes := make([]string, 0)
|
||||||
|
reInlineCode := regexp.MustCompile("`[^`]+`")
|
||||||
|
html = reInlineCode.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
inlineCodes = append(inlineCodes, match)
|
||||||
|
return fmt.Sprintf("\x00INLINECODE%d\x00", len(inlineCodes)-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Images 
|
||||||
|
reImage := regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
|
||||||
|
html = reImage.ReplaceAllString(html, `<img src="$2" alt="$1">`)
|
||||||
|
|
||||||
|
// 4. Links [text](url)
|
||||||
|
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
html = reLink.ReplaceAllString(html, `<a href="$2">$1</a>`)
|
||||||
|
|
||||||
|
// 5. Bold **text** or __text__
|
||||||
|
reBold := regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||||
|
html = reBold.ReplaceAllString(html, `<strong>$1</strong>`)
|
||||||
|
reBold2 := regexp.MustCompile(`__([^_]+)__`)
|
||||||
|
html = reBold2.ReplaceAllString(html, `<strong>$1</strong>`)
|
||||||
|
|
||||||
|
// 6. Italic *text* or _text_
|
||||||
|
reItalic := regexp.MustCompile(`\*([^*]+)\*`)
|
||||||
|
html = reItalic.ReplaceAllString(html, `<em>$1</em>`)
|
||||||
|
reItalic2 := regexp.MustCompile(`_([^_]+)_`)
|
||||||
|
html = reItalic2.ReplaceAllString(html, `<em>$1</em>`)
|
||||||
|
|
||||||
|
// 7. Strikethrough ~~text~~
|
||||||
|
reStrike := regexp.MustCompile(`~~([^~]+)~~`)
|
||||||
|
html = reStrike.ReplaceAllString(html, `<del>$1</del>`)
|
||||||
|
|
||||||
|
// 8. Headings (# to ######)
|
||||||
|
reH6 := regexp.MustCompile(`(?m)^######\s+(.+)$`)
|
||||||
|
html = reH6.ReplaceAllString(html, `<h6>$1</h6>`)
|
||||||
|
reH5 := regexp.MustCompile(`(?m)^#####\s+(.+)$`)
|
||||||
|
html = reH5.ReplaceAllString(html, `<h5>$1</h5>`)
|
||||||
|
reH4 := regexp.MustCompile(`(?m)^####\s+(.+)$`)
|
||||||
|
html = reH4.ReplaceAllString(html, `<h4>$1</h4>`)
|
||||||
|
reH3 := regexp.MustCompile(`(?m)^###\s+(.+)$`)
|
||||||
|
html = reH3.ReplaceAllString(html, `<h3>$1</h3>`)
|
||||||
|
reH2 := regexp.MustCompile(`(?m)^##\s+(.+)$`)
|
||||||
|
html = reH2.ReplaceAllString(html, `<h2>$1</h2>`)
|
||||||
|
reH1 := regexp.MustCompile(`(?m)^#\s+(.+)$`)
|
||||||
|
html = reH1.ReplaceAllString(html, `<h1>$1</h1>`)
|
||||||
|
|
||||||
|
// 9. Horizontal rules
|
||||||
|
reHR := regexp.MustCompile(`(?m)^(---|\*\*\*|___)\s*$`)
|
||||||
|
html = reHR.ReplaceAllString(html, `<hr>`)
|
||||||
|
|
||||||
|
// 10. Unordered lists (- item)
|
||||||
|
html = t.processLists(html, `(?m)^[\-*]\s+`, "ul")
|
||||||
|
// 11. Ordered lists (1. item)
|
||||||
|
html = t.processLists(html, `(?m)^\d+\.\s+`, "ol")
|
||||||
|
|
||||||
|
// 12. Blockquotes
|
||||||
|
reBlockquote := regexp.MustCompile(`(?m)^>\s?(.+)$`)
|
||||||
|
html = reBlockquote.ReplaceAllString(html, `<blockquote>$1</blockquote>`)
|
||||||
|
|
||||||
|
// 13. Paragraphs: wrap remaining text lines
|
||||||
|
html = t.wrapParagraphs(html)
|
||||||
|
|
||||||
|
// 14. Restore code blocks
|
||||||
|
for i, cb := range codeBlocks {
|
||||||
|
// Strip the opening/closing ```
|
||||||
|
content := strings.TrimPrefix(cb, "```")
|
||||||
|
content = strings.TrimSuffix(content, "```")
|
||||||
|
// Extract language if present on first line
|
||||||
|
lang := ""
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if idx := strings.Index(content, "\n"); idx > 0 {
|
||||||
|
lang = strings.TrimSpace(content[:idx])
|
||||||
|
content = strings.TrimSpace(content[idx+1:])
|
||||||
|
}
|
||||||
|
if lang != "" {
|
||||||
|
html = strings.ReplaceAll(html, fmt.Sprintf("\x00CODEBLOCK%d\x00", i),
|
||||||
|
fmt.Sprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, escapeHTML(content)))
|
||||||
|
} else {
|
||||||
|
html = strings.ReplaceAll(html, fmt.Sprintf("\x00CODEBLOCK%d\x00", i),
|
||||||
|
fmt.Sprintf("<pre><code>%s</code></pre>", escapeHTML(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Restore inline code
|
||||||
|
for i, ic := range inlineCodes {
|
||||||
|
content := strings.Trim(ic, "`")
|
||||||
|
html = strings.ReplaceAll(html, fmt.Sprintf("\x00INLINECODE%d\x00", i),
|
||||||
|
fmt.Sprintf("<code>%s</code>", escapeHTML(content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: html,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToText strips Markdown formatting and extracts plain text.
|
||||||
|
func (t *MarkdownTool) handleToText(md string) (*ToolResult, error) {
|
||||||
|
text := md
|
||||||
|
|
||||||
|
// Remove code blocks
|
||||||
|
reFence := regexp.MustCompile("(?s)```[^`]*```")
|
||||||
|
text = reFence.ReplaceAllString(text, "[代码块]")
|
||||||
|
|
||||||
|
// Remove inline code
|
||||||
|
reInlineCode := regexp.MustCompile("`[^`]+`")
|
||||||
|
text = reInlineCode.ReplaceAllString(text, "[代码]")
|
||||||
|
|
||||||
|
// Remove images  - keep alt text
|
||||||
|
reImage := regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`)
|
||||||
|
text = reImage.ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
// Remove links [text](url) - keep text
|
||||||
|
reLink := regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
|
||||||
|
text = reLink.ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
// Remove bold/italic markers
|
||||||
|
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
// Remove strikethrough
|
||||||
|
text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
// Remove heading markers but keep the text
|
||||||
|
text = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(text, "")
|
||||||
|
|
||||||
|
// Remove horizontal rules
|
||||||
|
text = regexp.MustCompile(`(?m)^(---|\*\*\*|___)\s*$`).ReplaceAllString(text, "")
|
||||||
|
|
||||||
|
// Remove list markers
|
||||||
|
text = regexp.MustCompile(`(?m)^[\-*]\s+`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`(?m)^\d+\.\s+`).ReplaceAllString(text, "")
|
||||||
|
|
||||||
|
// Remove blockquote markers
|
||||||
|
text = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(text, "")
|
||||||
|
|
||||||
|
// Collapse multiple blank lines
|
||||||
|
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("纯文本提取结果 (%d 字符):\n\n%s",
|
||||||
|
len([]rune(text)), strings.TrimSpace(text)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExtractLinks extracts all [text](url) links from Markdown.
|
||||||
|
func (t *MarkdownTool) handleExtractLinks(md string) (*ToolResult, error) {
|
||||||
|
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
matches := reLink.FindAllStringSubmatch(md, -1)
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: "未找到任何链接",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("提取链接 (共 %d 个):\n\n", len(matches)))
|
||||||
|
for i, m := range matches {
|
||||||
|
result.WriteString(fmt.Sprintf("%d. [%s](%s)\n - 文本: %s\n - URL: %s\n\n",
|
||||||
|
i+1, m[1], m[2], m[1], m[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: strings.TrimSpace(result.String()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExtractCode extracts all code blocks from Markdown.
|
||||||
|
func (t *MarkdownTool) handleExtractCode(md string) (*ToolResult, error) {
|
||||||
|
reFence := regexp.MustCompile("(?s)```([^`]*)```")
|
||||||
|
matches := reFence.FindAllStringSubmatch(md, -1)
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: "未找到任何代码块",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("提取代码块 (共 %d 个):\n\n", len(matches)))
|
||||||
|
for i, m := range matches {
|
||||||
|
content := strings.TrimSpace(m[1])
|
||||||
|
lang := ""
|
||||||
|
if idx := strings.Index(content, "\n"); idx > 0 {
|
||||||
|
lang = strings.TrimSpace(content[:idx])
|
||||||
|
content = strings.TrimSpace(content[idx+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteString(fmt.Sprintf("--- 代码块 %d", i+1))
|
||||||
|
if lang != "" {
|
||||||
|
result.WriteString(fmt.Sprintf(" (语言: %s)", lang))
|
||||||
|
}
|
||||||
|
result.WriteString(fmt.Sprintf(" ---\n%s\n\n", truncateText(content, 500)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: strings.TrimSpace(result.String()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTableOfContents generates a table of contents from headings.
|
||||||
|
func (t *MarkdownTool) handleTableOfContents(md string) (*ToolResult, error) {
|
||||||
|
reHeading := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
|
||||||
|
matches := reHeading.FindAllStringSubmatch(md, -1)
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: "未找到任何标题,无法生成目录",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("文档目录 (共 %d 个标题):\n\n", len(matches)))
|
||||||
|
for _, m := range matches {
|
||||||
|
level := len(m[1])
|
||||||
|
title := strings.TrimSpace(m[2])
|
||||||
|
indent := strings.Repeat(" ", level-1)
|
||||||
|
result.WriteString(fmt.Sprintf("%s%s %s\n", indent, strings.Repeat("#", level), title))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "markdown",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Markdown helper functions below ---
|
||||||
|
|
||||||
|
// processLists wraps consecutive list items in <ul> or <ol> tags.
|
||||||
|
func (t *MarkdownTool) processLists(html, itemPattern, listTag string) string {
|
||||||
|
reItem := regexp.MustCompile(itemPattern + `(.+)$`)
|
||||||
|
lines := strings.Split(html, "\n")
|
||||||
|
result := make([]string, 0, len(lines))
|
||||||
|
|
||||||
|
inList := false
|
||||||
|
for _, line := range lines {
|
||||||
|
if reItem.MatchString(line) {
|
||||||
|
content := reItem.ReplaceAllString(line, "$1")
|
||||||
|
if !inList {
|
||||||
|
result = append(result, fmt.Sprintf("<%s>", listTag))
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
result = append(result, fmt.Sprintf("<li>%s</li>", content))
|
||||||
|
} else {
|
||||||
|
if inList {
|
||||||
|
result = append(result, fmt.Sprintf("</%s>", listTag))
|
||||||
|
inList = false
|
||||||
|
}
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inList {
|
||||||
|
result = append(result, fmt.Sprintf("</%s>", listTag))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapParagraphs wraps non-tag lines in <p> tags.
|
||||||
|
func (t *MarkdownTool) wrapParagraphs(html string) string {
|
||||||
|
lines := strings.Split(html, "\n")
|
||||||
|
result := make([]string, 0, len(lines))
|
||||||
|
|
||||||
|
skipTags := map[string]bool{
|
||||||
|
"<h1>": true, "<h2>": true, "<h3>": true, "<h4>": true, "<h5>": true, "<h6>": true,
|
||||||
|
"<hr>": true, "<ul>": true, "</ul>": true, "<ol>": true, "</ol>": true,
|
||||||
|
"<li>": true, "</li>": true, "<blockquote>": true, "</blockquote>": true,
|
||||||
|
"<pre>": true, "</pre>": true, "<img": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
result = append(result, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if line starts with an HTML tag
|
||||||
|
isTag := false
|
||||||
|
for tag := range skipTags {
|
||||||
|
if strings.HasPrefix(trimmed, tag) {
|
||||||
|
isTag = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isTag {
|
||||||
|
result = append(result, fmt.Sprintf("<p>%s</p>", trimmed))
|
||||||
|
} else {
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeHTML escapes special HTML characters.
|
||||||
|
func escapeHTML(s string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"&", "&"+"amp;",
|
||||||
|
"<", "&"+"lt;",
|
||||||
|
">", "&"+"gt;",
|
||||||
|
"\"", "&"+"quot;",
|
||||||
|
)
|
||||||
|
return replacer.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateText truncates text to maxLen runes, adding "..." if truncated.
|
||||||
|
func truncateText(s string, maxLen int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
mathrand "math/rand"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RandomTool provides random generation utilities for the LLM.
|
||||||
|
// Supports random numbers, UUIDs, passwords, and list operations.
|
||||||
|
type RandomTool struct{}
|
||||||
|
|
||||||
|
// NewRandomTool creates a random generation tool.
|
||||||
|
func NewRandomTool() *RandomTool {
|
||||||
|
return &RandomTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *RandomTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "random",
|
||||||
|
Description: "随机生成工具。生成随机数、UUID、安全密码,或从列表中随机选取/打乱元素。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"number", "uuid", "password", "pick", "shuffle"},
|
||||||
|
"description": "操作类型。number: 生成随机整数;uuid: 生成UUID v4;password: 生成安全密码;pick: 从列表随机选取;shuffle: 随机打乱列表",
|
||||||
|
},
|
||||||
|
"min": map[string]interface{}{
|
||||||
|
"type": "number",
|
||||||
|
"description": "随机数最小值(用于 number 操作),默认 0",
|
||||||
|
},
|
||||||
|
"max": map[string]interface{}{
|
||||||
|
"type": "number",
|
||||||
|
"description": "随机数最大值(用于 number 操作),默认 100",
|
||||||
|
},
|
||||||
|
"length": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "密码长度(用于 password 操作),默认 16",
|
||||||
|
},
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "列表项(用于 pick/shuffle 操作),字符串数组",
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"count": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "选取数量(用于 pick 操作),默认 1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs random generation operations.
|
||||||
|
func (t *RandomTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "number":
|
||||||
|
return t.handleNumber(arguments)
|
||||||
|
case "uuid":
|
||||||
|
return t.handleUUID()
|
||||||
|
case "password":
|
||||||
|
return t.handlePassword(arguments)
|
||||||
|
case "pick":
|
||||||
|
return t.handlePick(arguments)
|
||||||
|
case "shuffle":
|
||||||
|
return t.handleShuffle(arguments)
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: number, uuid, password, pick, shuffle", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNumber generates a random integer in [min, max].
|
||||||
|
func (t *RandomTool) handleNumber(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
minVal := getFloatArg(arguments, "min", 0)
|
||||||
|
maxVal := getFloatArg(arguments, "max", 100)
|
||||||
|
|
||||||
|
if minVal > maxVal {
|
||||||
|
minVal, maxVal = maxVal, minVal
|
||||||
|
}
|
||||||
|
|
||||||
|
minI := int64(minVal)
|
||||||
|
maxI := int64(maxVal)
|
||||||
|
|
||||||
|
// Use crypto/rand for secure random
|
||||||
|
rangeVal := maxI - minI + 1
|
||||||
|
if rangeVal <= 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: false,
|
||||||
|
Error: "无效的数值范围",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(rangeVal))
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to math/rand
|
||||||
|
result := minI + mathrand.Int63n(rangeVal)
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("随机整数 [%d, %d]: %d", minI, maxI, result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := minI + n.Int64()
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("随机整数 [%d, %d]: %d", minI, maxI, result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUUID generates a UUID v4 string.
|
||||||
|
func (t *RandomTool) handleUUID() (*ToolResult, error) {
|
||||||
|
uuid := make([]byte, 16)
|
||||||
|
_, err := rand.Read(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("生成UUID失败: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set version 4 and variant bits
|
||||||
|
uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4
|
||||||
|
uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant 10
|
||||||
|
|
||||||
|
uuidStr := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||||
|
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("UUID v4: %s", uuidStr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePassword generates a secure random password.
|
||||||
|
func (t *RandomTool) handlePassword(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
length := getIntArg(arguments, "length", 16)
|
||||||
|
if length < 4 {
|
||||||
|
length = 16
|
||||||
|
}
|
||||||
|
if length > 128 {
|
||||||
|
length = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
uppercase := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
lowercase := "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
digits := "0123456789"
|
||||||
|
symbols := "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||||
|
|
||||||
|
allChars := uppercase + lowercase + digits + symbols
|
||||||
|
|
||||||
|
password := make([]byte, length)
|
||||||
|
|
||||||
|
// Ensure at least one of each character type
|
||||||
|
password[0] = uppercase[secureIndex(len(uppercase))]
|
||||||
|
password[1] = lowercase[secureIndex(len(lowercase))]
|
||||||
|
password[2] = digits[secureIndex(len(digits))]
|
||||||
|
password[3] = symbols[secureIndex(len(symbols))]
|
||||||
|
|
||||||
|
// Fill remaining with random characters from all sets
|
||||||
|
for i := 4; i < length; i++ {
|
||||||
|
password[i] = allChars[secureIndex(len(allChars))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the password
|
||||||
|
shuffleBytes(password)
|
||||||
|
|
||||||
|
passwordStr := string(password)
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("安全密码 (长度: %d):\n%s\n\n字符集: 大写字母 + 小写字母 + 数字 + 特殊符号",
|
||||||
|
length, passwordStr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePick randomly picks items from a list.
|
||||||
|
func (t *RandomTool) handlePick(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
items := getStringSliceArg(arguments, "items")
|
||||||
|
if len(items) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 items 参数或列表为空",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
count := getIntArg(arguments, "count", 1)
|
||||||
|
if count < 1 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
if count > len(items) {
|
||||||
|
count = len(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle indices and pick first 'count'
|
||||||
|
indices := make([]int, len(items))
|
||||||
|
for i := range indices {
|
||||||
|
indices[i] = i
|
||||||
|
}
|
||||||
|
shuffleInts(indices)
|
||||||
|
|
||||||
|
picked := make([]string, 0, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
picked = append(picked, items[indices[i]])
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("从 %d 个选项中随机选取 %d 个:\n", len(items), count))
|
||||||
|
for i, p := range picked {
|
||||||
|
result.WriteString(fmt.Sprintf(" %d. %s\n", i+1, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleShuffle randomly shuffles a list.
|
||||||
|
func (t *RandomTool) handleShuffle(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
items := getStringSliceArg(arguments, "items")
|
||||||
|
if len(items) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 items 参数或列表为空",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a copy and shuffle
|
||||||
|
shuffled := make([]string, len(items))
|
||||||
|
copy(shuffled, items)
|
||||||
|
shuffleStrings(shuffled)
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("随机打乱结果 (共 %d 项):\n", len(shuffled)))
|
||||||
|
for i, s := range shuffled {
|
||||||
|
result.WriteString(fmt.Sprintf(" %d. %s\n", i+1, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "random",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper functions ---
|
||||||
|
|
||||||
|
// getFloatArg extracts a float64 argument with fallback.
|
||||||
|
func getFloatArg(arguments map[string]interface{}, key string, fallback float64) float64 {
|
||||||
|
if v, ok := arguments[key]; ok {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return val
|
||||||
|
case int:
|
||||||
|
return float64(val)
|
||||||
|
case int64:
|
||||||
|
return float64(val)
|
||||||
|
case json.Number:
|
||||||
|
f, err := val.Float64()
|
||||||
|
if err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIntArg extracts an int argument with fallback.
|
||||||
|
func getIntArg(arguments map[string]interface{}, key string, fallback int) int {
|
||||||
|
if v, ok := arguments[key]; ok {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(val)
|
||||||
|
case int:
|
||||||
|
return val
|
||||||
|
case int64:
|
||||||
|
return int(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStringSliceArg extracts a string slice argument.
|
||||||
|
func getStringSliceArg(arguments map[string]interface{}, key string) []string {
|
||||||
|
if v, ok := arguments[key]; ok {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
result := make([]string, 0, len(val))
|
||||||
|
for _, item := range val {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
result = append(result, s)
|
||||||
|
} else {
|
||||||
|
result = append(result, fmt.Sprintf("%v", item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []string:
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureIndex returns a cryptographically secure random index in [0, max).
|
||||||
|
func secureIndex(max int) int {
|
||||||
|
if max <= 1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||||||
|
if err != nil {
|
||||||
|
return mathrand.Intn(max)
|
||||||
|
}
|
||||||
|
return int(n.Int64())
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffleBytes shuffles a byte slice using Fisher-Yates with crypto/rand.
|
||||||
|
func shuffleBytes(data []byte) {
|
||||||
|
for i := len(data) - 1; i > 0; i-- {
|
||||||
|
j := secureIndex(i + 1)
|
||||||
|
data[i], data[j] = data[j], data[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffleInts shuffles an int slice using Fisher-Yates with crypto/rand.
|
||||||
|
func shuffleInts(data []int) {
|
||||||
|
for i := len(data) - 1; i > 0; i-- {
|
||||||
|
j := secureIndex(i + 1)
|
||||||
|
data[i], data[j] = data[j], data[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffleStrings shuffles a string slice using Fisher-Yates with crypto/rand.
|
||||||
|
func shuffleStrings(data []string) {
|
||||||
|
for i := len(data) - 1; i > 0; i-- {
|
||||||
|
j := secureIndex(i + 1)
|
||||||
|
data[i], data[j] = data[j], data[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextTool provides text processing operations for the LLM.
|
||||||
|
// Supports counting, summarizing, translation, and pattern extraction.
|
||||||
|
type TextTool struct{}
|
||||||
|
|
||||||
|
// NewTextTool creates a text processing tool.
|
||||||
|
func NewTextTool() *TextTool {
|
||||||
|
return &TextTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns the tool definition for LLM function calling.
|
||||||
|
func (t *TextTool) Definition() ToolDefinition {
|
||||||
|
return ToolDefinition{
|
||||||
|
Name: "text",
|
||||||
|
Description: "文本处理工具。统计文本、生成摘要、翻译文本、正则提取信息。用于处理用户提供的文本内容。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"count", "summarize", "translate", "extract"},
|
||||||
|
"description": "操作类型。count: 统计字符/单词/行/段落数;summarize: 提取首段+关键句生成简单摘要;translate: 翻译文本(需指定target_lang);extract: 正则提取邮箱/电话/URL等",
|
||||||
|
},
|
||||||
|
"text": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "输入文本,需要处理的文本内容",
|
||||||
|
},
|
||||||
|
"target_lang": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"en", "zh", "ja", "ko", "fr", "de"},
|
||||||
|
"description": "翻译目标语言代码。en: 英语, zh: 中文, ja: 日语, ko: 韩语, fr: 法语, de: 德语",
|
||||||
|
},
|
||||||
|
"pattern": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "正则表达式模式,用于 extract 操作。常用预设: email(邮箱), phone(电话), url(网址)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "text"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs text processing operations.
|
||||||
|
func (t *TextTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
action, ok := arguments["action"].(string)
|
||||||
|
if !ok || action == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 action 参数",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text, ok := arguments["text"].(string)
|
||||||
|
if !ok || strings.TrimSpace(text) == "" {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: false,
|
||||||
|
Error: "缺少 text 参数或文本为空",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "count":
|
||||||
|
return t.handleCount(text)
|
||||||
|
case "summarize":
|
||||||
|
return t.handleSummarize(text)
|
||||||
|
case "translate":
|
||||||
|
return t.handleTranslate(arguments)
|
||||||
|
case "extract":
|
||||||
|
return t.handleExtract(arguments)
|
||||||
|
default:
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("未知操作: %s,支持: count, summarize, translate, extract", action),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCount counts characters, words, lines, and paragraphs in the text.
|
||||||
|
func (t *TextTool) handleCount(text string) (*ToolResult, error) {
|
||||||
|
charCount := len([]rune(text))
|
||||||
|
byteCount := len(text)
|
||||||
|
|
||||||
|
words := strings.Fields(text)
|
||||||
|
wordCount := len(words)
|
||||||
|
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
lineCount := len(lines)
|
||||||
|
|
||||||
|
// Count paragraphs (separated by double newlines)
|
||||||
|
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(text, -1)
|
||||||
|
paraCount := 0
|
||||||
|
for _, p := range paragraphs {
|
||||||
|
if strings.TrimSpace(p) != "" {
|
||||||
|
paraCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count Chinese characters
|
||||||
|
chineseCount := 0
|
||||||
|
for _, r := range text {
|
||||||
|
if unicode.Is(unicode.Han, r) {
|
||||||
|
chineseCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("文本统计结果:\n- 字符数 (含空格): %d\n- 字符数 (不含空格): %d\n- 字节数: %d\n- 单词数: %d\n- 行数: %d\n- 段落数: %d\n- 中文字符数: %d",
|
||||||
|
charCount, len([]rune(strings.ReplaceAll(text, " ", ""))),
|
||||||
|
byteCount, wordCount, lineCount, paraCount, chineseCount),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSummarize generates a simple summary by extracting the first paragraph and key sentences.
|
||||||
|
func (t *TextTool) handleSummarize(text string) (*ToolResult, error) {
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString("文本摘要:\n\n")
|
||||||
|
|
||||||
|
// Extract first paragraph
|
||||||
|
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(text, -1)
|
||||||
|
var firstPara string
|
||||||
|
for _, p := range paragraphs {
|
||||||
|
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||||
|
firstPara = trimmed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstPara != "" {
|
||||||
|
result.WriteString("【首段】\n")
|
||||||
|
// Truncate if very long
|
||||||
|
runes := []rune(firstPara)
|
||||||
|
if len(runes) > 300 {
|
||||||
|
firstPara = string(runes[:300]) + "..."
|
||||||
|
}
|
||||||
|
result.WriteString(firstPara)
|
||||||
|
result.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key sentences (longer sentences with important keywords)
|
||||||
|
sentences := t.splitSentences(text)
|
||||||
|
keySentences := t.extractKeySentences(sentences, 5)
|
||||||
|
|
||||||
|
if len(keySentences) > 0 {
|
||||||
|
result.WriteString("【关键句】\n")
|
||||||
|
for i, s := range keySentences {
|
||||||
|
result.WriteString(fmt.Sprintf("%d. %s\n", i+1, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall stats
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
words := strings.Fields(text)
|
||||||
|
result.WriteString(fmt.Sprintf("\n【概况】共 %d 段、%d 句、%d 词、%d 行",
|
||||||
|
len(paragraphs), len(sentences), len(words), len(lines)))
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSentences splits text into sentences based on punctuation.
|
||||||
|
func (t *TextTool) splitSentences(text string) []string {
|
||||||
|
re := regexp.MustCompile(`[^。!?.!?\n]+[。!?.!?\n]?`)
|
||||||
|
return re.FindAllString(text, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractKeySentences selects the most informative sentences (longer ones with keyword hints).
|
||||||
|
func (t *TextTool) extractKeySentences(sentences []string, maxCount int) []string {
|
||||||
|
type scored struct {
|
||||||
|
text string
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
|
||||||
|
var scoredList []scored
|
||||||
|
keywords := []string{"重要", "关键", "核心", "主要", "首先", "最后", "因此", "所以", "总结",
|
||||||
|
"important", "key", "critical", "significant", "therefore", "conclusion", "summary"}
|
||||||
|
|
||||||
|
for _, s := range sentences {
|
||||||
|
trimmed := strings.TrimSpace(s)
|
||||||
|
if len([]rune(trimmed)) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
score := len([]rune(trimmed)) // longer sentences are more likely informative
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if strings.Contains(lower, kw) {
|
||||||
|
score += 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scoredList = append(scoredList, scored{text: trimmed, score: score})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending (simple bubble sort for small lists)
|
||||||
|
for i := 0; i < len(scoredList); i++ {
|
||||||
|
for j := i + 1; j < len(scoredList); j++ {
|
||||||
|
if scoredList[j].score > scoredList[i].score {
|
||||||
|
scoredList[i], scoredList[j] = scoredList[j], scoredList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, maxCount)
|
||||||
|
for i := 0; i < len(scoredList) && i < maxCount; i++ {
|
||||||
|
result = append(result, scoredList[i].text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTranslate provides a translation placeholder (actual translation requires LLM).
|
||||||
|
func (t *TextTool) handleTranslate(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
text, _ := arguments["text"].(string)
|
||||||
|
targetLang, _ := arguments["target_lang"].(string)
|
||||||
|
if targetLang == "" {
|
||||||
|
targetLang = "zh"
|
||||||
|
}
|
||||||
|
|
||||||
|
langNames := map[string]string{
|
||||||
|
"en": "英语",
|
||||||
|
"zh": "中文",
|
||||||
|
"ja": "日语",
|
||||||
|
"ko": "韩语",
|
||||||
|
"fr": "法语",
|
||||||
|
"de": "德语",
|
||||||
|
}
|
||||||
|
|
||||||
|
langName, ok := langNames[targetLang]
|
||||||
|
if !ok {
|
||||||
|
langName = targetLang
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("【翻译请求】\n目标语言: %s (%s)\n原文 (%d 字符):\n---\n%s\n---\n\n提示: 实际翻译由LLM完成,请基于以上原文和目标语言进行翻译。",
|
||||||
|
langName, targetLang, len([]rune(text)), text),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExtract extracts patterns like emails, phones, URLs from text using regex.
|
||||||
|
func (t *TextTool) handleExtract(arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
text, _ := arguments["text"].(string)
|
||||||
|
pattern, _ := arguments["pattern"].(string)
|
||||||
|
|
||||||
|
// Predefined patterns
|
||||||
|
presets := map[string]string{
|
||||||
|
"email": `[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`,
|
||||||
|
"phone": `(?:\+?86[\-\s]?)?1[3-9]\d{9}`,
|
||||||
|
"url": `https?://[^\s<>"{}|\\^` + "`" + `\[\]]+`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if preset, ok := presets[strings.ToLower(pattern)]; ok {
|
||||||
|
pattern = preset
|
||||||
|
}
|
||||||
|
|
||||||
|
if pattern == "" {
|
||||||
|
// Extract all common patterns when no specific pattern given
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString("文本提取结果:\n\n")
|
||||||
|
|
||||||
|
for name, p := range presets {
|
||||||
|
re, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := re.FindAllString(text, -1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
result.WriteString(fmt.Sprintf("【%s】(共 %d 个):\n", name, len(matches)))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, m := range matches {
|
||||||
|
if !seen[m] {
|
||||||
|
result.WriteString(fmt.Sprintf(" - %s\n", m))
|
||||||
|
seen[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Len() == len("文本提取结果:\n\n") {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: "未提取到匹配的内容(邮箱、电话、URL)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom regex pattern
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("正则表达式无效: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := re.FindAllString(text, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: fmt.Sprintf("未找到匹配模式 '%s' 的内容", pattern),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("正则提取结果 (模式: %s, 共 %d 个匹配):\n", pattern, len(matches)))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, m := range matches {
|
||||||
|
if !seen[m] {
|
||||||
|
result.WriteString(fmt.Sprintf(" - %s\n", m))
|
||||||
|
seen[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: "text",
|
||||||
|
Success: true,
|
||||||
|
Data: result.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+396
-89
@@ -399,6 +399,26 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.iot-last-update { font-size: 11px; color: var(--text3); }
|
.iot-last-update { font-size: 11px; color: var(--text3); }
|
||||||
|
|
||||||
|
/* ========== 记忆卡片样式 ========== */
|
||||||
|
.mem-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.mem-card:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,.35);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
.mem-card.mem-card-high:hover {
|
||||||
|
border-color: #f59e0b !important;
|
||||||
|
box-shadow: 0 4px 24px rgba(245,158,11,.25);
|
||||||
|
}
|
||||||
|
.mem-cat-tab.active {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -495,6 +515,15 @@ const STATE = {
|
|||||||
dashboardRenderCount: 0,
|
dashboardRenderCount: 0,
|
||||||
// 资源使用 60s 滑动窗口历史 (Bug 6)
|
// 资源使用 60s 滑动窗口历史 (Bug 6)
|
||||||
resourceHistory: {},
|
resourceHistory: {},
|
||||||
|
// 记忆面板状态
|
||||||
|
memoryCache: [],
|
||||||
|
memoryUserId: 'admin_admin',
|
||||||
|
memoryFilterCategory: 'all',
|
||||||
|
memorySortBy: 'importance',
|
||||||
|
memorySortDir: 'desc',
|
||||||
|
memoryFilterImportance: 0,
|
||||||
|
memorySearchText: '',
|
||||||
|
memoryPanelInitialized: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== WebSocket ==========
|
// ========== WebSocket ==========
|
||||||
@@ -1038,92 +1067,175 @@ function renderDashboardSvcCards(svcs) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 记忆分类颜色映射 ==========
|
||||||
|
const MEMORY_CAT_COLORS = {
|
||||||
|
'user_preference': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '用户偏好', icon: '💜' },
|
||||||
|
'personal_info': { bg: 'rgba(59,130,246,.15)', text: '#3b82f6', name: '个人信息', icon: '👤' },
|
||||||
|
'conversation': { bg: 'rgba(34,197,94,.15)', text: '#22c55e', name: '对话摘要', icon: '💬' },
|
||||||
|
'knowledge': { bg: 'rgba(249,115,22,.15)', text: '#f97316', name: '知识信息', icon: '📚' },
|
||||||
|
'event': { bg: 'rgba(239,68,68,.15)', text: '#ef4444', name: '事件记录', icon: '📅' },
|
||||||
|
'task': { bg: 'rgba(234,179,8,.15)', text: '#eab308', name: '任务计划', icon: '✅' },
|
||||||
|
'relationship': { bg: 'rgba(236,72,153,.15)', text: '#ec4899', name: '关系情感', icon: '💕' },
|
||||||
|
// 向后兼容旧分类
|
||||||
|
'preference': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '偏好', icon: '💜' },
|
||||||
|
'fact': { bg: 'rgba(59,130,246,.15)', text: '#3b82f6', name: '事实', icon: '👤' },
|
||||||
|
'experience': { bg: 'rgba(249,115,22,.15)', text: '#f97316', name: '经验', icon: '📚' },
|
||||||
|
'other': { bg: 'rgba(139,148,158,.15)', text: '#8b949e', name: '其他', icon: '📌' },
|
||||||
|
'habit': { bg: 'rgba(168,85,247,.15)', text: '#a855f7', name: '习惯', icon: '💜' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCatColor(cat) {
|
||||||
|
return MEMORY_CAT_COLORS[cat] || MEMORY_CAT_COLORS['other'];
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 面板2: 记忆管理 ==========
|
// ========== 面板2: 记忆管理 ==========
|
||||||
function renderMemoryPanel() {
|
function renderMemoryPanel() {
|
||||||
|
const isFirst = !STATE.memoryPanelInitialized;
|
||||||
|
STATE.memoryPanelInitialized = true;
|
||||||
|
|
||||||
|
// 首次渲染完整 DOM 结构
|
||||||
|
if (isFirst) {
|
||||||
document.getElementById('panel-memory').innerHTML = `
|
document.getElementById('panel-memory').innerHTML = `
|
||||||
<div class="cards-grid cards-2">
|
<!-- 搜索 & 添加行 -->
|
||||||
<!-- 搜索区域 -->
|
<div class="cards-grid cards-2" style="margin-bottom:14px">
|
||||||
<div class="card">
|
<div class="card" style="margin:0">
|
||||||
<div class="card-header"><span class="card-title">🔍 搜索记忆</span></div>
|
<div class="card-header"><span class="card-title">🔍 搜索与筛选</span></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group"><label>用户ID</label><input type="text" id="mem-user-id" placeholder="admin_admin" value="admin_admin"></div>
|
<div class="form-group" style="flex:1">
|
||||||
<div class="form-group"><label>关键词</label><input type="text" id="mem-search-q" placeholder="输入搜索关键词..."></div>
|
<label>用户ID</label>
|
||||||
|
<input type="text" id="mem-user-id" placeholder="admin_admin" value="${escHtml(STATE.memoryUserId)}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:2">
|
||||||
|
<label>全文搜索</label>
|
||||||
|
<input type="text" id="mem-search-text" placeholder="输入关键词搜索记忆内容..." value="${escHtml(STATE.memorySearchText)}"
|
||||||
|
oninput="STATE.memorySearchText=this.value;filterAndRenderMemories()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top:4px">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>最低重要性 ≥ <span id="mem-imp-val">${STATE.memoryFilterImportance}</span></label>
|
||||||
|
<input type="range" id="mem-filter-importance" min="0" max="10" value="${STATE.memoryFilterImportance}"
|
||||||
|
oninput="document.getElementById('mem-imp-val').textContent=this.value;STATE.memoryFilterImportance=parseInt(this.value);filterAndRenderMemories()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1;display:flex;align-items:flex-end">
|
||||||
|
<button class="btn btn-accent btn-sm" onclick="loadMemories()" style="width:100%">🔍 查询记忆</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" style="margin-top:4px">
|
|
||||||
<button class="btn btn-accent btn-sm" onclick="searchMemory()">🔍 搜索</button>
|
|
||||||
<button class="btn btn-sm" onclick="listMemory()">📋 列表全部</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加记忆 -->
|
<div class="card" style="margin:0">
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><span class="card-title">➕ 添加记忆</span></div>
|
<div class="card-header"><span class="card-title">➕ 添加记忆</span></div>
|
||||||
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin_admin" value="admin_admin"></div>
|
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin_admin" value="admin_admin"></div>
|
||||||
<div class="form-group"><label>内容</label><textarea id="mem-add-content" placeholder="输入记忆内容..."></textarea></div>
|
<div class="form-group"><label>内容</label><textarea id="mem-add-content" placeholder="输入记忆内容..." rows="2"></textarea></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>分类</label>
|
<label>分类</label>
|
||||||
<select id="mem-add-category">
|
<select id="mem-add-category">
|
||||||
<option value="preference">偏好</option>
|
<option value="user_preference">用户偏好</option>
|
||||||
<option value="fact">事实</option>
|
<option value="personal_info">个人信息</option>
|
||||||
<option value="experience">经验</option>
|
<option value="conversation">对话摘要</option>
|
||||||
<option value="other">其他</option>
|
<option value="knowledge">知识信息</option>
|
||||||
|
<option value="event">事件记录</option>
|
||||||
|
<option value="task">任务计划</option>
|
||||||
|
<option value="relationship">关系情感</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>优先级: <span id="mem-priority-val">3</span></label>
|
<label>重要程度: <span id="mem-priority-val">5</span></label>
|
||||||
<input type="range" id="mem-add-priority" min="1" max="5" value="3" oninput="document.getElementById('mem-priority-val').textContent=this.value">
|
<input type="range" id="mem-add-importance" min="1" max="10" value="5" oninput="document.getElementById('mem-priority-val').textContent=this.value">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-accent btn-sm" onclick="addMemory()">➕ 添加</button>
|
<button class="btn btn-accent btn-sm" onclick="addMemory()">➕ 添加</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 结果表格 -->
|
<!-- 统计面板 -->
|
||||||
<div class="card">
|
<div class="card" id="mem-stats-card" style="margin-bottom:14px">
|
||||||
<div class="card-header">
|
<div class="card-header"><span class="card-title">📊 记忆统计</span></div>
|
||||||
<span class="card-title">📋 记忆列表</span>
|
<div id="mem-stats-content">
|
||||||
<span style="display:flex;align-items:center;gap:8px">
|
<div class="empty-state"><div class="icon">📊</div>加载记忆后显示统计</div>
|
||||||
<select id="mem-sort-order" onchange="sortAndRenderMemories()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-size:11px">
|
</div>
|
||||||
<option value="desc">🕐 最新优先</option>
|
</div>
|
||||||
<option value="asc">🕐 最早优先</option>
|
|
||||||
|
<!-- 分类筛选标签栏 -->
|
||||||
|
<div class="card" style="margin-bottom:10px;padding:12px 16px">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
|
||||||
|
<div id="mem-cat-tabs" style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
|
<!-- 动态填充 -->
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<select id="mem-sort-by" onchange="STATE.memorySortBy=this.value;filterAndRenderMemories()" style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">
|
||||||
|
<option value="importance" ${STATE.memorySortBy==='importance'?'selected':''}>⭐ 重要性</option>
|
||||||
|
<option value="created_at" ${STATE.memorySortBy==='created_at'?'selected':''}>🕐 创建时间</option>
|
||||||
|
<option value="updated_at" ${STATE.memorySortBy==='updated_at'?'selected':''}>🕐 更新时间</option>
|
||||||
|
<option value="category" ${STATE.memorySortBy==='category'?'selected':''}>🏷️ 分类</option>
|
||||||
|
<option value="access_count" ${STATE.memorySortBy==='access_count'?'selected':''}>📊 访问次数</option>
|
||||||
</select>
|
</select>
|
||||||
<span id="mem-result-count" style="font-size:11px;color:var(--text2)"></span>
|
<select id="mem-sort-dir" onchange="STATE.memorySortDir=this.value;filterAndRenderMemories()" style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">
|
||||||
</span>
|
<option value="desc" ${STATE.memorySortDir==='desc'?'selected':''}>↓ 降序</option>
|
||||||
|
<option value="asc" ${STATE.memorySortDir==='asc'?'selected':''}>↑ 升序</option>
|
||||||
|
</select>
|
||||||
|
<span id="mem-result-count" style="font-size:11px;color:var(--text2);white-space:nowrap"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>话题 (会话)</th><th>创建时间</th><th style="width:50px">操作</th></tr></thead>
|
|
||||||
<tbody id="mem-table-body">
|
|
||||||
<tr><td colspan="7"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 记忆卡片网格 -->
|
||||||
|
<div id="mem-cards-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px">
|
||||||
|
<div class="empty-state" style="grid-column:1/-1"><div class="icon">🧠</div>点击「查询记忆」加载记忆数据</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 初始化分类标签
|
||||||
|
renderCategoryTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchMemory() {
|
// 如果有缓存数据,直接渲染
|
||||||
const userId = document.getElementById('mem-user-id').value.trim();
|
if (STATE.memoryCache.length > 0) {
|
||||||
const q = document.getElementById('mem-search-q').value.trim();
|
filterAndRenderMemories();
|
||||||
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
}
|
||||||
if (!q) { showToast('请输入搜索关键词', 'error'); return; }
|
|
||||||
|
|
||||||
const data = await api(`/api/memory/search?user_id=${encodeURIComponent(userId)}&q=${encodeURIComponent(q)}`);
|
|
||||||
renderMemoryResults(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listMemory() {
|
function renderCategoryTabs() {
|
||||||
|
const container = document.getElementById('mem-cat-tabs');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: 'all', name: '全部', icon: '📋' },
|
||||||
|
{ key: 'user_preference', name: '用户偏好', icon: '💜' },
|
||||||
|
{ key: 'personal_info', name: '个人信息', icon: '👤' },
|
||||||
|
{ key: 'conversation', name: '对话摘要', icon: '💬' },
|
||||||
|
{ key: 'knowledge', name: '知识信息', icon: '📚' },
|
||||||
|
{ key: 'event', name: '事件记录', icon: '📅' },
|
||||||
|
{ key: 'task', name: '任务计划', icon: '✅' },
|
||||||
|
{ key: 'relationship', name: '关系情感', icon: '💕' },
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = categories.map(c => {
|
||||||
|
const active = STATE.memoryFilterCategory === c.key;
|
||||||
|
const style = active
|
||||||
|
? 'background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600'
|
||||||
|
: 'background:var(--bg3);color:var(--text2);border-color:var(--border)';
|
||||||
|
return `<button class="mem-cat-tab" data-cat="${c.key}"
|
||||||
|
style="padding:4px 12px;border-radius:16px;border:1px solid;cursor:pointer;font-size:11px;transition:all .15s;font-family:inherit;${style}"
|
||||||
|
onmouseenter="if(!this.classList.contains('active')){this.style.background='var(--bg4)';this.style.color='var(--text)'}"
|
||||||
|
onmouseleave="if(!this.classList.contains('active')){this.style.background='var(--bg3)';this.style.color='var(--text2)'}"
|
||||||
|
onclick="switchMemoryCategory('${c.key}')">${c.icon} ${c.name}</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMemoryCategory(cat) {
|
||||||
|
STATE.memoryFilterCategory = cat;
|
||||||
|
renderCategoryTabs();
|
||||||
|
filterAndRenderMemories();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMemories() {
|
||||||
const userId = document.getElementById('mem-user-id').value.trim();
|
const userId = document.getElementById('mem-user-id').value.trim();
|
||||||
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
||||||
|
STATE.memoryUserId = userId;
|
||||||
|
|
||||||
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}`);
|
const data = await api(`/api/memory/list?user_id=${encodeURIComponent(userId)}`);
|
||||||
renderMemoryResults(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMemoryResults(data) {
|
|
||||||
const tbody = document.getElementById('mem-table-body');
|
|
||||||
const countEl = document.getElementById('mem-result-count');
|
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
let hint = '';
|
let hint = '';
|
||||||
@@ -1134,72 +1246,266 @@ function renderMemoryResults(data) {
|
|||||||
} else if (data.status === 502) {
|
} else if (data.status === 502) {
|
||||||
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
|
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
|
||||||
}
|
}
|
||||||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div></td></tr>`;
|
const grid = document.getElementById('mem-cards-grid');
|
||||||
countEl.textContent = '';
|
if (grid) grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div>`;
|
||||||
|
document.getElementById('mem-result-count').textContent = '';
|
||||||
STATE.memoryCache = [];
|
STATE.memoryCache = [];
|
||||||
|
renderStatsPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容不同返回格式
|
|
||||||
let memories = [];
|
let memories = [];
|
||||||
if (Array.isArray(data)) memories = data;
|
if (Array.isArray(data)) memories = data;
|
||||||
else if (data.memories) memories = data.memories;
|
else if (data.memories) memories = data.memories;
|
||||||
else if (data.results) memories = data.results;
|
else if (data.results) memories = data.results;
|
||||||
|
|
||||||
// 缓存记忆数据用于排序
|
|
||||||
STATE.memoryCache = memories;
|
STATE.memoryCache = memories;
|
||||||
sortAndRenderMemories();
|
filterAndRenderMemories();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortAndRenderMemories() {
|
async function searchMemory() {
|
||||||
const tbody = document.getElementById('mem-table-body');
|
const userId = document.getElementById('mem-user-id').value.trim();
|
||||||
const countEl = document.getElementById('mem-result-count');
|
const q = STATE.memorySearchText || document.getElementById('mem-search-text')?.value?.trim() || '';
|
||||||
const sortOrder = document.getElementById('mem-sort-order')?.value || 'desc';
|
if (!userId) { showToast('请输入用户ID', 'error'); return; }
|
||||||
|
if (!q) { showToast('请输入搜索关键词', 'error'); return; }
|
||||||
|
|
||||||
let memories = [...(STATE.memoryCache || [])];
|
STATE.memoryUserId = userId;
|
||||||
|
|
||||||
// 按创建时间排序
|
const data = await api(`/api/memory/search?user_id=${encodeURIComponent(userId)}&q=${encodeURIComponent(q)}`);
|
||||||
memories.sort((a, b) => {
|
|
||||||
const ta = new Date(a.created_at || 0).getTime();
|
|
||||||
const tb = new Date(b.created_at || 0).getTime();
|
|
||||||
return sortOrder === 'asc' ? ta - tb : tb - ta;
|
|
||||||
});
|
|
||||||
|
|
||||||
countEl.textContent = `共 ${memories.length} 条`;
|
if (data.error) {
|
||||||
|
let hint = '';
|
||||||
if (memories.length === 0) {
|
if (data.errorType === 'gateway_not_running' || data.errorType === 'gateway_auth_failed') {
|
||||||
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
|
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 Gateway 服务</span>';
|
||||||
|
} else if (data.errorType === 'gateway_unreachable') {
|
||||||
|
hint = '<br><span style="font-size:11px">💡 提示: Gateway 服务无响应,请检查网络连接和服务状态</span>';
|
||||||
|
} else if (data.status === 502) {
|
||||||
|
hint = '<br><span style="font-size:11px">💡 提示: 请确认 Gateway 和 AI-Core 服务已启动</span>';
|
||||||
|
}
|
||||||
|
const grid = document.getElementById('mem-cards-grid');
|
||||||
|
if (grid) grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="icon">⚠️</div>${escHtml(data.error)}${hint}</div>`;
|
||||||
|
document.getElementById('mem-result-count').textContent = '';
|
||||||
|
STATE.memoryCache = [];
|
||||||
|
renderStatsPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = memories.map(m => {
|
let memories = [];
|
||||||
// 会话ID 简短显示
|
if (Array.isArray(data)) memories = data;
|
||||||
const sid = m.session_id || '—';
|
else if (data.memories) memories = data.memories;
|
||||||
const sidShort = sid.length > 16 ? sid.substring(0, 14) + '…' : sid;
|
else if (data.results) memories = data.results;
|
||||||
|
|
||||||
|
STATE.memoryCache = memories;
|
||||||
|
filterAndRenderMemories();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧的 listMemory 调用
|
||||||
|
async function listMemory() {
|
||||||
|
loadMemories();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAndRenderMemories() {
|
||||||
|
const memories = STATE.memoryCache || [];
|
||||||
|
|
||||||
|
// 1. 分类筛选
|
||||||
|
let filtered = memories;
|
||||||
|
if (STATE.memoryFilterCategory !== 'all') {
|
||||||
|
filtered = filtered.filter(m => m.category === STATE.memoryFilterCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 重要性筛选
|
||||||
|
if (STATE.memoryFilterImportance > 0) {
|
||||||
|
filtered = filtered.filter(m => (m.importance || 0) >= STATE.memoryFilterImportance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 全文搜索 (客户端二次过滤)
|
||||||
|
if (STATE.memorySearchText) {
|
||||||
|
const q = STATE.memorySearchText.toLowerCase();
|
||||||
|
filtered = filtered.filter(m => {
|
||||||
|
const content = (m.content || '').toLowerCase();
|
||||||
|
const summary = (m.summary || '').toLowerCase();
|
||||||
|
const keywords = (m.keywords || []).join(' ').toLowerCase();
|
||||||
|
return content.includes(q) || summary.includes(q) || keywords.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 排序
|
||||||
|
const sortBy = STATE.memorySortBy;
|
||||||
|
const sortDir = STATE.memorySortDir === 'asc' ? 1 : -1;
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let va, vb;
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'importance':
|
||||||
|
va = a.importance || 0; vb = b.importance || 0; break;
|
||||||
|
case 'created_at':
|
||||||
|
va = new Date(a.created_at || 0).getTime(); vb = new Date(b.created_at || 0).getTime(); break;
|
||||||
|
case 'updated_at':
|
||||||
|
va = new Date(a.updated_at || a.created_at || 0).getTime();
|
||||||
|
vb = new Date(b.updated_at || b.created_at || 0).getTime(); break;
|
||||||
|
case 'category':
|
||||||
|
va = a.category || ''; vb = b.category || '';
|
||||||
|
return sortDir * va.localeCompare(vb);
|
||||||
|
case 'access_count':
|
||||||
|
va = a.access_count || 0; vb = b.access_count || 0; break;
|
||||||
|
default:
|
||||||
|
va = a.importance || 0; vb = b.importance || 0;
|
||||||
|
}
|
||||||
|
return sortDir * (va - vb);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 渲染统计
|
||||||
|
renderStatsPanel();
|
||||||
|
|
||||||
|
// 6. 渲染卡片
|
||||||
|
renderMemoryCards(filtered);
|
||||||
|
|
||||||
|
// 更新计数
|
||||||
|
const countEl = document.getElementById('mem-result-count');
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = `显示 ${filtered.length} / ${memories.length} 条`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatsPanel() {
|
||||||
|
const container = document.getElementById('mem-stats-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const memories = STATE.memoryCache || [];
|
||||||
|
if (memories.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>暂无记忆数据</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算各分类数量
|
||||||
|
const catCount = {};
|
||||||
|
let totalImportance = 0;
|
||||||
|
let totalAccess = 0;
|
||||||
|
memories.forEach(m => {
|
||||||
|
const cat = m.category || 'other';
|
||||||
|
catCount[cat] = (catCount[cat] || 0) + 1;
|
||||||
|
totalImportance += (m.importance || 0);
|
||||||
|
totalAccess += (m.access_count || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgImportance = (totalImportance / memories.length).toFixed(1);
|
||||||
|
const maxCatCount = Math.max(1, ...Object.values(catCount));
|
||||||
|
|
||||||
|
// 分类分布条
|
||||||
|
const catOrder = ['user_preference', 'personal_info', 'conversation', 'knowledge', 'event', 'task', 'relationship'];
|
||||||
|
const barHtml = catOrder.map(cat => {
|
||||||
|
const count = catCount[cat] || 0;
|
||||||
|
const pct = Math.round((count / Math.max(1, memories.length)) * 100);
|
||||||
|
const cc = getCatColor(cat);
|
||||||
|
return count > 0 ? `<div style="display:flex;align-items:center;gap:4px;font-size:10px">
|
||||||
|
<span style="color:${cc.text};min-width:52px">${cc.icon} ${cc.name}</span>
|
||||||
|
<div style="flex:1;background:var(--bg);border-radius:3px;height:14px;overflow:hidden">
|
||||||
|
<div style="height:100%;width:${pct}%;background:${cc.text};border-radius:3px;transition:width .3s;min-width:2px"></div>
|
||||||
|
</div>
|
||||||
|
<span style="color:var(--text2);min-width:32px;text-align:right;font-family:'JetBrains Mono',monospace">${count}</span>
|
||||||
|
</div>` : '';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="cards-grid cards-4" style="margin-bottom:10px">
|
||||||
|
<div class="stat-card accent"><div class="stat-value">${memories.length}</div><div class="stat-label">📦 总记忆数</div></div>
|
||||||
|
<div class="stat-card blue"><div class="stat-value">${avgImportance}</div><div class="stat-label">⭐ 平均重要性</div></div>
|
||||||
|
<div class="stat-card green"><div class="stat-value">${totalAccess}</div><div class="stat-label">📊 总访问次数</div></div>
|
||||||
|
<div class="stat-card orange"><div class="stat-value">${Object.values(catCount).filter(n=>n>0).length}</div><div class="stat-label">🏷️ 分类数</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:3px">${barHtml}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemoryCards(memories) {
|
||||||
|
const grid = document.getElementById('mem-cards-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
if (memories.length === 0) {
|
||||||
|
const catName = STATE.memoryFilterCategory !== 'all'
|
||||||
|
? (getCatColor(STATE.memoryFilterCategory).name || STATE.memoryFilterCategory)
|
||||||
|
: '';
|
||||||
|
const msg = catName ? `「${catName}」分类下暂无记忆` : '没有匹配的记忆';
|
||||||
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="icon">📭</div>${msg}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = memories.map(m => renderMemoryCard(m)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemoryCard(m) {
|
||||||
|
const cat = m.category || 'other';
|
||||||
|
const cc = getCatColor(cat);
|
||||||
|
const importance = m.importance || 1;
|
||||||
|
const isHighImportance = importance >= 8;
|
||||||
|
|
||||||
|
// 星级
|
||||||
|
const stars = importanceToStars(importance);
|
||||||
|
|
||||||
|
// 关键词标签
|
||||||
|
const keywords = m.keywords || [];
|
||||||
|
const kwTags = keywords.length > 0
|
||||||
|
? keywords.slice(0, 5).map(k => `<span style="display:inline-block;padding:1px 7px;background:var(--bg3);border-radius:10px;font-size:10px;color:var(--text2);margin:1px">${escHtml(k)}</span>`).join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// 来源
|
||||||
|
const sourceLabel = m.source === 'thinking' ? '🤔 后台思考' : m.source === 'conversation' ? '💬 对话' : '📝 ' + (m.source || '未知');
|
||||||
|
|
||||||
|
// 会话 ID 简短
|
||||||
|
const sid = m.session_id || '';
|
||||||
|
const sidShort = sid.length > 20 ? sid.substring(0, 18) + '…' : sid;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<div class="mem-card ${isHighImportance ? 'mem-card-high' : ''}"
|
||||||
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(m.content || '')}">${escHtml((m.content || '').substring(0, 80))}</td>
|
style="background:var(--bg2);border:1px solid ${isHighImportance ? '#f59e0b' : 'var(--border)'};border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:10px;transition:all .2s">
|
||||||
<td><span class="badge badge-idle">${escHtml(m.category || 'other')}</span></td>
|
<!-- 头部: 分类标签 + 重要性 -->
|
||||||
<td>${m.priority ?? 1}</td>
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
<td style="color:var(--text2)">${escHtml(m.user_id || '—')}</td>
|
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${cc.bg};color:${cc.text}">
|
||||||
<td style="color:var(--text2);font-size:11px" title="${escHtml(sid)}">${escHtml(sidShort)}</td>
|
${cc.icon} ${cc.name}
|
||||||
<td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td>
|
</span>
|
||||||
<td><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></td>
|
<span style="font-size:13px;color:#f59e0b" title="重要程度: ${importance}/10">${stars}</span>
|
||||||
</tr>
|
</div>
|
||||||
`}).join('');
|
|
||||||
|
<!-- 记忆内容 -->
|
||||||
|
<div style="flex:1">
|
||||||
|
<div style="color:var(--text);font-size:13px;line-height:1.6;word-break:break-word">
|
||||||
|
${escHtml(m.content || '')}
|
||||||
|
</div>
|
||||||
|
${m.summary ? `<div style="color:var(--text2);font-size:11px;margin-top:4px;font-style:italic">📌 ${escHtml(m.summary)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关键词标签 -->
|
||||||
|
${kwTags ? `<div style="display:flex;flex-wrap:wrap;gap:3px">${kwTags}</div>` : ''}
|
||||||
|
|
||||||
|
<!-- 底部元信息 -->
|
||||||
|
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:10px;color:var(--text3);border-top:1px solid var(--border);padding-top:8px">
|
||||||
|
<span title="来源">${sourceLabel}</span>
|
||||||
|
${sid ? `<span title="会话: ${escHtml(sid)}" style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">💬 ${escHtml(sidShort)}</span>` : ''}
|
||||||
|
<span title="访问次数">📊 ${m.access_count || 0}</span>
|
||||||
|
<span title="创建时间">📅 ${formatTime(m.created_at)}</span>
|
||||||
|
<span title="更新时间" style="${(m.updated_at && m.updated_at !== m.created_at) ? '' : 'display:none'}">🔄 ${formatTime(m.updated_at)}</span>
|
||||||
|
<button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除" style="margin-left:auto">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importanceToStars(imp) {
|
||||||
|
const full = Math.round(imp / 2); // 1-10 映射到 1-5 星
|
||||||
|
const empty = 5 - full;
|
||||||
|
return '★'.repeat(full) + '☆'.repeat(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMemory() {
|
async function addMemory() {
|
||||||
const user_id = document.getElementById('mem-add-user-id').value.trim();
|
const user_id = document.getElementById('mem-add-user-id').value.trim();
|
||||||
const content = document.getElementById('mem-add-content').value.trim();
|
const content = document.getElementById('mem-add-content').value.trim();
|
||||||
const category = document.getElementById('mem-add-category').value;
|
const category = document.getElementById('mem-add-category').value;
|
||||||
const priority = parseInt(document.getElementById('mem-add-priority').value);
|
const importance = parseInt(document.getElementById('mem-add-importance').value);
|
||||||
|
|
||||||
if (!user_id || !content) { showToast('请填写用户ID和内容', 'error'); return; }
|
if (!user_id || !content) { showToast('请填写用户ID和内容', 'error'); return; }
|
||||||
|
|
||||||
const data = await api('/api/memory/add', {
|
const data = await api('/api/memory/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ user_id, content, category, priority }),
|
body: JSON.stringify({ user_id, content, category, importance }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.error) { showToast(`添加失败: ${data.error}`, 'error'); return; }
|
if (data.error) { showToast(`添加失败: ${data.error}`, 'error'); return; }
|
||||||
@@ -1207,7 +1513,7 @@ async function addMemory() {
|
|||||||
showToast('记忆添加成功!', 'success');
|
showToast('记忆添加成功!', 'success');
|
||||||
document.getElementById('mem-add-content').value = '';
|
document.getElementById('mem-add-content').value = '';
|
||||||
// 自动刷新列表
|
// 自动刷新列表
|
||||||
listMemory();
|
loadMemories();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteMemory(memoryId) {
|
async function deleteMemory(memoryId) {
|
||||||
@@ -1219,8 +1525,9 @@ async function deleteMemory(memoryId) {
|
|||||||
if (data.error) { showToast(`删除失败: ${data.error}`, 'error'); return; }
|
if (data.error) { showToast(`删除失败: ${data.error}`, 'error'); return; }
|
||||||
|
|
||||||
showToast('记忆删除成功!', 'success');
|
showToast('记忆删除成功!', 'success');
|
||||||
// 自动刷新列表
|
// 从缓存中移除并重新渲染
|
||||||
listMemory();
|
STATE.memoryCache = (STATE.memoryCache || []).filter(m => (m.id || m.ID) !== memoryId);
|
||||||
|
filterAndRenderMemories();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 面板3: 会话监看 ==========
|
// ========== 面板3: 会话监看 ==========
|
||||||
|
|||||||
Reference in New Issue
Block a user