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:
@@ -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