feat: Round 5 - Memory Service, Tool Engine, Call Records, Thinking Logs
- Fix: Session history flash (race condition + WS guard) - Fix: Chat background overlay + sidebar transparency - Fix: IoT device control (Chinese action names, status field) - Feat: Independent memory-service (port 8091, 13 endpoints) - Feat: Independent tool-engine service (port 8092, 13 tools) - Feat: Tool call logs with paginated DevTools panel - Feat: Thinking log records with DevTools panel - Feat: Future development roadmap document - Chore: Updated .gitignore, go.work, DevTools config - Chore: 5-service health check, project review docs
This commit is contained in:
@@ -50,6 +50,9 @@ type Thinker struct {
|
||||
adminUserID string // 管理员用户 ID
|
||||
adminSessionID string // 管理员主对话 session ID
|
||||
|
||||
// 记忆服务 HTTP 客户端(用于持久化思考日志)
|
||||
memClient *memory.Client
|
||||
|
||||
pendingThoughts []*PendingThought
|
||||
|
||||
lastUserMessage time.Time
|
||||
@@ -90,6 +93,7 @@ func NewThinker(
|
||||
convStore *ctxbuild.ConversationStore,
|
||||
adminUserID string,
|
||||
adminSessionID string,
|
||||
memClient *memory.Client,
|
||||
) *Thinker {
|
||||
return &Thinker{
|
||||
enabled: cfg.Enabled,
|
||||
@@ -106,6 +110,7 @@ func NewThinker(
|
||||
convStore: convStore,
|
||||
adminUserID: adminUserID,
|
||||
adminSessionID: adminSessionID,
|
||||
memClient: memClient,
|
||||
pendingThoughts: make([]*PendingThought, 0),
|
||||
lastUserMessage: time.Now(),
|
||||
stopCh: make(chan struct{}),
|
||||
@@ -281,6 +286,7 @@ func (t *Thinker) performThink() {
|
||||
maxToolRounds := 3
|
||||
var finalContent string
|
||||
var totalToolCalls int
|
||||
var toolCallRecords []map[string]interface{}
|
||||
|
||||
for round := 0; round <= maxToolRounds; round++ {
|
||||
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
|
||||
@@ -327,6 +333,10 @@ func (t *Thinker) performThink() {
|
||||
})
|
||||
|
||||
totalToolCalls++
|
||||
toolCallRecords = append(toolCallRecords, map[string]interface{}{
|
||||
"name": tc.Name,
|
||||
"args": args,
|
||||
})
|
||||
}
|
||||
|
||||
// 最后一轮:即使有 tool_calls 也强制停止
|
||||
@@ -348,8 +358,16 @@ func (t *Thinker) performThink() {
|
||||
return
|
||||
}
|
||||
|
||||
// 8. 存储思考结果
|
||||
t.storeThought(finalContent)
|
||||
// 序列化工具调用记录
|
||||
toolCallsJSON := "[]"
|
||||
if len(toolCallRecords) > 0 {
|
||||
if data, err := json.Marshal(toolCallRecords); err == nil {
|
||||
toolCallsJSON = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 存储思考结果(内存队列 + 持久化到 memory-service)
|
||||
t.storeThought(finalContent, toolCallsJSON, totalToolCalls)
|
||||
|
||||
log.Printf("[后台思考] 完成 (内容长度=%d, 工具调用=%d次)", len(finalContent), totalToolCalls)
|
||||
|
||||
@@ -475,11 +493,9 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
||||
return result
|
||||
}
|
||||
|
||||
// storeThought 存储思考结果到待推送队列
|
||||
func (t *Thinker) storeThought(content string) {
|
||||
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
|
||||
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||
Content: content,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -490,8 +506,22 @@ func (t *Thinker) storeThought(content string) {
|
||||
if len(t.pendingThoughts) > 10 {
|
||||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
||||
|
||||
// 异步持久化到 memory-service (不阻塞思考循环)
|
||||
if t.memClient != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := t.memClient.SaveThinkingLog(ctx, t.adminUserID, content, toolCallsJSON, toolCallCount, len(content)); err != nil {
|
||||
log.Printf("[后台思考] 持久化思考日志失败: %v", err)
|
||||
} else {
|
||||
log.Printf("[后台思考] 思考日志已持久化 (长度=%d, 工具调用=%d)", len(content), toolCallCount)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Printf("[memory-client] HTTP 请求失败 %s %s: %v", method, url, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -277,8 +277,8 @@ reflection_guidelines:
|
||||
- question: "开拓者的情绪是否有变化?"
|
||||
action: "如果情绪变好,说明陪伴有效;如果变差,思考如何改进"
|
||||
periodic:
|
||||
- frequency: "每10轮对话一次"
|
||||
actions:
|
||||
- "回顾最近的记忆,检查是否有矛盾之处"
|
||||
- "总结开拓者最近的生活状态和情绪趋势"
|
||||
- "思考如何在下次对话中创造惊喜或温暖"
|
||||
frequency: "每10轮对话一次"
|
||||
actions:
|
||||
- "回顾最近的记忆,检查是否有矛盾之处"
|
||||
- "总结开拓者最近的生活状态和情绪趋势"
|
||||
- "思考如何在下次对话中创造惊喜或温暖"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IoTControlTool IoT 设备控制工具
|
||||
@@ -53,6 +54,73 @@ func (t *IoTControlTool) Definition() ToolDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeAction 标准化 action 参数,支持中文别名、power 参数等
|
||||
func normalizeAction(arguments map[string]interface{}) string {
|
||||
action, _ := arguments["action"].(string)
|
||||
|
||||
// 如果 action 为空,检查 power/status 参数
|
||||
if action == "" {
|
||||
// power 参数: "off"/"关"/"关闭" → turn_off, "on"/"开"/"打开" → turn_on
|
||||
if pv, ok := arguments["power"]; ok {
|
||||
switch v := pv.(type) {
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "off", "false", "关", "关闭":
|
||||
return "turn_off"
|
||||
case "on", "true", "开", "打开", "开启":
|
||||
return "turn_on"
|
||||
}
|
||||
case bool:
|
||||
if !v {
|
||||
return "turn_off"
|
||||
}
|
||||
return "turn_on"
|
||||
}
|
||||
}
|
||||
// status 参数同理
|
||||
if sv, ok := arguments["status"]; ok {
|
||||
switch v := sv.(type) {
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "off", "false", "关", "关闭":
|
||||
return "turn_off"
|
||||
case "on", "true", "开", "打开", "开启":
|
||||
return "turn_on"
|
||||
}
|
||||
case bool:
|
||||
if !v {
|
||||
return "turn_off"
|
||||
}
|
||||
return "turn_on"
|
||||
}
|
||||
}
|
||||
// 默认 toggle
|
||||
return "toggle"
|
||||
}
|
||||
|
||||
// 标准化中文 action 名
|
||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||
case "打开", "开启", "开":
|
||||
return "turn_on"
|
||||
case "关闭", "关":
|
||||
return "turn_off"
|
||||
case "切换":
|
||||
return "toggle"
|
||||
case "设置温度", "调温度", "set_temp":
|
||||
return "set_temperature"
|
||||
case "设置亮度", "调亮度", "set_light":
|
||||
return "set_brightness"
|
||||
case "设置位置", "调位置":
|
||||
return "set_position"
|
||||
case "设置模式", "调模式", "切换模式":
|
||||
return "set_mode"
|
||||
case "设置颜色", "调颜色", "换颜色":
|
||||
return "set_color"
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
|
||||
// Execute 执行设备控制
|
||||
func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
if t.iotClient == nil {
|
||||
@@ -69,7 +137,7 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
||||
deviceID, _ = arguments["entity_id"].(string)
|
||||
}
|
||||
|
||||
action, _ := arguments["action"].(string)
|
||||
action := normalizeAction(arguments)
|
||||
|
||||
if deviceID == "" {
|
||||
return &ToolResult{
|
||||
@@ -79,8 +147,10 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
||||
}, nil
|
||||
}
|
||||
|
||||
if action == "" {
|
||||
action = "toggle"
|
||||
// 先获取设备名用于友好的返回消息(失败不影响后续流程)
|
||||
deviceName := deviceID
|
||||
if dev, err := t.iotClient.GetDevice(deviceID); err == nil {
|
||||
deviceName = dev.Name
|
||||
}
|
||||
|
||||
// 处理属性设置类操作
|
||||
@@ -95,51 +165,10 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
||||
return t.handleSetMode(deviceID, arguments)
|
||||
case "set_color":
|
||||
return t.handleSetColor(deviceID, arguments)
|
||||
}
|
||||
|
||||
// 处理开关类操作:需要获取当前设备状态
|
||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("获取设备状态失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "turn_on":
|
||||
// 如果设备已经开启,不需要操作
|
||||
if currentDevice.Status == "on" || currentDevice.Status == "open" || currentDevice.Status == "unlocked" {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: true,
|
||||
Data: fmt.Sprintf("设备 %s (%s) 已经处于开启状态,无需操作。", currentDevice.Name, deviceID),
|
||||
}, nil
|
||||
}
|
||||
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("打开设备失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: true,
|
||||
Data: fmt.Sprintf("已打开设备: %s", currentDevice.Name),
|
||||
}, nil
|
||||
|
||||
case "turn_off":
|
||||
// 如果设备已经关闭,不需要操作
|
||||
if currentDevice.Status == "off" || currentDevice.Status == "closed" || currentDevice.Status == "locked" {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: true,
|
||||
Data: fmt.Sprintf("设备 %s (%s) 已经处于关闭状态,无需操作。", currentDevice.Name, deviceID),
|
||||
}, nil
|
||||
}
|
||||
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
|
||||
// 声明式关闭:使用 SetDeviceProperty status/off 而非 toggle
|
||||
// 即使设备已经关闭,SetProperty 也会幂等处理
|
||||
if err := t.iotClient.SetDeviceProperty(deviceID, "status", "off"); err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: false,
|
||||
@@ -149,9 +178,22 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: true,
|
||||
Data: fmt.Sprintf("已关闭设备: %s", currentDevice.Name),
|
||||
Data: fmt.Sprintf("已关闭设备: %s", deviceName),
|
||||
}, nil
|
||||
case "turn_on":
|
||||
// 声明式打开:使用 SetDeviceProperty status/on 而非 toggle
|
||||
if err := t.iotClient.SetDeviceProperty(deviceID, "status", "on"); err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("打开设备失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: true,
|
||||
Data: fmt.Sprintf("已打开设备: %s", deviceName),
|
||||
}, nil
|
||||
|
||||
default: // "toggle"
|
||||
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
|
||||
return &ToolResult{
|
||||
@@ -167,7 +209,7 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
||||
return &ToolResult{
|
||||
ToolName: "iot_control",
|
||||
Success: true,
|
||||
Data: fmt.Sprintf("已成功切换设备 %s 的状态。", currentDevice.Name),
|
||||
Data: fmt.Sprintf("已成功切换设备 %s 的状态。", deviceName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ToolEngineClient 工具引擎 HTTP 客户端
|
||||
// 将工具执行请求转发到独立的 tool-engine 微服务
|
||||
type ToolEngineClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// toolEngineToolDef 来自 tool-engine 的工具定义响应
|
||||
type toolEngineToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
// toolEngineResult 来自 tool-engine 的工具执行结果
|
||||
type toolEngineResult struct {
|
||||
ID string `json:"id"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewToolEngineClient 创建工具引擎客户端
|
||||
func NewToolEngineClient(baseURL string) *ToolEngineClient {
|
||||
return &ToolEngineClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefinitions 从 tool-engine 获取所有工具定义
|
||||
func (c *ToolEngineClient) GetDefinitions(ctx context.Context) ([]ToolDefinition, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/tools", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求工具列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("获取工具列表返回状态码 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Tools []toolEngineToolDef `json:"tools"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析工具列表失败: %w", err)
|
||||
}
|
||||
|
||||
defs := make([]ToolDefinition, 0, len(result.Tools))
|
||||
for _, t := range result.Tools {
|
||||
defs = append(defs, ToolDefinition{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[tool-engine-client] 从 tool-engine 获取了 %d 个工具定义", len(defs))
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// Execute 通过 tool-engine 执行工具调用
|
||||
func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"arguments": arguments,
|
||||
})
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("序列化参数失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/tools/%s/execute", c.baseURL, toolName)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("请求 tool-engine 失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("工具 %s 不存在", toolName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("tool-engine 返回状态码 %d: %s", resp.StatusCode, string(respBody)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var result toolEngineResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析 tool-engine 响应失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: result.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: true,
|
||||
Data: result.Output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HealthCheck 检查 tool-engine 服务是否可用
|
||||
func (c *ToolEngineClient) HealthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建健康检查请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tool-engine 不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tool-engine 健康检查返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user