71f0a1abdb
- 所有Go模块路径从 github.com/yourname/cyrene-ai 迁移到 git.yeij.top/AskaEth/Cyrene - 5个Go Dockerfile添加 GOPROXY=https://goproxy.cn,direct 解决国内构建问题 - ai-core go.mod 添加 pkg/plugins replace 指令 - Caddyfile 简化为 http:// 通配 + handle 保留 /api 前缀 - ethend Dockerfile 适配 (npm install + 仅 COPY package.json) - ethend 新增 RUNNING_IN_DOCKER 环境变量,健康检查改用Docker服务名 - ethend 数据库状态检查支持Docker hostname (postgres/redis/qdrant/minio) - process-manager 新增 CONTAINER_SVC_MAP + Docker模式自动检测 - 统一 docker-compose.dev.db.yml 卷名 (pg_data/redis_data/qdrant_data/minio_data) - docker-compose.yml ethend服务挂载docker.sock + 端口变量化 - 清理 .env 统一后的残留文件与提示信息 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
335 lines
9.4 KiB
Go
335 lines
9.4 KiB
Go
package memory
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
|
||
"net/http"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||
)
|
||
|
||
// Client 记忆服务 HTTP 客户端
|
||
// ai-core 通过此客户端调用独立的 memory-service
|
||
type Client struct {
|
||
baseURL string
|
||
httpClient *http.Client
|
||
}
|
||
|
||
// NewClient 创建记忆服务客户端
|
||
func NewClient(baseURL string) *Client {
|
||
return &Client{
|
||
baseURL: baseURL,
|
||
httpClient: &http.Client{
|
||
Timeout: 15 * time.Second,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Ping 检查记忆服务是否可用
|
||
func (c *Client) Ping(ctx context.Context) error {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/health", nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("记忆服务健康检查失败: %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Save 保存记忆
|
||
func (c *Client) Save(ctx context.Context, entry *model.MemoryEntry) error {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"user_id": entry.UserID,
|
||
"content": entry.Content,
|
||
"summary": entry.Summary,
|
||
"category": string(entry.Category),
|
||
"priority": int(entry.Priority),
|
||
"importance": entry.Importance,
|
||
"keywords": entry.Keywords,
|
||
"session_id": entry.SessionID,
|
||
"source": entry.Source,
|
||
})
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodPost, c.baseURL+"/api/v1/memories", body)
|
||
if err != nil {
|
||
return fmt.Errorf("保存记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= 300 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("保存记忆失败 (%d): %s", resp.StatusCode, string(respBody))
|
||
}
|
||
|
||
// 解析返回以获取 ID 和 CreatedAt
|
||
var result struct {
|
||
Memory *model.MemoryEntry `json:"memory"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.Memory != nil {
|
||
entry.ID = result.Memory.ID
|
||
entry.CreatedAt = result.Memory.CreatedAt
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Query 按条件查询记忆
|
||
func (c *Client) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryEntry, error) {
|
||
url := fmt.Sprintf("%s/api/v1/memories?user_id=%s", c.baseURL, q.UserID)
|
||
if q.Category != "" {
|
||
url += "&category=" + string(q.Category)
|
||
}
|
||
if q.MinImportance > 0 {
|
||
url += fmt.Sprintf("&min_importance=%d", q.MinImportance)
|
||
}
|
||
if q.Limit > 0 {
|
||
url += fmt.Sprintf("&limit=%d", q.Limit)
|
||
}
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result struct {
|
||
Memories []model.MemoryEntry `json:"memories"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("解析查询结果失败: %w", err)
|
||
}
|
||
return result.Memories, nil
|
||
}
|
||
|
||
// QueryByText 语义查询(POST /api/v1/memories/query)
|
||
func (c *Client) QueryByText(ctx context.Context, userID, queryText, category string, minImportance, limit int) ([]model.MemoryEntry, error) {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"user_id": userID,
|
||
"query_text": queryText,
|
||
"category": category,
|
||
"min_importance": minImportance,
|
||
"limit": limit,
|
||
})
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodPost, c.baseURL+"/api/v1/memories/query", body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("语义查询失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= 300 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("语义查询失败 (%d): %s", resp.StatusCode, string(respBody))
|
||
}
|
||
|
||
var result struct {
|
||
Memories []model.MemoryEntry `json:"memories"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("解析查询结果失败: %w", err)
|
||
}
|
||
return result.Memories, nil
|
||
}
|
||
|
||
// GetByID 根据ID获取记忆
|
||
func (c *Client) GetByID(ctx context.Context, id string) (*model.MemoryEntry, error) {
|
||
resp, err := c.doRequest(ctx, http.MethodGet, c.baseURL+"/api/v1/memories/"+id, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == http.StatusNotFound {
|
||
return nil, nil
|
||
}
|
||
if resp.StatusCode >= 300 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("获取记忆失败 (%d): %s", resp.StatusCode, string(respBody))
|
||
}
|
||
|
||
var result struct {
|
||
Memory model.MemoryEntry `json:"memory"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("解析获取结果失败: %w", err)
|
||
}
|
||
return &result.Memory, nil
|
||
}
|
||
|
||
// Update 更新记忆
|
||
func (c *Client) Update(ctx context.Context, entry *model.MemoryEntry) error {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"content": entry.Content,
|
||
"summary": entry.Summary,
|
||
"category": string(entry.Category),
|
||
"priority": int(entry.Priority),
|
||
"importance": entry.Importance,
|
||
"keywords": entry.Keywords,
|
||
"source": entry.Source,
|
||
})
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodPut, c.baseURL+"/api/v1/memories/"+entry.ID, body)
|
||
if err != nil {
|
||
return fmt.Errorf("更新记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= 300 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("更新记忆失败 (%d): %s", resp.StatusCode, string(respBody))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Delete 删除记忆
|
||
func (c *Client) Delete(ctx context.Context, id string) error {
|
||
resp, err := c.doRequest(ctx, http.MethodDelete, c.baseURL+"/api/v1/memories/"+id, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("删除记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= 300 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("删除记忆失败 (%d): %s", resp.StatusCode, string(respBody))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetMemoriesByCategory 按分类获取记忆
|
||
func (c *Client) GetMemoriesByCategory(ctx context.Context, userID string, category model.MemoryCategory) ([]model.MemoryEntry, error) {
|
||
return c.Query(ctx, model.MemoryQuery{
|
||
UserID: userID,
|
||
Category: category,
|
||
Limit: 50,
|
||
})
|
||
}
|
||
|
||
// ConsolidateMemories 合并相似记忆
|
||
func (c *Client) ConsolidateMemories(ctx context.Context, userID string) (int, error) {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"user_id": userID,
|
||
})
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodPost, c.baseURL+"/api/v1/memories/consolidate", body)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("合并记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result struct {
|
||
Merged int `json:"merged"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return 0, fmt.Errorf("解析合并结果失败: %w", err)
|
||
}
|
||
return result.Merged, nil
|
||
}
|
||
|
||
// DecayMemories 衰减旧记忆
|
||
func (c *Client) DecayMemories(ctx context.Context, userID string) (int, int, error) {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"user_id": userID,
|
||
})
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodPost, c.baseURL+"/api/v1/memories/decay", body)
|
||
if err != nil {
|
||
return 0, 0, fmt.Errorf("衰减记忆失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result struct {
|
||
Decayed int `json:"decayed"`
|
||
Deleted int `json:"deleted"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return 0, 0, fmt.Errorf("解析衰减结果失败: %w", err)
|
||
}
|
||
return result.Decayed, result.Deleted, nil
|
||
}
|
||
|
||
// GetCategories 获取用户类别统计
|
||
func (c *Client) GetCategories(ctx context.Context, userID string) (map[string]int, error) {
|
||
url := fmt.Sprintf("%s/api/v1/memories/categories?user_id=%s", c.baseURL, userID)
|
||
resp, err := c.doRequest(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取类别统计失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result struct {
|
||
Categories map[string]int `json:"categories"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("解析类别统计失败: %w", err)
|
||
}
|
||
return result.Categories, nil
|
||
}
|
||
|
||
// SaveThinkingLog 持久化自主思考日志到 memory-service
|
||
func (c *Client) SaveThinkingLog(ctx context.Context, userID, content, toolCalls string, toolCallCount, contentLength int) error {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"user_id": userID,
|
||
"content": content,
|
||
"tool_calls": toolCalls,
|
||
"tool_call_count": toolCallCount,
|
||
"content_length": contentLength,
|
||
})
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodPost, c.baseURL+"/api/v1/thinking", body)
|
||
if err != nil {
|
||
return fmt.Errorf("保存思考日志失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= 300 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("保存思考日志失败 (%d): %s", resp.StatusCode, string(respBody))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// IsReady 检查记忆服务是否就绪
|
||
func (c *Client) IsReady() bool {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
return c.Ping(ctx) == nil
|
||
}
|
||
|
||
// doRequest 内部 HTTP 请求辅助方法
|
||
func (c *Client) doRequest(ctx context.Context, method, url string, body []byte) (*http.Response, error) {
|
||
var reqBody io.Reader
|
||
if body != nil {
|
||
reqBody = bytes.NewReader(body)
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
logger.Printf("[memory-client] HTTP 请求失败 %s %s: %v", method, url, err)
|
||
return nil, err
|
||
}
|
||
|
||
return resp, nil
|
||
}
|