feat: 插件-工具合并 — 创建 pkg/plugins 共享模块并移除 tool-engine
- 新增 backend/pkg/plugins/ 共享模块:SDK 接口、PluginManager、ToolRegistry(含环形缓冲区调用日志) - 13 个通用插件从 plugin-manager 迁移至共享模块(import 路径统一) - ai-core 切换至共享 ToolRegistry,进程内执行(零网络开销),包装 6 个专属工具 - plugin-manager 迁移至共享模块,保留管理 REST API - 新增 DevTools 插件管理面板(侧边栏 → 🔌 插件管理) - 移除 tool-engine 服务(从 go.work、DevTools 配置、编译系统) - 工具调用记录 API 从 Tool-Engine 迁至 AI-Core(/api/v1/tools/calls) - ai-core ContextStore 启动时从 PostgreSQL 恢复会话历史 - 清理所有过时引用和备份文件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ data/
|
|||||||
.env
|
.env
|
||||||
backend/.env
|
backend/.env
|
||||||
models.json
|
models.json
|
||||||
|
thinking_schedule.json
|
||||||
platform_configs.json
|
platform_configs.json
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
+142
-32
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,6 +27,20 @@ import (
|
|||||||
"github.com/yourname/cyrene-ai/ai-core/internal/rag"
|
"github.com/yourname/cyrene-ai/ai-core/internal/rag"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/subsession"
|
"github.com/yourname/cyrene-ai/ai-core/internal/subsession"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
|
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
|
||||||
|
|
||||||
|
plgManager "github.com/yourname/cyrene-ai/pkg/plugins/manager"
|
||||||
|
plgSDK "github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
|
pluginCalc "github.com/yourname/cyrene-ai/pkg/plugins/calculator"
|
||||||
|
pluginCrypto "github.com/yourname/cyrene-ai/pkg/plugins/crypto"
|
||||||
|
pluginDate "github.com/yourname/cyrene-ai/pkg/plugins/datetime"
|
||||||
|
pluginFile "github.com/yourname/cyrene-ai/pkg/plugins/file"
|
||||||
|
pluginHTTP "github.com/yourname/cyrene-ai/pkg/plugins/http"
|
||||||
|
pluginJSON "github.com/yourname/cyrene-ai/pkg/plugins/json"
|
||||||
|
pluginMD "github.com/yourname/cyrene-ai/pkg/plugins/markdown"
|
||||||
|
pluginRand "github.com/yourname/cyrene-ai/pkg/plugins/random"
|
||||||
|
pluginText "github.com/yourname/cyrene-ai/pkg/plugins/text"
|
||||||
|
pluginWF "github.com/yourname/cyrene-ai/pkg/plugins/web_fetch"
|
||||||
|
pluginWS "github.com/yourname/cyrene-ai/pkg/plugins/web_search"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
@@ -113,6 +128,15 @@ func main() {
|
|||||||
|
|
||||||
// 初始化会话历史存储
|
// 初始化会话历史存储
|
||||||
convStore := ctxbuild.NewConversationStore(50)
|
convStore := ctxbuild.NewConversationStore(50)
|
||||||
|
|
||||||
|
// 从数据库恢复主会话历史(避免重启丢失上下文)
|
||||||
|
adminUserID := "admin"
|
||||||
|
adminSessionID := "admin-session-main"
|
||||||
|
if cfg.DatabaseURL != "" {
|
||||||
|
if err := convStore.LoadFromDB(cfg.DatabaseURL, adminSessionID, 50); err != nil {
|
||||||
|
log.Printf("⚠ 从数据库恢复会话历史失败(不影响服务启动): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
log.Println("会话历史存储已就绪 (上限50条)")
|
log.Println("会话历史存储已就绪 (上限50条)")
|
||||||
|
|
||||||
// 初始化上下文构建器
|
// 初始化上下文构建器
|
||||||
@@ -141,36 +165,34 @@ func main() {
|
|||||||
knowledgeRetriever := rag.NewRetriever(knowledgeStore)
|
knowledgeRetriever := rag.NewRetriever(knowledgeStore)
|
||||||
log.Printf("RAG 知识库已就绪: 目录=%s, 嵌入模型=text-embedding-3-small", knowledgeDir)
|
log.Printf("RAG 知识库已就绪: 目录=%s, 嵌入模型=text-embedding-3-small", knowledgeDir)
|
||||||
|
|
||||||
// 初始化工具注册中心
|
// 初始化工具注册中心 (使用共享插件模块)
|
||||||
toolRegistry := tools.NewRegistry()
|
toolRegistry := plgManager.NewToolRegistry()
|
||||||
if getEnvBool("ENABLE_TOOLS", true) {
|
if getEnvBool("ENABLE_TOOLS", true) {
|
||||||
toolRegistry.Register(tools.NewWebFetchTool())
|
// 11 个共享通用插件 — 注册其工具到统一注册中心
|
||||||
toolRegistry.Register(tools.NewWebSearchTool())
|
registerPluginTools(toolRegistry, &pluginCalc.CalculatorPlugin{})
|
||||||
toolRegistry.Register(tools.NewCalculatorTool())
|
registerPluginTools(toolRegistry, &pluginDate.DatetimePlugin{})
|
||||||
toolRegistry.Register(tools.NewDateTimeTool())
|
registerPluginTools(toolRegistry, &pluginText.TextPlugin{})
|
||||||
toolRegistry.Register(tools.NewHTTPTool())
|
registerPluginTools(toolRegistry, &pluginCrypto.CryptoPlugin{})
|
||||||
toolRegistry.Register(tools.NewJSONTool())
|
registerPluginTools(toolRegistry, &pluginRand.RandomPlugin{})
|
||||||
toolRegistry.Register(tools.NewTextTool())
|
registerPluginTools(toolRegistry, &pluginMD.MarkdownPlugin{})
|
||||||
toolRegistry.Register(tools.NewRandomTool())
|
registerPluginTools(toolRegistry, &pluginJSON.JSONPlugin{})
|
||||||
toolRegistry.Register(tools.NewCryptoTool())
|
registerPluginTools(toolRegistry, pluginFile.NewFilePlugin(dataDir))
|
||||||
toolRegistry.Register(tools.NewMarkdownTool())
|
registerPluginTools(toolRegistry, pluginHTTP.NewHTTPPlugin())
|
||||||
|
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
|
||||||
// File tool uses DATA_DIR or defaults to /tmp/cyrene_data
|
registerPluginTools(toolRegistry, pluginWF.NewWebFetchPlugin())
|
||||||
toolRegistry.Register(tools.NewFileTool(dataDir))
|
|
||||||
|
|
||||||
|
// ai-core 专属工具 — 通过 sdk.Tool 适配器注册
|
||||||
if iotClient != nil {
|
if iotClient != nil {
|
||||||
toolRegistry.Register(tools.NewIoTQueryTool(iotClient))
|
toolRegistry.Register(wrapTool(tools.NewIoTQueryTool(iotClient), "iot_query", "Query IoT Devices", "iot"))
|
||||||
toolRegistry.Register(tools.NewIoTControlTool(iotClient))
|
toolRegistry.Register(wrapTool(tools.NewIoTControlTool(iotClient), "iot_control", "Control IoT Devices", "iot"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 6.2: 主机操控工具
|
|
||||||
if hostManager != nil {
|
if hostManager != nil {
|
||||||
toolRegistry.Register(tools.NewHostExecTool(hostManager))
|
toolRegistry.Register(wrapTool(tools.NewHostExecTool(hostManager), "host_exec", "Host Command Execution", "system"))
|
||||||
toolRegistry.Register(tools.NewHostFileTool(hostManager))
|
toolRegistry.Register(wrapTool(tools.NewHostFileTool(hostManager), "host_file", "Host File Operations", "system"))
|
||||||
toolRegistry.Register(tools.NewHostSystemTool(hostManager))
|
toolRegistry.Register(wrapTool(tools.NewHostSystemTool(hostManager), "host_system", "Host System Info", "system"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 6.3: 视觉理解工具 — 可选 LLM 增强,无视觉模型时回退 base64 模式
|
|
||||||
var visionProvider llm.LLMProvider
|
var visionProvider llm.LLMProvider
|
||||||
if configLoader != nil && configLoader.HasConfig() {
|
if configLoader != nil && configLoader.HasConfig() {
|
||||||
cfg := configLoader.GetConfig()
|
cfg := configLoader.GetConfig()
|
||||||
@@ -187,20 +209,17 @@ func main() {
|
|||||||
if visionProvider == nil {
|
if visionProvider == nil {
|
||||||
log.Println("视觉模型未配置,vision_analyze 将使用 base64 模式")
|
log.Println("视觉模型未配置,vision_analyze 将使用 base64 模式")
|
||||||
}
|
}
|
||||||
toolRegistry.Register(tools.NewVisionTool(visionProvider))
|
toolRegistry.Register(wrapTool(tools.NewVisionTool(visionProvider), "vision_analyze", "Image Vision Analysis & OCR", "multimodal"))
|
||||||
|
|
||||||
// Phase 6.6: 知识库 RAG 工具
|
|
||||||
if knowledgeRetriever != nil {
|
if knowledgeRetriever != nil {
|
||||||
toolRegistry.Register(tools.NewKnowledgeSearchTool(knowledgeRetriever))
|
toolRegistry.Register(wrapTool(tools.NewKnowledgeSearchTool(knowledgeRetriever), "knowledge_search", "Search Knowledge Base", "knowledge"))
|
||||||
toolRegistry.Register(tools.NewKnowledgeIngestTool(knowledgeStore))
|
toolRegistry.Register(wrapTool(tools.NewKnowledgeIngestTool(knowledgeStore), "knowledge_ingest", "Ingest Knowledge Document", "knowledge"))
|
||||||
}
|
}
|
||||||
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.ListTools()), toolRegistry.ListTools())
|
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.DefinitionNames()), toolRegistry.DefinitionNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化后台思考器(增强版:支持工具调用和记忆管理)
|
// 初始化后台思考器(增强版:支持工具调用和记忆管理)
|
||||||
thinkerCfg := background.DefaultThinkerConfig()
|
thinkerCfg := background.DefaultThinkerConfig()
|
||||||
adminUserID := "admin"
|
|
||||||
adminSessionID := "admin-session-main"
|
|
||||||
|
|
||||||
// 创建记忆服务 HTTP 客户端(用于持久化思考日志到 memory-service)
|
// 创建记忆服务 HTTP 客户端(用于持久化思考日志到 memory-service)
|
||||||
memServiceURL := getEnv("MEMORY_SERVICE_URL", "http://localhost:8091")
|
memServiceURL := getEnv("MEMORY_SERVICE_URL", "http://localhost:8091")
|
||||||
@@ -221,6 +240,18 @@ func main() {
|
|||||||
adminSessionID,
|
adminSessionID,
|
||||||
memClient,
|
memClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 初始化动态调度加载器 (Phase: thinking-schedule)
|
||||||
|
schedulePath := getEnv("THINKING_SCHEDULE_PATH", "../thinking_schedule.json")
|
||||||
|
if scheduleLoader, err := background.NewScheduleLoader(schedulePath); err != nil {
|
||||||
|
log.Printf("⚠ 思考调度配置加载失败,使用固定间隔: %v", err)
|
||||||
|
} else if scheduleLoader.HasConfig() {
|
||||||
|
thinker.SetScheduleLoader(scheduleLoader)
|
||||||
|
log.Println("[后台思考] 动态调度已启用 (thinking_schedule.json)")
|
||||||
|
} else {
|
||||||
|
log.Println("[后台思考] 调度配置文件不存在,使用 .env 固定间隔")
|
||||||
|
}
|
||||||
|
|
||||||
thinker.Start()
|
thinker.Start()
|
||||||
defer thinker.Stop()
|
defer thinker.Stop()
|
||||||
|
|
||||||
@@ -347,6 +378,44 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 工具调用记录
|
||||||
|
mux.HandleFunc("/api/v1/tools/calls", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toolName := r.URL.Query().Get("tool_name")
|
||||||
|
limit := 50
|
||||||
|
if n, err := fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit); n != 1 || err != nil || limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
calls := toolRegistry.GetCallLogs(toolName, limit)
|
||||||
|
if calls == nil {
|
||||||
|
calls = []plgManager.CallLogRecord{}
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"calls": calls, "total": len(calls), "page": page, "limit": limit,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 工具调用统计
|
||||||
|
mux.HandleFunc("/api/v1/tools/calls/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(toolRegistry.GetCallStats())
|
||||||
|
})
|
||||||
|
|
||||||
// 启动HTTP服务
|
// 启动HTTP服务
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
@@ -444,12 +513,53 @@ func getEnvBool(key string, fallback bool) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerPluginTools 从插件实例注册其所有工具到注册中心
|
||||||
|
func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin) {
|
||||||
|
for _, t := range plugin.Tools() {
|
||||||
|
if err := registry.Register(t); err != nil {
|
||||||
|
log.Printf("⚠ 注册工具失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapTool 包装 ai-core 旧 ToolExecutor 为 sdk.Tool
|
||||||
|
func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plgSDK.Tool {
|
||||||
|
return &toolAdapter{
|
||||||
|
executor: executor,
|
||||||
|
def: plgSDK.ToolDefinition{
|
||||||
|
ID: id, Name: id, DisplayName: displayName,
|
||||||
|
Description: executor.Definition().Description,
|
||||||
|
Category: category,
|
||||||
|
Complexity: plgSDK.ComplexitySimple,
|
||||||
|
Parameters: executor.Definition().Parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolAdapter struct {
|
||||||
|
plgSDK.BaseTool
|
||||||
|
executor tools.ToolExecutor
|
||||||
|
def plgSDK.ToolDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *toolAdapter) Definition() plgSDK.ToolDefinition { return a.def }
|
||||||
|
func (a *toolAdapter) Execute(ctx context.Context, args map[string]interface{}) (*plgSDK.ToolResult, error) {
|
||||||
|
result, err := a.executor.Execute(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &plgSDK.ToolResult{
|
||||||
|
ToolName: result.ToolName, Success: result.Success,
|
||||||
|
Output: result.Data, Error: result.Error,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
|
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
|
||||||
func buildOpenAITools(registry *tools.Registry) []llm.OpenAITool {
|
func buildOpenAITools(registry *plgManager.ToolRegistry) []llm.OpenAITool {
|
||||||
if registry == nil || !registry.IsEnabled() {
|
if registry == nil || !registry.IsEnabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defs := registry.GetDefinitions()
|
defs := registry.Definitions()
|
||||||
if len(defs) == 0 {
|
if len(defs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -478,7 +588,7 @@ func handleChat(
|
|||||||
_ *memory.Extractor,
|
_ *memory.Extractor,
|
||||||
iotClient *tools.IoTClient,
|
iotClient *tools.IoTClient,
|
||||||
thinker *background.Thinker,
|
thinker *background.Thinker,
|
||||||
_ *tools.Registry,
|
_ *plgManager.ToolRegistry,
|
||||||
) {
|
) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
|
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
|
||||||
|
|
||||||
|
plgManager "github.com/yourname/cyrene-ai/pkg/plugins/manager"
|
||||||
|
plgSDK "github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PendingThought 待推送的后台思考
|
// PendingThought 待推送的后台思考
|
||||||
@@ -50,7 +53,7 @@ type Thinker struct {
|
|||||||
memoryStore *memory.Store
|
memoryStore *memory.Store
|
||||||
|
|
||||||
// 工具调用
|
// 工具调用
|
||||||
toolRegistry *tools.Registry
|
toolRegistry *plgManager.ToolRegistry
|
||||||
|
|
||||||
// 会话上下文
|
// 会话上下文
|
||||||
convStore *ctxbuild.ConversationStore
|
convStore *ctxbuild.ConversationStore
|
||||||
@@ -115,6 +118,9 @@ type Thinker struct {
|
|||||||
// Phase 2: 主动消息决策守卫
|
// Phase 2: 主动消息决策守卫
|
||||||
proactiveGuard *ProactiveGuard
|
proactiveGuard *ProactiveGuard
|
||||||
|
|
||||||
|
// 动态调度: 按时间段自动调整思考间隔
|
||||||
|
scheduleLoader *ScheduleLoader
|
||||||
|
|
||||||
// Phase 2: 在线状态追踪
|
// Phase 2: 在线状态追踪
|
||||||
userOnline bool
|
userOnline bool
|
||||||
lastOnlineChange time.Time
|
lastOnlineChange time.Time
|
||||||
@@ -149,6 +155,13 @@ func DefaultAutonomousToolPolicy() *AutonomousToolPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetMessagePusher 设置主动消息推送回调
|
// SetMessagePusher 设置主动消息推送回调
|
||||||
|
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
|
||||||
|
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.scheduleLoader = loader
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
@@ -228,7 +241,7 @@ func NewThinker(
|
|||||||
toolAdapter *llm.Adapter,
|
toolAdapter *llm.Adapter,
|
||||||
iotClient *tools.IoTClient,
|
iotClient *tools.IoTClient,
|
||||||
memoryStore *memory.Store,
|
memoryStore *memory.Store,
|
||||||
toolRegistry *tools.Registry,
|
toolRegistry *plgManager.ToolRegistry,
|
||||||
convStore *ctxbuild.ConversationStore,
|
convStore *ctxbuild.ConversationStore,
|
||||||
adminUserID string,
|
adminUserID string,
|
||||||
adminSessionID string,
|
adminSessionID string,
|
||||||
@@ -434,8 +447,8 @@ func (t *Thinker) resetSilenceTimer() {
|
|||||||
|
|
||||||
// periodicThinkLoop 周期性自主思考循环
|
// periodicThinkLoop 周期性自主思考循环
|
||||||
//
|
//
|
||||||
// 每隔 thinkInterval 触发一次思考,保证昔涟在无用户活动时也能持续进行后台反思。
|
// 使用动态间隔:若配置了 ScheduleLoader,每次循环根据当前时段计算间隔;
|
||||||
// 每次触发前检查 minThinkGap,避免与事件驱动思考冲突。
|
// 否则回退到固定的 thinkInterval。
|
||||||
func (t *Thinker) periodicThinkLoop() {
|
func (t *Thinker) periodicThinkLoop() {
|
||||||
defer t.wg.Done()
|
defer t.wg.Done()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -444,17 +457,22 @@ func (t *Thinker) periodicThinkLoop() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ticker := time.NewTicker(t.thinkInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
log.Printf("[后台思考] 周期性思考已启动 (间隔=%v)", t.thinkInterval)
|
log.Printf("[后台思考] 周期性思考已启动 (间隔=%v)", t.thinkInterval)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
// 计算本次等待间隔
|
||||||
|
interval := t.thinkInterval
|
||||||
|
if t.scheduleLoader != nil {
|
||||||
|
if mins := t.scheduleLoader.GetInterval(time.Now()); mins > 0 {
|
||||||
|
interval = time.Duration(mins) * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-t.stopCh:
|
case <-t.stopCh:
|
||||||
log.Println("[后台思考] 周期性思考已停止")
|
log.Println("[后台思考] 周期性思考已停止")
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-time.After(interval):
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
sinceLastThink := time.Since(t.lastThinkTime)
|
sinceLastThink := time.Since(t.lastThinkTime)
|
||||||
sinceLastUser := time.Since(t.lastUserMessage)
|
sinceLastUser := time.Since(t.lastUserMessage)
|
||||||
@@ -482,7 +500,7 @@ func (t *Thinker) periodicThinkLoop() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[后台思考] 周期性触发 (上次思考=%v前, 上次用户消息=%v前)", sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
|
log.Printf("[后台思考] 周期性触发 (间隔=%v, 上次思考=%v前, 上次用户消息=%v前)", interval, sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
|
||||||
t.performThink("periodic")
|
t.performThink("periodic")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,6 +671,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
|
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
|
||||||
}
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = &plgSDK.ToolResult{ToolName: tc.Name, Success: false, Error: execErr.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
resultJSON, _ := json.Marshal(result)
|
resultJSON, _ := json.Marshal(result)
|
||||||
messages = append(messages, model.LLMMessage{
|
messages = append(messages, model.LLMMessage{
|
||||||
@@ -741,7 +762,7 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
|||||||
- 开拓者说要离开一会儿、去忙、去吃饭
|
- 开拓者说要离开一会儿、去忙、去吃饭
|
||||||
- 开拓者明确表示不想被打扰
|
- 开拓者明确表示不想被打扰
|
||||||
- 对话刚刚自然结束且开拓者没有未完成的事
|
- 对话刚刚自然结束且开拓者没有未完成的事
|
||||||
如果对话历史显示以上任何情况,反思中不要写【主动消息】标记。你可以在心里想想他,但不要去打扰。`
|
如果对话历史显示以上任何情况,你只需要在心里默默陪伴,不要输出任何【主动消息】指令行。`
|
||||||
|
|
||||||
switch triggerReason {
|
switch triggerReason {
|
||||||
case "post_chat":
|
case "post_chat":
|
||||||
@@ -768,9 +789,10 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
|||||||
|
|
||||||
其他规则:
|
其他规则:
|
||||||
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
|
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
|
||||||
2. 只有开拓者状态正常且真的有必要时才写【主动消息】,不要硬找话题。
|
2. 只有开拓者状态正常且真的有必要时,才在独立一行写【主动消息】标记,后面跟你要发给他的话。不要硬找话题。
|
||||||
3. 【主动消息】的内容必须直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。反思是给自己看的,主动消息是发给他的——语气要区分开。
|
3. 【主动消息】标记必须独占一行开头,内容直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。
|
||||||
4. 2-4句话即可。`
|
4. 如果你在反思中提到"主动消息"这个词但不打算发消息,不要使用【主动消息】这个带括号的标记——系统会误解析。
|
||||||
|
5. 2-4句话即可。`
|
||||||
|
|
||||||
case "silence":
|
case "silence":
|
||||||
thinkingInstructions = `
|
thinkingInstructions = `
|
||||||
@@ -840,6 +862,14 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
) string {
|
) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// 注入当前现实时间,让模型对时间有感知
|
||||||
|
now := time.Now()
|
||||||
|
weekdayNames := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||||
|
sb.WriteString(fmt.Sprintf("🕐 现在是 %s %s %02d:%02d。\n",
|
||||||
|
now.Format("2006年1月2日"),
|
||||||
|
weekdayNames[now.Weekday()],
|
||||||
|
now.Hour(), now.Minute()))
|
||||||
|
|
||||||
// 根据触发原因使用不同的开场白
|
// 根据触发原因使用不同的开场白
|
||||||
switch triggerReason {
|
switch triggerReason {
|
||||||
case "post_chat":
|
case "post_chat":
|
||||||
@@ -886,7 +916,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
|
|
||||||
// 关键:强调根据对话历史判断用户当前状态
|
// 关键:强调根据对话历史判断用户当前状态
|
||||||
if lastUserMsg != "" {
|
if lastUserMsg != "" {
|
||||||
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,反思中不要写【主动消息】。\n", lastUserMsg))
|
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,不要输出【主动消息】指令行。\n", lastUserMsg))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 现有记忆
|
// 现有记忆
|
||||||
@@ -930,9 +960,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
// 结尾引导
|
// 结尾引导
|
||||||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
||||||
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
||||||
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要写【主动消息】。你可以在心里想他,但不要去打扰。")
|
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要输出【主动消息】指令行。你可以在心里想他,但不要去打扰。")
|
||||||
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才写【主动消息】。")
|
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才输出一行【主动消息】+ 你要发给他的话。")
|
||||||
sb.WriteString("\n❗【主动消息】的内容必须直接对开拓者说话(用\"你\"来称呼他),就像你主动找他聊天一样自然。不要用第三人称或自言自语的方式写主动消息。")
|
sb.WriteString("\n❗【主动消息】标记必须独占一行开头,后面紧跟你要对开拓者说的话(用\"你\"称呼),语气自然像主动找他聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@@ -963,7 +993,7 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
|||||||
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
|
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defs := t.toolRegistry.GetDefinitions()
|
defs := t.toolRegistry.Definitions()
|
||||||
if len(defs) == 0 {
|
if len(defs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1066,29 +1096,52 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容
|
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容。
|
||||||
// 返回空字符串表示没有主动消息
|
// 返回空字符串表示没有主动消息。
|
||||||
|
//
|
||||||
|
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
|
||||||
|
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
|
||||||
func extractProactiveMessage(content string) string {
|
func extractProactiveMessage(content string) string {
|
||||||
marker := "【主动消息】"
|
marker := "【主动消息】"
|
||||||
idx := strings.Index(content, marker)
|
|
||||||
if idx < 0 {
|
// 扫描每一行,只接受 marker 在行首(忽略前导空白)的行作为指令
|
||||||
return ""
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(trimmed, marker) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 检查否定语境:标记前面的文字包含否定词
|
||||||
|
markerIdx := strings.Index(line, marker)
|
||||||
|
prefix := strings.TrimSpace(line[:markerIdx])
|
||||||
|
if containsNegation(prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 提取标记后的内容
|
||||||
|
msg := strings.TrimSpace(trimmed[len(marker):])
|
||||||
|
if msg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 限制主动消息长度(最多 200 字符,保持简短)
|
||||||
|
runes := []rune(msg)
|
||||||
|
if len(runes) > 200 {
|
||||||
|
msg = string(runes[:200])
|
||||||
|
}
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
// 提取标记后的内容(到下一个标记或结尾)
|
|
||||||
msg := strings.TrimSpace(content[idx+len(marker):])
|
return ""
|
||||||
// 截断到下一个【或换行之前的合理长度
|
}
|
||||||
if endIdx := strings.Index(msg, "【"); endIdx > 0 {
|
|
||||||
msg = strings.TrimSpace(msg[:endIdx])
|
// containsNegation checks if a short prefix string contains negation words
|
||||||
|
// that would nullify the 【主动消息】directive.
|
||||||
|
func containsNegation(prefix string) bool {
|
||||||
|
negations := []string{"不", "别", "不要", "不需要", "不用", "别写", "没", "没有"}
|
||||||
|
for _, n := range negations {
|
||||||
|
if strings.Contains(prefix, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 限制主动消息长度(最多 200 字符,保持简短)
|
return false
|
||||||
runes := []rune(msg)
|
|
||||||
if len(runes) > 200 {
|
|
||||||
msg = string(runes[:200])
|
|
||||||
}
|
|
||||||
if msg == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package context
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IoTDeviceSummary IoT设备摘要接口(避免循环依赖)
|
// IoTDeviceSummary IoT设备摘要接口(避免循环依赖)
|
||||||
@@ -76,6 +79,48 @@ func (cs *ConversationStore) GetHistory(sessionID string, limit int) []model.LLM
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadFromDB 从数据库的 messages 表恢复会话历史到内存
|
||||||
|
func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int) error {
|
||||||
|
db, err := sql.Open("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT role, content FROM messages
|
||||||
|
WHERE session_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $2`,
|
||||||
|
sessionID, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询消息失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
var loaded int
|
||||||
|
for rows.Next() {
|
||||||
|
var roleStr, content string
|
||||||
|
if err := rows.Scan(&roleStr, &content); err != nil {
|
||||||
|
return fmt.Errorf("扫描消息行失败: %w", err)
|
||||||
|
}
|
||||||
|
cs.messages[sessionID] = append(cs.messages[sessionID], model.LLMMessage{
|
||||||
|
Role: model.Role(roleStr),
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
loaded++
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded > 0 {
|
||||||
|
logger.Printf("[context] 从数据库恢复会话 %s 历史 %d 条", sessionID, loaded)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Builder 对话上下文构建器
|
// Builder 对话上下文构建器
|
||||||
type Builder struct {
|
type Builder struct {
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolDefinition 工具定义(用于 LLM function calling)
|
// ToolDefinition 工具定义(用于 LLM function calling)
|
||||||
@@ -31,11 +33,93 @@ type ToolExecutor interface {
|
|||||||
Definition() ToolDefinition
|
Definition() ToolDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallLogRecord 工具调用记录
|
||||||
|
type CallLogRecord struct {
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
ToolName string `json:"tool_name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// callLogRing 线程安全的环形缓冲区
|
||||||
|
type callLogRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []CallLogRecord
|
||||||
|
capacity int
|
||||||
|
head int
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCallLogRing(capacity int) *callLogRing {
|
||||||
|
return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callLogRing) push(rec CallLogRecord) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
rec.Timestamp = time.Now().UnixMilli()
|
||||||
|
r.records[r.head] = rec
|
||||||
|
r.head = (r.head + 1) % r.capacity
|
||||||
|
if r.size < r.capacity {
|
||||||
|
r.size++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callLogRing) get(limit int) []CallLogRecord {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if limit <= 0 || limit > r.size {
|
||||||
|
limit = r.size
|
||||||
|
}
|
||||||
|
result := make([]CallLogRecord, limit)
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
idx := (r.head - 1 - i) % r.capacity
|
||||||
|
if idx < 0 {
|
||||||
|
idx += r.capacity
|
||||||
|
}
|
||||||
|
result[i] = r.records[idx]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callLogRing) statsByTool() map[string]map[string]interface{} {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
byTool := make(map[string]map[string]interface{})
|
||||||
|
for i := 0; i < r.size; i++ {
|
||||||
|
idx := (r.head - 1 - i) % r.capacity
|
||||||
|
if idx < 0 {
|
||||||
|
idx += r.capacity
|
||||||
|
}
|
||||||
|
rec := r.records[idx]
|
||||||
|
if _, ok := byTool[rec.ToolName]; !ok {
|
||||||
|
byTool[rec.ToolName] = map[string]interface{}{
|
||||||
|
"tool_name": rec.ToolName, "count": 0, "success_count": 0, "fail_count": 0, "total_duration_ms": 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := byTool[rec.ToolName]
|
||||||
|
s["count"] = s["count"].(int) + 1
|
||||||
|
if rec.Success {
|
||||||
|
s["success_count"] = s["success_count"].(int) + 1
|
||||||
|
} else {
|
||||||
|
s["fail_count"] = s["fail_count"].(int) + 1
|
||||||
|
}
|
||||||
|
s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs
|
||||||
|
}
|
||||||
|
return byTool
|
||||||
|
}
|
||||||
|
|
||||||
// Registry 工具注册中心
|
// Registry 工具注册中心
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
tools map[string]ToolExecutor
|
tools map[string]ToolExecutor
|
||||||
enabled bool
|
enabled bool
|
||||||
|
callLog *callLogRing
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry 创建工具注册中心
|
// NewRegistry 创建工具注册中心
|
||||||
@@ -43,6 +127,7 @@ func NewRegistry() *Registry {
|
|||||||
return &Registry{
|
return &Registry{
|
||||||
tools: make(map[string]ToolExecutor),
|
tools: make(map[string]ToolExecutor),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
callLog: newCallLogRing(500),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,30 +158,38 @@ func (r *Registry) Execute(ctx context.Context, toolName string, arguments map[s
|
|||||||
executor, ok := r.tools[toolName]
|
executor, ok := r.tools[toolName]
|
||||||
r.mu.RUnlock()
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return &ToolResult{
|
errMsg := fmt.Sprintf("未知工具: %s", toolName)
|
||||||
ToolName: toolName,
|
r.callLog.push(CallLogRecord{
|
||||||
Success: false,
|
ToolName: toolName, Error: errMsg, Success: false, DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||||
Error: fmt.Sprintf("未知工具: %s", toolName),
|
})
|
||||||
}, nil
|
return &ToolResult{ToolName: toolName, Success: false, Error: errMsg}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Printf("[工具执行] 调用工具 %s,参数: %v", toolName, arguments)
|
logger.Printf("[工具执行] 调用工具 %s,参数: %v", toolName, arguments)
|
||||||
result, err := executor.Execute(ctx, arguments)
|
result, err := executor.Execute(ctx, arguments)
|
||||||
|
durationMs := int(time.Since(startTime).Milliseconds())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("[工具执行] 工具 %s 执行失败: %v", toolName, err)
|
logger.Printf("[工具执行] 工具 %s 执行失败: %v", toolName, err)
|
||||||
return &ToolResult{
|
r.callLog.push(CallLogRecord{
|
||||||
ToolName: toolName,
|
ToolName: toolName, Error: err.Error(), Success: false, DurationMs: durationMs,
|
||||||
Success: false,
|
})
|
||||||
Error: err.Error(),
|
return &ToolResult{ToolName: toolName, Success: false, Error: err.Error()}, nil
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
argsJSON, _ := json.Marshal(arguments)
|
||||||
if result.Success {
|
if result.Success {
|
||||||
logger.Printf("[工具执行] 工具 %s 执行成功 (数据长度: %d)", toolName, len(result.Data))
|
logger.Printf("[工具执行] 工具 %s 执行成功 (数据长度: %d)", toolName, len(result.Data))
|
||||||
} else {
|
} else {
|
||||||
logger.Printf("[工具执行] 工具 %s 返回错误: %s", toolName, result.Error)
|
logger.Printf("[工具执行] 工具 %s 返回错误: %s", toolName, result.Error)
|
||||||
}
|
}
|
||||||
|
r.callLog.push(CallLogRecord{
|
||||||
|
ToolName: toolName, Arguments: string(argsJSON), Output: result.Data,
|
||||||
|
Error: result.Error, Success: result.Success, DurationMs: durationMs,
|
||||||
|
})
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -135,6 +228,63 @@ func (r *Registry) ListTools() []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCallLogs 获取工具调用记录(最新在前)
|
||||||
|
func (r *Registry) GetCallLogs(toolName string, limit int) []CallLogRecord {
|
||||||
|
all := r.callLog.get(r.callLog.size)
|
||||||
|
if toolName == "" {
|
||||||
|
if limit > 0 && limit < len(all) {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
filtered := make([]CallLogRecord, 0)
|
||||||
|
for _, rec := range all {
|
||||||
|
if rec.ToolName == toolName {
|
||||||
|
filtered = append(filtered, rec)
|
||||||
|
if limit > 0 && len(filtered) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallStats 获取工具调用统计
|
||||||
|
func (r *Registry) GetCallStats() map[string]interface{} {
|
||||||
|
byTool := r.callLog.statsByTool()
|
||||||
|
totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0
|
||||||
|
toolStats := make([]map[string]interface{}, 0, len(byTool))
|
||||||
|
for _, s := range byTool {
|
||||||
|
count := s["count"].(int)
|
||||||
|
success := s["success_count"].(int)
|
||||||
|
fail := s["fail_count"].(int)
|
||||||
|
totalDur := s["total_duration_ms"].(int)
|
||||||
|
avgDur := 0.0
|
||||||
|
if count > 0 {
|
||||||
|
avgDur = float64(totalDur) / float64(count)
|
||||||
|
}
|
||||||
|
s["avg_duration_ms"] = avgDur
|
||||||
|
delete(s, "total_duration_ms")
|
||||||
|
toolStats = append(toolStats, s)
|
||||||
|
totalCalls += count
|
||||||
|
successCount += success
|
||||||
|
failCount += fail
|
||||||
|
totalDurationMs += totalDur
|
||||||
|
}
|
||||||
|
avgDuration := 0.0
|
||||||
|
if totalCalls > 0 {
|
||||||
|
avgDuration = float64(totalDurationMs) / float64(totalCalls)
|
||||||
|
}
|
||||||
|
successRate := 0.0
|
||||||
|
if totalCalls > 0 {
|
||||||
|
successRate = float64(successCount) / float64(totalCalls) * 100
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_calls": totalCalls, "success_count": successCount, "fail_count": failCount,
|
||||||
|
"success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ToJSON 将工具定义序列化为 JSON(用于 LLM 请求)
|
// ToJSON 将工具定义序列化为 JSON(用于 LLM 请求)
|
||||||
func (r *Registry) ToJSON() ([]byte, error) {
|
func (r *Registry) ToJSON() ([]byte, error) {
|
||||||
defs := r.GetDefinitions()
|
defs := r.GetDefinitions()
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("[tool-engine-client] 从 tool-engine 获取了 %d 个工具定义", len(defs))
|
|
||||||
return defs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute 通过 tool-engine 执行工具调用
|
|
||||||
// 包含重试逻辑:最多重试 2 次(共 3 次尝试),间隔 100ms
|
|
||||||
func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
|
||||||
const maxRetries = 2
|
|
||||||
const retryDelay = 100 * time.Millisecond
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
logger.Printf("[tool-engine-client] 工具 %s 第 %d 次重试 (上次错误: %v)", toolName, attempt, lastErr)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return &ToolResult{
|
|
||||||
ToolName: toolName,
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("请求被取消: %v", ctx.Err()),
|
|
||||||
}, nil
|
|
||||||
case <-time.After(retryDelay):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := c.executeOnce(ctx, toolName, arguments)
|
|
||||||
if err == nil && result.Success {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
lastErr = fmt.Errorf("%s", result.Error)
|
|
||||||
} else {
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不可重试的错误:工具不存在、参数序列化失败、创建请求失败
|
|
||||||
if result != nil && (strings.Contains(result.Error, "不存在") ||
|
|
||||||
strings.Contains(result.Error, "序列化") ||
|
|
||||||
strings.Contains(result.Error, "创建请求")) {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("[tool-engine-client] 工具 %s 所有重试均失败 (最后错误: %v)", toolName, lastErr)
|
|
||||||
return &ToolResult{
|
|
||||||
ToolName: toolName,
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("请求 tool-engine 失败 (已重试 %d 次): %v", maxRetries, lastErr),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeOnce 执行单次工具调用(不含重试逻辑)
|
|
||||||
func (c *ToolEngineClient) executeOnce(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
|
|
||||||
}
|
|
||||||
@@ -185,8 +185,17 @@ func main() {
|
|||||||
logger.Println("[INFO] 模型配置文件不存在,回退到 .env LLM 配置")
|
logger.Println("[INFO] 模型配置文件不存在,回退到 .env LLM 配置")
|
||||||
}
|
}
|
||||||
|
|
||||||
router.Setup(r, hub, cfg, sessionStore, reminderStore, briefingStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil, db, modelConfigStore)
|
// 初始化思考调度配置存储
|
||||||
|
thinkingScheduleStore, err := config.NewThinkingScheduleStore("../thinking_schedule.json")
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("[WARN] 思考调度配置存储初始化失败: %v", err)
|
||||||
|
thinkingScheduleStore = nil
|
||||||
|
} else {
|
||||||
|
logger.Println("[INFO] 思考调度配置文件已加载 (thinking_schedule.json)")
|
||||||
|
}
|
||||||
|
|
||||||
|
router.Setup(r, hub, cfg, sessionStore, reminderStore, briefingStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil, db, modelConfigStore, thinkingScheduleStore)
|
||||||
|
|
||||||
// 启动提醒调度器
|
// 启动提醒调度器
|
||||||
if reminderStore != nil {
|
if reminderStore != nil {
|
||||||
handler.StartReminderScheduler(reminderStore, hub)
|
handler.StartReminderScheduler(reminderStore, hub)
|
||||||
|
|||||||
@@ -238,8 +238,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
// 增大 scanner buffer 以处理大块 SSE 数据
|
// 增大 scanner buffer 以处理大块 SSE 数据
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
// 通知前端 AI 开始生成回复
|
// 通知所有客户端 AI 开始生成回复
|
||||||
client.SendMessage(ws.ServerMessage{
|
h.broadcastToUser(client.UserID, ws.ServerMessage{
|
||||||
Type: "stream_start",
|
Type: "stream_start",
|
||||||
MessageID: "msg_" + generateID(),
|
MessageID: "msg_" + generateID(),
|
||||||
SessionID: client.SessionID,
|
SessionID: client.SessionID,
|
||||||
@@ -336,7 +336,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
ClientInfo: clientInfo,
|
ClientInfo: clientInfo,
|
||||||
})
|
})
|
||||||
client.SendMessage(ws.ServerMessage{
|
h.broadcastToUser(client.UserID, ws.ServerMessage{
|
||||||
Type: "response",
|
Type: "response",
|
||||||
MessageID: reviewMsgID,
|
MessageID: reviewMsgID,
|
||||||
Content: rm.Content,
|
Content: rm.Content,
|
||||||
@@ -363,8 +363,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
Text: seg.Text,
|
Text: seg.Text,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 发送断句事件给前端
|
// 发送断句事件给所有客户端
|
||||||
client.SendMessage(ws.ServerMessage{
|
h.broadcastToUser(client.UserID, ws.ServerMessage{
|
||||||
Type: "stream_segments",
|
Type: "stream_segments",
|
||||||
MessageID: msgID,
|
MessageID: msgID,
|
||||||
Segments: segments,
|
Segments: segments,
|
||||||
@@ -408,7 +408,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
Content: part,
|
Content: part,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
client.SendMessage(ws.ServerMessage{
|
h.broadcastToUser(client.UserID, ws.ServerMessage{
|
||||||
Type: "multi_message",
|
Type: "multi_message",
|
||||||
MessageID: msgID,
|
MessageID: msgID,
|
||||||
SessionID: client.SessionID,
|
SessionID: client.SessionID,
|
||||||
@@ -419,8 +419,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 stream_end
|
// 发送 stream_end 到所有客户端
|
||||||
client.SendMessage(ws.ServerMessage{
|
h.broadcastToUser(client.UserID, ws.ServerMessage{
|
||||||
Type: "stream_end",
|
Type: "stream_end",
|
||||||
MessageID: msgID,
|
MessageID: msgID,
|
||||||
SessionID: client.SessionID,
|
SessionID: client.SessionID,
|
||||||
@@ -919,6 +919,16 @@ func randomStr(n int) string {
|
|||||||
return hex.EncodeToString(b)[:n]
|
return hex.EncodeToString(b)[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// broadcastToUser sends a server message to ALL connected clients for a user.
|
||||||
|
func (h *ChatHandler) broadcastToUser(userID string, msg ws.ServerMessage) {
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("[chat] 序列化广播消息失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.hub.SendToUser(userID, data)
|
||||||
|
}
|
||||||
|
|
||||||
// parseMultiMessage 检测并解析多消息格式
|
// parseMultiMessage 检测并解析多消息格式
|
||||||
// 如果文本包含空行分隔的多条消息,拆分为多条;否则返回单条
|
// 如果文本包含空行分隔的多条消息,拆分为多条;否则返回单条
|
||||||
func parseMultiMessage(text string) []string {
|
func parseMultiMessage(text string) []string {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Setup 注册所有路由
|
// Setup 注册所有路由
|
||||||
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore, reminderStore *store.ReminderStore, briefingStore *store.BriefingStore, automationStore *store.AutomationStore, fileStore *store.FileStore, ruleEngine *engine.RuleEngine, knowledgeStore *store.KnowledgeStore, imageHandler *handler.ImageHandler, db interface{}, modelConfigStore *config.ModelsConfigStore) {
|
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore, reminderStore *store.ReminderStore, briefingStore *store.BriefingStore, automationStore *store.AutomationStore, fileStore *store.FileStore, ruleEngine *engine.RuleEngine, knowledgeStore *store.KnowledgeStore, imageHandler *handler.ImageHandler, db interface{}, modelConfigStore *config.ModelsConfigStore, thinkingScheduleStore *config.ThinkingScheduleStore) {
|
||||||
// 限流器
|
// 限流器
|
||||||
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
|
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
|||||||
automationHandler := handler.NewAutomationHandler(automationStore, ruleEngine)
|
automationHandler := handler.NewAutomationHandler(automationStore, ruleEngine)
|
||||||
knowledgeHandler := handler.NewKnowledgeHandler(knowledgeStore, fileStore)
|
knowledgeHandler := handler.NewKnowledgeHandler(knowledgeStore, fileStore)
|
||||||
modelConfigHandler := handler.NewModelConfigHandler(modelConfigStore)
|
modelConfigHandler := handler.NewModelConfigHandler(modelConfigStore)
|
||||||
|
thinkingScheduleHandler := handler.NewThinkingScheduleHandler(thinkingScheduleStore)
|
||||||
if imageHandler == nil {
|
if imageHandler == nil {
|
||||||
imageHandler = handler.NewImageHandler(cfg, fileStore)
|
imageHandler = handler.NewImageHandler(cfg, fileStore)
|
||||||
}
|
}
|
||||||
@@ -226,6 +227,13 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
|||||||
models.POST("/health-check", modelConfigHandler.TestProvider)
|
models.POST("/health-check", modelConfigHandler.TestProvider)
|
||||||
models.GET("/fetch-models/:name", modelConfigHandler.ProxyListModels)
|
models.GET("/fetch-models/:name", modelConfigHandler.ProxyListModels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 思考调度配置
|
||||||
|
thinkingSchedule := admin.Group("/thinking-schedule")
|
||||||
|
{
|
||||||
|
thinkingSchedule.GET("", thinkingScheduleHandler.GetSchedule)
|
||||||
|
thinkingSchedule.PUT("", thinkingScheduleHandler.SetSchedule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ use (
|
|||||||
./iot-debug-service
|
./iot-debug-service
|
||||||
./memory-service
|
./memory-service
|
||||||
./pkg/logger
|
./pkg/logger
|
||||||
./plugin-manager
|
./pkg/plugins
|
||||||
./platform-bridge
|
./platform-bridge
|
||||||
./tool-engine
|
./plugin-manager
|
||||||
./voice-service
|
./voice-service
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CalculatorPlugin struct {
|
type CalculatorPlugin struct {
|
||||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
"hash"
|
"hash"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CryptoPlugin struct{ sdk.BasePlugin }
|
type CryptoPlugin struct{ sdk.BasePlugin }
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DatetimePlugin struct{ sdk.BasePlugin }
|
type DatetimePlugin struct{ sdk.BasePlugin }
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FilePlugin struct {
|
type FilePlugin struct {
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/yourname/cyrene-ai/pkg/plugins
|
||||||
|
|
||||||
|
go 1.21
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPPlugin struct {
|
type HTTPPlugin struct {
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IoTController extends IoTClient with control operations.
|
// IoTController extends IoTClient with control operations.
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IoTClient is the interface for IoT device access.
|
// IoTClient is the interface for IoT device access.
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JSONPlugin struct{ sdk.BasePlugin }
|
type JSONPlugin struct{ sdk.BasePlugin }
|
||||||
+31
-29
@@ -6,13 +6,13 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PluginManager manages the lifecycle of all plugins and their tools.
|
// PluginManager manages the lifecycle of all plugins and their tools.
|
||||||
type PluginManager struct {
|
type PluginManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins map[string]*pluginEntry // plugin name -> entry
|
plugins map[string]*pluginEntry
|
||||||
registry *ToolRegistry
|
registry *ToolRegistry
|
||||||
host sdk.HostAPI
|
host sdk.HostAPI
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ func NewPluginManager(registry *ToolRegistry, host sdk.HostAPI) *PluginManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install registers a plugin instance. For built-in plugins this is called at startup.
|
// Install registers a plugin instance.
|
||||||
func (m *PluginManager) Install(plugin sdk.Plugin) error {
|
func (m *PluginManager) Install(plugin sdk.Plugin) error {
|
||||||
meta := plugin.Metadata()
|
meta := plugin.Metadata()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -83,7 +83,6 @@ func (m *PluginManager) Enable(ctx context.Context, pluginName string) error {
|
|||||||
return fmt.Errorf("plugin %q start failed: %w", meta.Name, err)
|
return fmt.Errorf("plugin %q start failed: %w", meta.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all tools from this plugin.
|
|
||||||
tools := entry.instance.Tools()
|
tools := entry.instance.Tools()
|
||||||
toolIDs := make([]string, 0, len(tools))
|
toolIDs := make([]string, 0, len(tools))
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
@@ -133,22 +132,6 @@ func (m *PluginManager) Disable(ctx context.Context, pluginName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall removes a plugin completely.
|
|
||||||
func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error {
|
|
||||||
if err := m.Disable(ctx, pluginName); err != nil {
|
|
||||||
// If already disabled, continue.
|
|
||||||
if entry, ok := m.plugins[pluginName]; !ok || entry.info.Status != sdk.StatusRunning {
|
|
||||||
// not running, skip stop
|
|
||||||
} else {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
delete(m.plugins, pluginName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns info for all installed plugins.
|
// List returns info for all installed plugins.
|
||||||
func (m *PluginManager) List() []sdk.PluginInfo {
|
func (m *PluginManager) List() []sdk.PluginInfo {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -168,18 +151,10 @@ func (m *PluginManager) Get(pluginName string) (*sdk.PluginInfo, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
info := entry.info // copy
|
info := entry.info
|
||||||
return &info, true
|
return &info, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload stops and re-starts a plugin.
|
|
||||||
func (m *PluginManager) Reload(ctx context.Context, pluginName string) error {
|
|
||||||
if err := m.Disable(ctx, pluginName); err != nil {
|
|
||||||
return fmt.Errorf("reload disable: %w", err)
|
|
||||||
}
|
|
||||||
return m.Enable(ctx, pluginName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnableAll starts all installed plugins.
|
// EnableAll starts all installed plugins.
|
||||||
func (m *PluginManager) EnableAll(ctx context.Context) []error {
|
func (m *PluginManager) EnableAll(ctx context.Context) []error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -198,6 +173,33 @@ func (m *PluginManager) EnableAll(ctx context.Context) []error {
|
|||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uninstall removes a plugin completely.
|
||||||
|
func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
entry, ok := m.plugins[pluginName]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin %q not found", pluginName)
|
||||||
|
}
|
||||||
|
if entry.info.Status == sdk.StatusRunning {
|
||||||
|
if err := m.Disable(ctx, pluginName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.plugins, pluginName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload stops and re-starts a plugin.
|
||||||
|
func (m *PluginManager) Reload(ctx context.Context, pluginName string) error {
|
||||||
|
if err := m.Disable(ctx, pluginName); err != nil {
|
||||||
|
return fmt.Errorf("reload disable: %w", err)
|
||||||
|
}
|
||||||
|
return m.Enable(ctx, pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown stops all running plugins gracefully.
|
// Shutdown stops all running plugins gracefully.
|
||||||
func (m *PluginManager) Shutdown(ctx context.Context) []error {
|
func (m *PluginManager) Shutdown(ctx context.Context) []error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CallLogRecord 工具调用记录
|
||||||
|
type CallLogRecord struct {
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
ToolName string `json:"tool_name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// callLogRing 线程安全的环形缓冲区
|
||||||
|
type callLogRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []CallLogRecord
|
||||||
|
capacity int
|
||||||
|
head int
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCallLogRing(capacity int) *callLogRing {
|
||||||
|
return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callLogRing) push(rec CallLogRecord) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
rec.Timestamp = time.Now().UnixMilli()
|
||||||
|
r.records[r.head] = rec
|
||||||
|
r.head = (r.head + 1) % r.capacity
|
||||||
|
if r.size < r.capacity {
|
||||||
|
r.size++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callLogRing) getAll() []CallLogRecord {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
result := make([]CallLogRecord, r.size)
|
||||||
|
for i := 0; i < r.size; i++ {
|
||||||
|
idx := (r.head - 1 - i) % r.capacity
|
||||||
|
if idx < 0 {
|
||||||
|
idx += r.capacity
|
||||||
|
}
|
||||||
|
result[i] = r.records[idx]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callLogRing) statsByTool() map[string]map[string]interface{} {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
byTool := make(map[string]map[string]interface{})
|
||||||
|
for i := 0; i < r.size; i++ {
|
||||||
|
idx := (r.head - 1 - i) % r.capacity
|
||||||
|
if idx < 0 {
|
||||||
|
idx += r.capacity
|
||||||
|
}
|
||||||
|
rec := r.records[idx]
|
||||||
|
if _, ok := byTool[rec.ToolName]; !ok {
|
||||||
|
byTool[rec.ToolName] = map[string]interface{}{
|
||||||
|
"tool_name": rec.ToolName, "count": 0, "success_count": 0,
|
||||||
|
"fail_count": 0, "total_duration_ms": 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := byTool[rec.ToolName]
|
||||||
|
s["count"] = s["count"].(int) + 1
|
||||||
|
if rec.Success {
|
||||||
|
s["success_count"] = s["success_count"].(int) + 1
|
||||||
|
} else {
|
||||||
|
s["fail_count"] = s["fail_count"].(int) + 1
|
||||||
|
}
|
||||||
|
s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs
|
||||||
|
}
|
||||||
|
return byTool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolRegistry aggregates tool definitions from all running plugins and dispatches execution.
|
||||||
|
type ToolRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tools map[string]sdk.Tool // tool ID -> Tool
|
||||||
|
callLog *callLogRing
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewToolRegistry() *ToolRegistry {
|
||||||
|
return &ToolRegistry{
|
||||||
|
tools: make(map[string]sdk.Tool),
|
||||||
|
callLog: newCallLogRing(500),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns whether tool execution is enabled.
|
||||||
|
func (r *ToolRegistry) IsEnabled() bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled enables or disables tool execution.
|
||||||
|
func (r *ToolRegistry) SetEnabled(enabled bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefinitionNames returns all registered tool names.
|
||||||
|
func (r *ToolRegistry) DefinitionNames() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
names := make([]string, 0, len(r.tools))
|
||||||
|
for id := range r.tools {
|
||||||
|
names = append(names, id)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ToolRegistry) Register(tool sdk.Tool) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
id := tool.Definition().ID
|
||||||
|
if _, exists := r.tools[id]; exists {
|
||||||
|
return fmt.Errorf("tool %q already registered", id)
|
||||||
|
}
|
||||||
|
r.tools[id] = tool
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ToolRegistry) Unregister(toolID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.tools, toolID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ToolRegistry) Get(toolID string) (sdk.Tool, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
t, ok := r.tools[toolID]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ToolRegistry) List() []sdk.Tool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]sdk.Tool, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ToolRegistry) Definitions() []sdk.ToolDefinition {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
defs := make([]sdk.ToolDefinition, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
defs = append(defs, t.Definition())
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ToolRegistry) Execute(ctx context.Context, toolID string, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
tool, ok := r.tools[toolID]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
r.callLog.push(CallLogRecord{
|
||||||
|
ToolName: toolID, Error: fmt.Sprintf("tool %q not found", toolID),
|
||||||
|
Success: false, DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("tool %q not found", toolID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tool.Validate(args); err != nil {
|
||||||
|
r.callLog.push(CallLogRecord{
|
||||||
|
ToolName: toolID, Error: err.Error(), Success: false,
|
||||||
|
DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||||
|
})
|
||||||
|
return &sdk.ToolResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tool.Execute(ctx, args)
|
||||||
|
durationMs := int(time.Since(startTime).Milliseconds())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.callLog.push(CallLogRecord{
|
||||||
|
ToolName: toolID, Error: err.Error(), Success: false, DurationMs: durationMs,
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var argsJSON string
|
||||||
|
if args != nil {
|
||||||
|
if b, _ := json.Marshal(args); b != nil {
|
||||||
|
argsJSON = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.callLog.push(CallLogRecord{
|
||||||
|
ToolName: toolID, Arguments: argsJSON, Output: result.Output,
|
||||||
|
Error: result.Error, Success: result.Success, DurationMs: durationMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterAll removes all tools matching given IDs.
|
||||||
|
func (r *ToolRegistry) UnregisterAll(toolIDs []string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, id := range toolIDs {
|
||||||
|
delete(r.tools, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallLogs 获取工具调用记录(最新在前,支持按工具名过滤)
|
||||||
|
func (r *ToolRegistry) GetCallLogs(toolName string, limit int) []CallLogRecord {
|
||||||
|
all := r.callLog.getAll()
|
||||||
|
if toolName == "" {
|
||||||
|
if limit > 0 && limit < len(all) {
|
||||||
|
return all[:limit]
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
filtered := make([]CallLogRecord, 0)
|
||||||
|
for _, rec := range all {
|
||||||
|
if rec.ToolName == toolName {
|
||||||
|
filtered = append(filtered, rec)
|
||||||
|
if limit > 0 && len(filtered) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallStats 获取工具调用统计
|
||||||
|
func (r *ToolRegistry) GetCallStats() map[string]interface{} {
|
||||||
|
byTool := r.callLog.statsByTool()
|
||||||
|
totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0
|
||||||
|
toolStats := make([]map[string]interface{}, 0, len(byTool))
|
||||||
|
for _, s := range byTool {
|
||||||
|
count := s["count"].(int)
|
||||||
|
success := s["success_count"].(int)
|
||||||
|
fail := s["fail_count"].(int)
|
||||||
|
totalDur := s["total_duration_ms"].(int)
|
||||||
|
avgDur := 0.0
|
||||||
|
if count > 0 {
|
||||||
|
avgDur = float64(totalDur) / float64(count)
|
||||||
|
}
|
||||||
|
s["avg_duration_ms"] = avgDur
|
||||||
|
delete(s, "total_duration_ms")
|
||||||
|
toolStats = append(toolStats, s)
|
||||||
|
totalCalls += count
|
||||||
|
successCount += success
|
||||||
|
failCount += fail
|
||||||
|
totalDurationMs += totalDur
|
||||||
|
}
|
||||||
|
avgDuration := 0.0
|
||||||
|
if totalCalls > 0 {
|
||||||
|
avgDuration = float64(totalDurationMs) / float64(totalCalls)
|
||||||
|
}
|
||||||
|
successRate := 0.0
|
||||||
|
if totalCalls > 0 {
|
||||||
|
successRate = float64(successCount) / float64(totalCalls) * 100
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_calls": totalCalls, "success_count": successCount, "fail_count": failCount,
|
||||||
|
"success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats,
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MarkdownPlugin struct{ sdk.BasePlugin }
|
type MarkdownPlugin struct{ sdk.BasePlugin }
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
mathrand "math/rand"
|
mathrand "math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RandomPlugin struct{ sdk.BasePlugin }
|
type RandomPlugin struct{ sdk.BasePlugin }
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TextPlugin struct{ sdk.BasePlugin }
|
type TextPlugin struct{ sdk.BasePlugin }
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebFetchPlugin struct {
|
type WebFetchPlugin struct {
|
||||||
+1
-1
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebSearchPlugin struct {
|
type WebSearchPlugin struct {
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/manager"
|
"github.com/yourname/cyrene-ai/pkg/plugins/manager"
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type hostAPI struct {
|
type hostAPI struct {
|
||||||
@@ -38,13 +38,9 @@ func (h *hostAPI) GetConfig(key string) (string, error) {
|
|||||||
return "", fmt.Errorf("config key not found: %s", key)
|
return "", fmt.Errorf("config key not found: %s", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hostAPI) SetConfig(_, _ string) error {
|
func (h *hostAPI) SetConfig(_, _ string) error { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *hostAPI) PublishEvent(_ context.Context, _ map[string]interface{}) error {
|
func (h *hostAPI) PublishEvent(_ context.Context, _ map[string]interface{}) error { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *hostAPI) HTTPClient() *http.Client {
|
func (h *hostAPI) HTTPClient() *http.Client {
|
||||||
return http.DefaultClient
|
return http.DefaultClient
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
iotquery "github.com/yourname/cyrene-ai/plugin-manager/plugins/iot_query"
|
iotquery "github.com/yourname/cyrene-ai/pkg/plugins/iot_query"
|
||||||
)
|
)
|
||||||
|
|
||||||
type iotClient struct {
|
type iotClient struct {
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/calculator"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/crypto"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/datetime"
|
||||||
|
fileplugin "github.com/yourname/cyrene-ai/pkg/plugins/file"
|
||||||
|
httpplugin "github.com/yourname/cyrene-ai/pkg/plugins/http"
|
||||||
|
iotcontrol "github.com/yourname/cyrene-ai/pkg/plugins/iot_control"
|
||||||
|
iotquery "github.com/yourname/cyrene-ai/pkg/plugins/iot_query"
|
||||||
|
jsonplugin "github.com/yourname/cyrene-ai/pkg/plugins/json"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/manager"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/markdown"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/random"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||||
|
"github.com/yourname/cyrene-ai/pkg/plugins/text"
|
||||||
|
webfetch "github.com/yourname/cyrene-ai/pkg/plugins/web_fetch"
|
||||||
|
websearch "github.com/yourname/cyrene-ai/pkg/plugins/web_search"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/config"
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/config"
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/handler"
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/handler"
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/manager"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/plugins/calculator"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/plugins/crypto"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/plugins/datetime"
|
|
||||||
fileplugin "github.com/yourname/cyrene-ai/plugin-manager/plugins/file"
|
|
||||||
httpplugin "github.com/yourname/cyrene-ai/plugin-manager/plugins/http"
|
|
||||||
iotcontrol "github.com/yourname/cyrene-ai/plugin-manager/plugins/iot_control"
|
|
||||||
iotquery "github.com/yourname/cyrene-ai/plugin-manager/plugins/iot_query"
|
|
||||||
jsonplugin "github.com/yourname/cyrene-ai/plugin-manager/plugins/json"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/plugins/markdown"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/plugins/random"
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/plugins/text"
|
|
||||||
webfetch "github.com/yourname/cyrene-ai/plugin-manager/plugins/web_fetch"
|
|
||||||
websearch "github.com/yourname/cyrene-ai/plugin-manager/plugins/web_search"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/manager"
|
"github.com/yourname/cyrene-ai/pkg/plugins/manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PluginHandler exposes the Plugin Manager REST API via net/http.
|
// PluginHandler exposes the Plugin Manager REST API via net/http.
|
||||||
@@ -39,13 +39,11 @@ func (h *PluginHandler) listPlugins(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PluginHandler) pluginRoute(w http.ResponseWriter, r *http.Request) {
|
func (h *PluginHandler) pluginRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
// Path: /api/v1/plugins/{id}[/enable|/disable|/reload|/tools]
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/")
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
pluginID := parts[0]
|
pluginID := parts[0]
|
||||||
|
|
||||||
if pluginID == "" {
|
if pluginID == "" {
|
||||||
// GET /api/v1/plugins (handled by listPlugins normally)
|
|
||||||
h.listPlugins(w, r)
|
h.listPlugins(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -163,7 +161,6 @@ func (h *PluginHandler) toolRoute(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tools/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tools/")
|
||||||
toolID := path
|
toolID := path
|
||||||
|
|
||||||
// Check if this is an execute call
|
|
||||||
if strings.HasSuffix(path, "/execute") {
|
if strings.HasSuffix(path, "/execute") {
|
||||||
toolID = strings.TrimSuffix(path, "/execute")
|
toolID = strings.TrimSuffix(path, "/execute")
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
package manager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ToolRegistry aggregates tool definitions from all running plugins and dispatches execution.
|
|
||||||
type ToolRegistry struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
tools map[string]sdk.Tool // tool ID -> Tool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewToolRegistry() *ToolRegistry {
|
|
||||||
return &ToolRegistry{tools: make(map[string]sdk.Tool)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ToolRegistry) Register(tool sdk.Tool) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
id := tool.Definition().ID
|
|
||||||
if _, exists := r.tools[id]; exists {
|
|
||||||
return fmt.Errorf("tool %q already registered", id)
|
|
||||||
}
|
|
||||||
r.tools[id] = tool
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ToolRegistry) Unregister(toolID string) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
delete(r.tools, toolID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ToolRegistry) Get(toolID string) (sdk.Tool, bool) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
t, ok := r.tools[toolID]
|
|
||||||
return t, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ToolRegistry) List() []sdk.Tool {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
result := make([]sdk.Tool, 0, len(r.tools))
|
|
||||||
for _, t := range r.tools {
|
|
||||||
result = append(result, t)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ToolRegistry) Definitions() []sdk.ToolDefinition {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
defs := make([]sdk.ToolDefinition, 0, len(r.tools))
|
|
||||||
for _, t := range r.tools {
|
|
||||||
defs = append(defs, t.Definition())
|
|
||||||
}
|
|
||||||
return defs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ToolRegistry) Execute(ctx context.Context, toolID string, args map[string]interface{}) (*sdk.ToolResult, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
tool, ok := r.tools[toolID]
|
|
||||||
r.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("tool %q not found", toolID)
|
|
||||||
}
|
|
||||||
if err := tool.Validate(args); err != nil {
|
|
||||||
return &sdk.ToolResult{Success: false, Error: err.Error()}, nil
|
|
||||||
}
|
|
||||||
return tool.Execute(ctx, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnregisterAll removes all tools matching a prefix (plugin's tools).
|
|
||||||
func (r *ToolRegistry) UnregisterAll(toolIDs []string) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
for _, id := range toolIDs {
|
|
||||||
delete(r.tools, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "calculator",
|
|
||||||
"displayName": "Calculator",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Safe mathematical expression evaluation with custom parser",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["math", "calculator", "arithmetic"],
|
|
||||||
"category": "utility"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"displayName": "Crypto & Encoding",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Hashing (MD5/SHA) and encoding (Base64, URL) utilities",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["crypto", "hash", "base64", "encode"],
|
|
||||||
"category": "utility"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "datetime",
|
|
||||||
"displayName": "Date & Time",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Date/time utilities: now, format, arithmetic, diff, timezone list",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["datetime", "time", "timezone"],
|
|
||||||
"category": "utility"
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "file",
|
|
||||||
"displayName": "File Operations",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Sandboxed file operations: read, write, list, delete within DATA_DIR",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["file", "read", "write"],
|
|
||||||
"category": "system",
|
|
||||||
"permissions": ["file:read", "file:write"]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "http",
|
|
||||||
"displayName": "HTTP Client",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Send arbitrary HTTP requests with custom methods, headers, body",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["http", "request", "fetch"],
|
|
||||||
"category": "network",
|
|
||||||
"permissions": ["network:outbound"]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "iot_control",
|
|
||||||
"displayName": "IoT Device Control",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Control smart home devices: toggle, set temperature/brightness/mode/color",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["iot", "control", "toggle", "temperature"],
|
|
||||||
"category": "iot",
|
|
||||||
"permissions": ["iot:read", "iot:write"]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "iot_query",
|
|
||||||
"displayName": "IoT Device Query",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Query smart home device status (single device or all devices)",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["iot", "query", "device", "status"],
|
|
||||||
"category": "iot",
|
|
||||||
"permissions": ["iot:read"]
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "json",
|
|
||||||
"displayName": "JSON Processor",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "JSON parsing, dot-path query, validation, pretty-print",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["json", "parse", "query", "validate"],
|
|
||||||
"category": "format"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "markdown",
|
|
||||||
"displayName": "Markdown Processor",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Markdown processing: to HTML, extract text/links/code, generate TOC",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["markdown", "html", "text", "toc"],
|
|
||||||
"category": "format"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "random",
|
|
||||||
"displayName": "Random Generator",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Random generation: numbers, UUIDs, secure passwords, pick/shuffle",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["random", "uuid", "password", "shuffle"],
|
|
||||||
"category": "utility"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "text",
|
|
||||||
"displayName": "Text Processing",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Text processing: count stats, summarize, regex extract",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["text", "count", "summarize", "extract"],
|
|
||||||
"category": "utility"
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web_fetch",
|
|
||||||
"displayName": "Web Fetch",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Fetch and extract text content from URLs",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["fetch", "web", "scrape"],
|
|
||||||
"category": "network",
|
|
||||||
"permissions": ["network:outbound"]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web_search",
|
|
||||||
"displayName": "Web Search",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minCyreneVersion": "1.0.0",
|
|
||||||
"author": { "name": "Cyrene Team" },
|
|
||||||
"description": "Search the internet via DuckDuckGo Instant Answer API",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["search", "web", "duckduckgo"],
|
|
||||||
"category": "network",
|
|
||||||
"permissions": ["network:outbound"]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM golang:1.26-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /tool-engine ./cmd/
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
FROM alpine:3.21
|
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /tool-engine .
|
|
||||||
|
|
||||||
EXPOSE 8092
|
|
||||||
|
|
||||||
ENTRYPOINT ["./tool-engine"]
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/config"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/handler"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/service"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/store"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/tools"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger.SetDefault(logger.New("tool-engine"))
|
|
||||||
logger.Println("🔧 Tool-Engine 启动中...")
|
|
||||||
|
|
||||||
// 加载配置
|
|
||||||
cfg := config.Load()
|
|
||||||
|
|
||||||
logger.Printf("配置: 端口=%s, IoT服务=%s, 数据目录=%s, DB=%s", cfg.Port, cfg.IoTServiceURL, cfg.DataDir, cfg.DBUrl)
|
|
||||||
|
|
||||||
// 初始化调用日志存储
|
|
||||||
callLogStore, err := store.NewCallLogStore(cfg.DBUrl)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[main] 初始化调用日志存储失败: %v", err)
|
|
||||||
callLogStore = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 IoT 客户端
|
|
||||||
var iotClient tools.IoTClientInterface
|
|
||||||
if cfg.IoTServiceURL != "" {
|
|
||||||
iotClient = tools.NewIoTClient(cfg.IoTServiceURL)
|
|
||||||
logger.Printf("[main] IoT 客户端已初始化: %s", cfg.IoTServiceURL)
|
|
||||||
} else {
|
|
||||||
logger.Println("[main] IoT 服务 URL 未配置,IoT 工具将不可用")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化服务层
|
|
||||||
svc := service.NewToolService(iotClient, cfg.DataDir)
|
|
||||||
|
|
||||||
// 初始化 HTTP 处理器
|
|
||||||
h := handler.NewToolHandler(svc, callLogStore)
|
|
||||||
|
|
||||||
// 注册路由
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
h.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
// 健康检查端点
|
|
||||||
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"status":"ok","service":"tool-engine"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 启动 HTTP 服务
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: ":" + cfg.Port,
|
|
||||||
Handler: mux,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
logger.Printf("🚀 Tool-Engine 已启动在端口 %s", cfg.Port)
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.Fatalf("服务启动失败: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 优雅关闭
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-quit
|
|
||||||
logger.Println("正在关闭 Tool-Engine...")
|
|
||||||
|
|
||||||
if callLogStore != nil {
|
|
||||||
callLogStore.Close()
|
|
||||||
}
|
|
||||||
srv.Close()
|
|
||||||
logger.Println("Tool-Engine 已关闭")
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
module github.com/yourname/cyrene-ai/tool-engine
|
|
||||||
|
|
||||||
go 1.26.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/lib/pq v1.10.9
|
|
||||||
github.com/yourname/cyrene-ai/pkg/logger v0.0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
replace github.com/yourname/cyrene-ai/pkg/logger => ../pkg/logger
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config 工具引擎服务配置
|
|
||||||
type Config struct {
|
|
||||||
Port string
|
|
||||||
IoTServiceURL string
|
|
||||||
DataDir string
|
|
||||||
DBUrl string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load 从环境变量加载配置
|
|
||||||
func Load() *Config {
|
|
||||||
// 向后兼容:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL
|
|
||||||
iotURL := os.Getenv("IOT_SERVICE_URL")
|
|
||||||
if iotURL == "" {
|
|
||||||
iotURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Config{
|
|
||||||
Port: getEnv("PORT", "8092"),
|
|
||||||
IoTServiceURL: iotURL,
|
|
||||||
DataDir: getEnv("DATA_DIR", "/tmp/cyrene_data"),
|
|
||||||
DBUrl: getEnv("DB_URL", ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/service"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ToolHandler HTTP API 处理器
|
|
||||||
type ToolHandler struct {
|
|
||||||
svc *service.ToolService
|
|
||||||
callLogStore *store.CallLogStore
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewToolHandler 创建工具处理器
|
|
||||||
func NewToolHandler(svc *service.ToolService, callLogStore *store.CallLogStore) *ToolHandler {
|
|
||||||
return &ToolHandler{svc: svc, callLogStore: callLogStore}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes 注册所有路由到 mux
|
|
||||||
func (h *ToolHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
||||||
// GET /api/v1/tools - 列出所有工具
|
|
||||||
mux.HandleFunc("/api/v1/tools", h.handleTools)
|
|
||||||
// GET /api/v1/tools/ - 工具详情和单个执行 (带名称)
|
|
||||||
mux.HandleFunc("/api/v1/tools/", h.handleToolByName)
|
|
||||||
// POST /api/v1/tools/execute - 批量执行
|
|
||||||
mux.HandleFunc("/api/v1/tools/execute", h.handleBatchExecute)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTools GET /api/v1/tools - 列出所有工具
|
|
||||||
func (h *ToolHandler) handleTools(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tools := h.svc.ListTools()
|
|
||||||
if tools == nil {
|
|
||||||
tools = []model.ToolDefinition{}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"tools": tools,
|
|
||||||
"total": len(tools),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleToolByName 处理 /api/v1/tools/{name} 和 /api/v1/tools/{name}/execute 和 /api/v1/tools/calls 和 /api/v1/tools/calls/stats
|
|
||||||
func (h *ToolHandler) handleToolByName(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// 解析路径: /api/v1/tools/{name} 或 /api/v1/tools/{name}/execute
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tools/")
|
|
||||||
parts := strings.SplitN(path, "/", 2)
|
|
||||||
|
|
||||||
toolName := parts[0]
|
|
||||||
if toolName == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "缺少工具名称")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 /api/v1/tools/calls/stats
|
|
||||||
if toolName == "calls" && len(parts) == 2 && parts[1] == "stats" {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.handleCallStats(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 /api/v1/tools/calls
|
|
||||||
if toolName == "calls" {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.handleCallLogs(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断是否为执行请求
|
|
||||||
if len(parts) == 2 && parts[1] == "execute" {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed, use POST")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.executeTool(w, r, toolName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/tools/{name} - 获取工具定义
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
def, ok := h.svc.GetTool(toolName)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusNotFound, "工具 "+toolName+" 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, def)
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeTool POST /api/v1/tools/{name}/execute - 执行单个工具
|
|
||||||
func (h *ToolHandler) executeTool(w http.ResponseWriter, r *http.Request, toolName string) {
|
|
||||||
var req model.ExecuteRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "请求体格式错误: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Arguments == nil {
|
|
||||||
req.Arguments = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
result, err := h.svc.Execute(r.Context(), toolName, req.Arguments)
|
|
||||||
durationMs := int(time.Since(startTime).Milliseconds())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-handler] 执行工具 %s 失败: %v", toolName, err)
|
|
||||||
h.logCall(toolName, req.Arguments, "", err.Error(), false, durationMs, r)
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 异步记录调用日志
|
|
||||||
h.logCall(toolName, req.Arguments, result.Output, result.Error, result.Error == "" && err == nil, durationMs, r)
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBatchExecute POST /api/v1/tools/execute - 批量执行
|
|
||||||
func (h *ToolHandler) handleBatchExecute(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed, use POST")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.BatchExecuteRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "请求体格式错误: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Calls) == 0 {
|
|
||||||
writeError(w, http.StatusBadRequest, "calls 不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
response := h.svc.ExecuteBatch(r.Context(), req.Calls)
|
|
||||||
batchDuration := int(time.Since(startTime).Milliseconds())
|
|
||||||
|
|
||||||
// 异步记录每个调用
|
|
||||||
for i, call := range req.Calls {
|
|
||||||
var output, errStr string
|
|
||||||
var success bool
|
|
||||||
if i < len(response.Results) {
|
|
||||||
output = response.Results[i].Output
|
|
||||||
errStr = response.Results[i].Error
|
|
||||||
success = errStr == ""
|
|
||||||
}
|
|
||||||
h.logCall(call.Name, call.Arguments, output, errStr, success, batchDuration, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newUUID generates a UUID v4 string using crypto/rand
|
|
||||||
func newUUID() string {
|
|
||||||
b := make([]byte, 16)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
|
|
||||||
b[8] = (b[8] & 0x3f) | 0x80 // Variant 10
|
|
||||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
||||||
}
|
|
||||||
|
|
||||||
// logCall 异步记录工具调用日志
|
|
||||||
func (h *ToolHandler) logCall(toolName string, args map[string]interface{}, output, errStr string, success bool, durationMs int, r *http.Request) {
|
|
||||||
if h.callLogStore == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callID := newUUID()
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
|
||||||
sessionID := r.URL.Query().Get("session_id")
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
argsJSON, _ := json.Marshal(args)
|
|
||||||
record := &store.CallLogRecord{
|
|
||||||
CallID: callID,
|
|
||||||
ToolName: toolName,
|
|
||||||
Arguments: argsJSON,
|
|
||||||
Output: output,
|
|
||||||
Error: errStr,
|
|
||||||
Success: success,
|
|
||||||
DurationMs: durationMs,
|
|
||||||
UserID: userID,
|
|
||||||
SessionID: sessionID,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
if err := h.callLogStore.Insert(record); err != nil {
|
|
||||||
logger.Printf("[tool-handler] 记录调用日志失败: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCallLogs GET /api/v1/tools/calls - 查询调用记录
|
|
||||||
func (h *ToolHandler) handleCallLogs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.callLogStore == nil {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"calls": []interface{}{},
|
|
||||||
"total": 0,
|
|
||||||
"page": 1,
|
|
||||||
"limit": 20,
|
|
||||||
"total_pages": 0,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
q := r.URL.Query()
|
|
||||||
|
|
||||||
page, _ := strconv.Atoi(q.Get("page"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
||||||
if limit < 1 || limit > 100 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
query := store.CallLogQuery{
|
|
||||||
ToolName: q.Get("tool_name"),
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.callLogStore.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-handler] 查询调用记录失败: %v", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "查询调用记录失败: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCallStats GET /api/v1/tools/calls/stats - 调用统计
|
|
||||||
func (h *ToolHandler) handleCallStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.callLogStore == nil {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"total_calls": 0,
|
|
||||||
"success_count": 0,
|
|
||||||
"fail_count": 0,
|
|
||||||
"success_rate": 0,
|
|
||||||
"avg_duration_ms": 0,
|
|
||||||
"by_tool": []interface{}{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := h.callLogStore.Stats()
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-handler] 查询调用统计失败: %v", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "查询调用统计失败: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSON 写入 JSON 响应
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
|
||||||
logger.Printf("[tool-handler] JSON 编码失败: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeError 写入错误响应
|
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"error": message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// ToolDefinition 工具定义(用于 LLM function calling)
|
|
||||||
type ToolDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolCall 工具调用请求
|
|
||||||
type ToolCall struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments map[string]interface{} `json:"arguments"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolResult 工具执行结果
|
|
||||||
type ToolResult struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Output string `json:"output"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteRequest 单个工具执行请求
|
|
||||||
type ExecuteRequest struct {
|
|
||||||
Arguments map[string]interface{} `json:"arguments"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchExecuteRequest 批量执行请求
|
|
||||||
type BatchExecuteRequest struct {
|
|
||||||
Calls []ToolCall `json:"calls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchExecuteResponse 批量执行响应
|
|
||||||
type BatchExecuteResponse struct {
|
|
||||||
Results []ToolResult `json:"results"`
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/tools"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ToolService 工具执行引擎
|
|
||||||
type ToolService struct {
|
|
||||||
registry map[string]tools.Tool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewToolService 创建工具服务,注册所有工具
|
|
||||||
func NewToolService(iotClient tools.IoTClientInterface, dataDir string) *ToolService {
|
|
||||||
svc := &ToolService{
|
|
||||||
registry: make(map[string]tools.Tool),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册所有 13 个工具
|
|
||||||
svc.Register(tools.NewCalculatorTool())
|
|
||||||
svc.Register(tools.NewDateTimeTool())
|
|
||||||
svc.Register(tools.NewTextTool())
|
|
||||||
svc.Register(tools.NewCryptoTool())
|
|
||||||
svc.Register(tools.NewRandomTool())
|
|
||||||
svc.Register(tools.NewMarkdownTool())
|
|
||||||
svc.Register(tools.NewJSONTool())
|
|
||||||
svc.Register(tools.NewFileTool(dataDir))
|
|
||||||
svc.Register(tools.NewHTTPTool())
|
|
||||||
svc.Register(tools.NewWebSearchTool())
|
|
||||||
svc.Register(tools.NewWebFetchTool())
|
|
||||||
|
|
||||||
// IoT 工具(需要 IoT 客户端)
|
|
||||||
if iotClient != nil {
|
|
||||||
svc.Register(tools.NewIoTQueryTool(iotClient))
|
|
||||||
svc.Register(tools.NewIoTControlTool(iotClient))
|
|
||||||
} else {
|
|
||||||
logger.Println("[tool-service] IoT 客户端未配置,跳过 IoT 工具注册")
|
|
||||||
}
|
|
||||||
|
|
||||||
return svc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register 注册工具
|
|
||||||
func (s *ToolService) Register(tool tools.Tool) {
|
|
||||||
def := tool.Definition()
|
|
||||||
s.registry[def.Name] = tool
|
|
||||||
logger.Printf("[tool-service] 已注册工具: %s", def.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTools 获取所有工具定义
|
|
||||||
func (s *ToolService) ListTools() []model.ToolDefinition {
|
|
||||||
defs := make([]model.ToolDefinition, 0, len(s.registry))
|
|
||||||
for _, tool := range s.registry {
|
|
||||||
defs = append(defs, tool.Definition())
|
|
||||||
}
|
|
||||||
return defs
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTool 获取单个工具定义
|
|
||||||
func (s *ToolService) GetTool(name string) (*model.ToolDefinition, bool) {
|
|
||||||
tool, ok := s.registry[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
def := tool.Definition()
|
|
||||||
return &def, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute 执行单个工具
|
|
||||||
func (s *ToolService) Execute(ctx context.Context, name string, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
tool, ok := s.registry[name]
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("工具 %s 不存在", name),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("[tool-service] 执行工具: %s", name)
|
|
||||||
result, err := tool.Execute(ctx, arguments)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-service] 工具 %s 执行错误: %v", name, err)
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("执行工具 %s 失败: %v", name, err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Error != "" {
|
|
||||||
logger.Printf("[tool-service] 工具 %s 返回错误: %s", name, result.Error)
|
|
||||||
} else {
|
|
||||||
logger.Printf("[tool-service] 工具 %s 执行成功", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteBatch 批量执行工具调用
|
|
||||||
func (s *ToolService) ExecuteBatch(ctx context.Context, calls []model.ToolCall) *model.BatchExecuteResponse {
|
|
||||||
results := make([]model.ToolResult, 0, len(calls))
|
|
||||||
|
|
||||||
for _, call := range calls {
|
|
||||||
result, err := s.Execute(ctx, call.Name, call.Arguments)
|
|
||||||
if err != nil {
|
|
||||||
results = append(results, model.ToolResult{
|
|
||||||
ID: call.ID,
|
|
||||||
Output: "",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.ID = call.ID
|
|
||||||
results = append(results, *result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.BatchExecuteResponse{
|
|
||||||
Results: results,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CallLogRecord 工具调用记录
|
|
||||||
type CallLogRecord struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
CallID string `json:"call_id"`
|
|
||||||
ToolName string `json:"tool_name"`
|
|
||||||
Arguments json.RawMessage `json:"arguments"`
|
|
||||||
Output string `json:"output"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Success bool `json:"success"`
|
|
||||||
DurationMs int `json:"duration_ms"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallLogQuery 查询参数
|
|
||||||
type CallLogQuery struct {
|
|
||||||
ToolName string
|
|
||||||
Page int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallLogPageResult 分页结果
|
|
||||||
type CallLogPageResult struct {
|
|
||||||
Calls []CallLogRecord `json:"calls"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallLogStats 调用统计
|
|
||||||
type CallLogStats struct {
|
|
||||||
TotalCalls int `json:"total_calls"`
|
|
||||||
SuccessCount int `json:"success_count"`
|
|
||||||
FailCount int `json:"fail_count"`
|
|
||||||
SuccessRate float64 `json:"success_rate"`
|
|
||||||
AvgDuration float64 `json:"avg_duration_ms"`
|
|
||||||
ByTool []ToolCallCount `json:"by_tool"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolCallCount 按工具统计
|
|
||||||
type ToolCallCount struct {
|
|
||||||
ToolName string `json:"tool_name"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
SuccessCount int `json:"success_count"`
|
|
||||||
FailCount int `json:"fail_count"`
|
|
||||||
AvgDuration float64 `json:"avg_duration_ms"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CallLogStore 工具调用日志存储
|
|
||||||
type CallLogStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCallLogStore 创建调用日志存储并自动建表
|
|
||||||
func NewCallLogStore(dbURL string) (*CallLogStore, error) {
|
|
||||||
if dbURL == "" {
|
|
||||||
logger.Println("[call-log-store] DB_URL 未设置,工具调用日志将不会持久化")
|
|
||||||
return &CallLogStore{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := sql.Open("postgres", dbURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("打开数据库连接失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db.SetMaxOpenConns(5)
|
|
||||||
db.SetMaxIdleConns(2)
|
|
||||||
db.SetConnMaxLifetime(5 * time.Minute)
|
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
|
||||||
logger.Printf("[call-log-store] 数据库连接失败: %v (将尝试继续运行)", err)
|
|
||||||
return &CallLogStore{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
store := &CallLogStore{db: db}
|
|
||||||
if err := store.migrate(); err != nil {
|
|
||||||
logger.Printf("[call-log-store] 数据库迁移失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Println("[call-log-store] 数据库连接成功,表已就绪")
|
|
||||||
return store, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrate 创建表结构
|
|
||||||
func (s *CallLogStore) migrate() error {
|
|
||||||
if s.db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
CREATE TABLE IF NOT EXISTS tool_call_logs (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
call_id TEXT NOT NULL,
|
|
||||||
tool_name TEXT NOT NULL,
|
|
||||||
arguments JSONB,
|
|
||||||
output TEXT,
|
|
||||||
error TEXT,
|
|
||||||
success BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
duration_ms INTEGER,
|
|
||||||
user_id TEXT DEFAULT '',
|
|
||||||
session_id TEXT DEFAULT '',
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tcl_tool_name ON tool_call_logs(tool_name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tcl_created_at ON tool_call_logs(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tcl_user_id ON tool_call_logs(user_id);
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := s.db.Exec(query)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert 插入一条调用记录
|
|
||||||
func (s *CallLogStore) Insert(record *CallLogRecord) error {
|
|
||||||
if s.db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
argsJSON, _ := json.Marshal(record.Arguments)
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO tool_call_logs (call_id, tool_name, arguments, output, error, success, duration_ms, user_id, session_id, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
||||||
record.CallID, record.ToolName, argsJSON, record.Output, record.Error,
|
|
||||||
record.Success, record.DurationMs, record.UserID, record.SessionID, record.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[call-log-store] 插入记录失败: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query 分页查询调用记录
|
|
||||||
func (s *CallLogStore) Query(q CallLogQuery) (*CallLogPageResult, error) {
|
|
||||||
if s.db == nil {
|
|
||||||
return &CallLogPageResult{Calls: []CallLogRecord{}, Total: 0, Page: q.Page, Limit: q.Limit, TotalPages: 0}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if q.Page < 1 {
|
|
||||||
q.Page = 1
|
|
||||||
}
|
|
||||||
if q.Limit < 1 || q.Limit > 100 {
|
|
||||||
q.Limit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 WHERE 条件
|
|
||||||
where := ""
|
|
||||||
whereArgs := []interface{}{}
|
|
||||||
argIdx := 1
|
|
||||||
|
|
||||||
if q.ToolName != "" {
|
|
||||||
where = fmt.Sprintf(" WHERE tool_name = $%d", argIdx)
|
|
||||||
whereArgs = append(whereArgs, q.ToolName)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计数
|
|
||||||
countQuery := "SELECT COUNT(*) FROM tool_call_logs" + where
|
|
||||||
var total int
|
|
||||||
if err := s.db.QueryRow(countQuery, whereArgs...).Scan(&total); err != nil {
|
|
||||||
return nil, fmt.Errorf("查询总数失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页查询
|
|
||||||
offset := (q.Page - 1) * q.Limit
|
|
||||||
querySql := fmt.Sprintf(
|
|
||||||
"SELECT id, call_id, tool_name, arguments, COALESCE(output,''), COALESCE(error,''), success, COALESCE(duration_ms,0), COALESCE(user_id,''), COALESCE(session_id,''), created_at FROM tool_call_logs%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
|
|
||||||
where, argIdx, argIdx+1,
|
|
||||||
)
|
|
||||||
queryArgs := append(whereArgs, q.Limit, offset)
|
|
||||||
|
|
||||||
rows, err := s.db.Query(querySql, queryArgs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("查询记录失败: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
calls := make([]CallLogRecord, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var r CallLogRecord
|
|
||||||
var argsJSON []byte
|
|
||||||
if err := rows.Scan(&r.ID, &r.CallID, &r.ToolName, &argsJSON, &r.Output, &r.Error, &r.Success, &r.DurationMs, &r.UserID, &r.SessionID, &r.CreatedAt); err != nil {
|
|
||||||
logger.Printf("[call-log-store] 扫描行失败: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.Arguments = argsJSON
|
|
||||||
calls = append(calls, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := (total + q.Limit - 1) / q.Limit
|
|
||||||
|
|
||||||
return &CallLogPageResult{
|
|
||||||
Calls: calls,
|
|
||||||
Total: total,
|
|
||||||
Page: q.Page,
|
|
||||||
Limit: q.Limit,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats 获取调用统计
|
|
||||||
func (s *CallLogStore) Stats() (*CallLogStats, error) {
|
|
||||||
if s.db == nil {
|
|
||||||
return &CallLogStats{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := &CallLogStats{}
|
|
||||||
|
|
||||||
// 总体统计
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
"SELECT COUNT(*), COUNT(*) FILTER (WHERE success=true), COUNT(*) FILTER (WHERE success=false), COALESCE(AVG(duration_ms),0) FROM tool_call_logs",
|
|
||||||
).Scan(&stats.TotalCalls, &stats.SuccessCount, &stats.FailCount, &stats.AvgDuration)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("查询总体统计失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats.TotalCalls > 0 {
|
|
||||||
stats.SuccessRate = float64(stats.SuccessCount) / float64(stats.TotalCalls) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按工具统计
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
"SELECT tool_name, COUNT(*), COUNT(*) FILTER (WHERE success=true), COUNT(*) FILTER (WHERE success=false), COALESCE(AVG(duration_ms),0) FROM tool_call_logs GROUP BY tool_name ORDER BY COUNT(*) DESC",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("查询按工具统计失败: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
stats.ByTool = make([]ToolCallCount, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var tc ToolCallCount
|
|
||||||
if err := rows.Scan(&tc.ToolName, &tc.Count, &tc.SuccessCount, &tc.FailCount, &tc.AvgDuration); err != nil {
|
|
||||||
logger.Printf("[call-log-store] 扫描工具统计失败: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stats.ByTool = append(stats.ByTool, tc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭数据库连接
|
|
||||||
func (s *CallLogStore) Close() {
|
|
||||||
if s.db != nil {
|
|
||||||
s.db.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBUrlFromEnv 从环境变量获取数据库连接
|
|
||||||
func DBUrlFromEnv() string {
|
|
||||||
// 如果设置了 DB_URL 直接使用
|
|
||||||
if url := os.Getenv("DB_URL"); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// 否则从单独的环境变量构建
|
|
||||||
host := getEnv("DB_HOST", "localhost")
|
|
||||||
port := getEnv("DB_PORT", "5432")
|
|
||||||
user := getEnv("DB_USER", "cyrene")
|
|
||||||
pass := getEnv("DB_PASSWORD", "cyrene_pass")
|
|
||||||
dbname := getEnv("DB_NAME", "cyrene_ai")
|
|
||||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
|
||||||
|
|
||||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, dbname, sslmode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CalculatorTool performs safe mathematical expression evaluation.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
expression, ok := arguments["expression"].(string)
|
|
||||||
if !ok || strings.TrimSpace(expression) == "" {
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: "缺少 expression 参数",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := evaluate(expression)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("计算错误: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("表达式: %s\n结果: %s", expression, formatResult(result)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tokenKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
tokNumber tokenKind = iota
|
|
||||||
tokIdent
|
|
||||||
tokOp
|
|
||||||
tokLParen
|
|
||||||
tokRParen
|
|
||||||
tokComma
|
|
||||||
tokEOF
|
|
||||||
)
|
|
||||||
|
|
||||||
type token struct {
|
|
||||||
kind tokenKind
|
|
||||||
value string
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
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])}
|
|
||||||
}
|
|
||||||
|
|
||||||
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])}
|
|
||||||
}
|
|
||||||
|
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "pi":
|
|
||||||
return math.Pi, nil
|
|
||||||
case "e":
|
|
||||||
return math.E, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.cur.kind != tokLParen {
|
|
||||||
return 0, fmt.Errorf("未知标识符: %s (如果是函数需要加括号)", name)
|
|
||||||
}
|
|
||||||
p.advance()
|
|
||||||
|
|
||||||
arg, err := p.parseExpression()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.cur.kind != tokRParen {
|
|
||||||
return 0, fmt.Errorf("函数 %s 缺少右括号", name)
|
|
||||||
}
|
|
||||||
p.advance()
|
|
||||||
|
|
||||||
return applyFunc(name, arg)
|
|
||||||
|
|
||||||
case tokLParen:
|
|
||||||
p.advance()
|
|
||||||
val, err := p.parseExpression()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if p.cur.kind != tokRParen {
|
|
||||||
return 0, fmt.Errorf("缺少右括号")
|
|
||||||
}
|
|
||||||
p.advance()
|
|
||||||
return val, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("意外的 token: %v", p.cur.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "github.com/yourname/cyrene-ai/pkg/logger":
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/md5"
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/sha512"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"hash"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CryptoTool provides cryptographic and encoding utilities for the LLM.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
input, ok := arguments["input"].(string)
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: hash, base64_encode, base64_decode, url_encode, url_decode", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CryptoTool) handleHash(arguments map[string]interface{}) (*model.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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("不支持的哈希算法: %s,支持: md5, sha1, sha256, sha512", algorithm),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Write([]byte(input))
|
|
||||||
hashBytes := h.Sum(nil)
|
|
||||||
hashHex := fmt.Sprintf("%x", hashBytes)
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("哈希算法: %s\n输入长度: %d 字节\n哈希值 (hex): %s\n哈希长度: %d 位",
|
|
||||||
algorithm, len(input), hashHex, len(hashBytes)*8),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CryptoTool) handleBase64Encode(input string) (*model.ToolResult, error) {
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(input))
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("Base64 编码结果:\n原始 (%d 字节): %s\n编码 (%d 字符): %s",
|
|
||||||
len(input), truncate(input, 100), len(encoded), encoded),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CryptoTool) handleBase64Decode(input string) (*model.ToolResult, error) {
|
|
||||||
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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: "Base64 解码失败: 输入不是有效的 Base64 字符串",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("Base64 解码结果:\n原始 (%d 字符): %s\n解码 (%d 字节): %s",
|
|
||||||
len(input), truncate(input, 100), len(decoded), truncate(string(decoded), 200)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CryptoTool) handleURLEncode(input string) (*model.ToolResult, error) {
|
|
||||||
encoded := url.QueryEscape(input)
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("URL 编码结果:\n原始 (%d 字节): %s\n编码 (%d 字节): %s",
|
|
||||||
len(input), truncate(input, 100), len(encoded), encoded),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *CryptoTool) handleURLDecode(input string) (*model.ToolResult, error) {
|
|
||||||
decoded, err := url.QueryUnescape(input)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("URL 解码失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("URL 解码结果:\n原始 (%d 字节): %s\n解码 (%d 字节): %s",
|
|
||||||
len(input), truncate(input, 100), len(decoded), truncate(decoded, 200)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(s string, maxLen int) string {
|
|
||||||
runes := []rune(s)
|
|
||||||
if len(runes) <= maxLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return string(runes[:maxLen]) + "..."
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DateTimeTool provides date/time operations for the LLM.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: now, format, add, diff, timezone_list", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) handleNow(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
tz, err := t.getTimezone(arguments)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
format := t.getFormat(arguments)
|
|
||||||
now := time.Now().In(tz)
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("当前时间: %s\n时区: %s\nUnix时间戳: %d",
|
|
||||||
now.Format(format), tz.String(), now.Unix()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) handleFormat(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
dateStr, _ := arguments["date"].(string)
|
|
||||||
if dateStr == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "format 操作需要 date 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := t.parseDate(dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("日期解析失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tz, err := t.getTimezone(arguments)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
format := t.getFormat(arguments)
|
|
||||||
formatted := parsed.In(tz).Format(format)
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("原始: %s\n格式化: %s\n时区: %s", dateStr, formatted, tz.String()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) handleAdd(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
durationStr, _ := arguments["duration"].(string)
|
|
||||||
if durationStr == "" {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{ID: "", 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 &model.ToolResult{ID: "", Error: fmt.Sprintf("时长解析失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tz, _ := t.getTimezone(arguments)
|
|
||||||
|
|
||||||
result := base.In(tz)
|
|
||||||
|
|
||||||
months := extractDurationUnit(durationStr, 'M')
|
|
||||||
years := extractDurationUnit(durationStr, 'y')
|
|
||||||
|
|
||||||
if months != 0 || years != 0 {
|
|
||||||
result = result.AddDate(years, months, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dur != 0 {
|
|
||||||
result = result.Add(dur)
|
|
||||||
}
|
|
||||||
|
|
||||||
format := t.getFormat(arguments)
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("基准日期: %s\n操作: %s\n结果: %s",
|
|
||||||
base.In(tz).Format(format), durationStr, result.Format(format)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) handleDiff(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
dateStr, _ := arguments["date"].(string)
|
|
||||||
date2Str, _ := arguments["date2"].(string)
|
|
||||||
|
|
||||||
if dateStr == "" || date2Str == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "diff 操作需要 date 和 date2 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
d1, err := t.parseDate(dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("date 解析失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
d2, err := t.parseDate(date2Str)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) handleTimezoneList() (*model.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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: result.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) getFormat(arguments map[string]interface{}) string {
|
|
||||||
format, _ := arguments["format"].(string)
|
|
||||||
if format == "" {
|
|
||||||
return "2006-01-02 15:04:05"
|
|
||||||
}
|
|
||||||
return format
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *DateTimeTool) parseDuration(s string) (time.Duration, error) {
|
|
||||||
if d, err := time.ParseDuration(s); err == nil {
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var total time.Duration
|
|
||||||
remaining := s
|
|
||||||
|
|
||||||
for len(remaining) > 0 {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining = remaining[unitEnd:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractDurationUnit(s string, unit byte) int {
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == unit {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileTool provides sandboxed file system operations for the LLM.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath, ok := arguments["path"].(string)
|
|
||||||
if !ok || relPath == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 path 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
safePath, err := t.resolveSafePath(relPath)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: read, write, list, exists, delete", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *FileTool) resolveSafePath(relPath string) (string, error) {
|
|
||||||
clean := filepath.Clean(relPath)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(t.dataDir, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("创建数据目录失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
abs := filepath.Join(t.dataDir, clean)
|
|
||||||
|
|
||||||
realPath, err := filepath.EvalSymlinks(abs)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *FileTool) handleRead(absPath, relPath string) (*model.ToolResult, error) {
|
|
||||||
const maxSize = 100 * 1024
|
|
||||||
|
|
||||||
info, err := os.Stat(absPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("文件不存在: %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取文件失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("路径是目录,不能用 read 操作: %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Size() > maxSize {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("文件过大 (%d bytes),超过限制 (%d bytes)", info.Size(), maxSize)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取文件失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("文件: %s\n大小: %d bytes\n---\n%s", relPath, len(data), string(data)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *FileTool) handleWrite(absPath, relPath, content string) (*model.ToolResult, error) {
|
|
||||||
dir := filepath.Dir(absPath)
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("创建目录失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("写入文件失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("已写入文件: %s (%d bytes)", relPath, len(content)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *FileTool) handleList(absPath, relPath string) (*model.ToolResult, error) {
|
|
||||||
entries, err := os.ReadDir(absPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("目录不存在: %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取目录失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Output: 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 &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *FileTool) handleExists(absPath, relPath string) (*model.ToolResult, error) {
|
|
||||||
info, err := os.Stat(absPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return &model.ToolResult{ID: "", Output: fmt.Sprintf("路径不存在: %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("检查路径失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kind := "文件"
|
|
||||||
if info.IsDir() {
|
|
||||||
kind = "目录"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("路径存在: %s (%s, %d bytes)", relPath, kind, info.Size()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *FileTool) handleDelete(absPath, relPath string) (*model.ToolResult, error) {
|
|
||||||
info, err := os.Stat(absPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("文件不存在: %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("删除文件失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("不能删除目录(安全限制): %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Remove(absPath); err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("删除文件失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{ID: "", Output: fmt.Sprintf("已删除文件: %s", relPath)}, nil
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HTTPTool sends arbitrary HTTP requests, more flexible than web_fetch.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
url, ok := arguments["url"].(string)
|
|
||||||
if !ok || url == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 url 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
|
||||||
return &model.ToolResult{ID: "", Error: "仅支持 http:// 或 https:// 链接"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
method, _ := arguments["method"].(string)
|
|
||||||
if method == "" {
|
|
||||||
method = "GET"
|
|
||||||
}
|
|
||||||
method = strings.ToUpper(method)
|
|
||||||
|
|
||||||
validMethods := map[string]bool{
|
|
||||||
"GET": true, "POST": true, "PUT": true, "DELETE": true,
|
|
||||||
"PATCH": true, "HEAD": true, "OPTIONS": true,
|
|
||||||
}
|
|
||||||
if !validMethods[method] {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("不支持的HTTP方法: %s", method)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutSec := 10.0
|
|
||||||
if timeoutVal, ok := arguments["timeout"].(float64); ok && timeoutVal > 0 {
|
|
||||||
timeoutSec = timeoutVal
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: time.Duration(timeoutSec * float64(time.Second)),
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{ID: "", Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
|
|
||||||
|
|
||||||
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 &model.ToolResult{ID: "", Error: fmt.Sprintf("请求失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
const maxBodySize = 50 * 1024
|
|
||||||
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBodySize)))
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取响应失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: result,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IoTClient IoT 调试服务 HTTP 客户端
|
|
||||||
type IoTClient struct {
|
|
||||||
baseURL string
|
|
||||||
client *http.Client
|
|
||||||
|
|
||||||
// 缓存控制
|
|
||||||
mu sync.RWMutex
|
|
||||||
cache []IoTDevice
|
|
||||||
cacheTime time.Time
|
|
||||||
cacheTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIoTClient 创建 IoT 客户端
|
|
||||||
func NewIoTClient(baseURL string) *IoTClient {
|
|
||||||
return &IoTClient{
|
|
||||||
baseURL: baseURL,
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
},
|
|
||||||
cacheTTL: 60 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllDevices 获取所有设备列表(带缓存)
|
|
||||||
func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
|
|
||||||
// 检查缓存
|
|
||||||
c.mu.RLock()
|
|
||||||
if c.cache != nil && time.Since(c.cacheTime) < c.cacheTTL {
|
|
||||||
devices := make([]IoTDevice, len(c.cache))
|
|
||||||
copy(devices, c.cache)
|
|
||||||
c.mu.RUnlock()
|
|
||||||
return devices, nil
|
|
||||||
}
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
// 请求 API
|
|
||||||
resp, err := c.client.Get(c.baseURL + "/api/v1/devices")
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[IoT客户端] 请求失败: %v", err)
|
|
||||||
return nil, fmt.Errorf("获取设备列表失败: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("获取设备列表返回状态码 %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Devices []IoTDevice `json:"devices"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, fmt.Errorf("解析设备列表失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
c.mu.Lock()
|
|
||||||
c.cache = result.Devices
|
|
||||||
c.cacheTime = time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
return result.Devices, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDevice 获取单个设备详情
|
|
||||||
func (c *IoTClient) GetDevice(id string) (*IoTDevice, error) {
|
|
||||||
resp, err := c.client.Get(c.baseURL + "/api/v1/devices/" + id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取设备 %s 失败: %w", id, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return nil, fmt.Errorf("设备 %s 不存在", id)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("获取设备 %s 返回状态码 %d", id, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Device IoTDevice `json:"device"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, fmt.Errorf("解析设备信息失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result.Device, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToggleDevice 切换设备开关状态
|
|
||||||
func (c *IoTClient) ToggleDevice(id string) error {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
|
|
||||||
return fmt.Errorf("创建切换请求失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
|
|
||||||
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
|
|
||||||
return fmt.Errorf("设备 %s 不存在", id)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
|
|
||||||
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换后清除缓存,确保下次查询获取最新状态
|
|
||||||
c.mu.Lock()
|
|
||||||
c.cache = nil
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ✅ 切换设备成功: %s", id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
|
|
||||||
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
|
|
||||||
|
|
||||||
body, err := json.Marshal(map[string]interface{}{
|
|
||||||
"field": field,
|
|
||||||
"value": value,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
|
|
||||||
return fmt.Errorf("序列化请求失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
|
|
||||||
return fmt.Errorf("创建设置请求失败: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
|
|
||||||
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
|
|
||||||
return fmt.Errorf("设备 %s 不存在", id)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
var errResp struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
|
||||||
if errResp.Error != "" {
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
|
|
||||||
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
|
|
||||||
}
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
|
|
||||||
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改后清除缓存
|
|
||||||
c.mu.Lock()
|
|
||||||
c.cache = nil
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
logger.Printf("[tool-engine:IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDevicesForContext 获取设备状态摘要(供上下文注入使用,失败不报错)
|
|
||||||
func (c *IoTClient) GetDevicesForContext() []IoTDevice {
|
|
||||||
devices, err := c.GetAllDevices()
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return devices
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateCache 使缓存失效
|
|
||||||
func (c *IoTClient) InvalidateCache() {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.cache = nil
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IoTControlTool IoT 设备控制工具
|
|
||||||
type IoTControlTool struct {
|
|
||||||
iotClient IoTClientInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIoTControlTool 创建 IoT 控制工具
|
|
||||||
func NewIoTControlTool(iotClient IoTClientInterface) *IoTControlTool {
|
|
||||||
return &IoTControlTool{iotClient: iotClient}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definition 返回工具定义
|
|
||||||
func (t *IoTControlTool) Definition() model.ToolDefinition {
|
|
||||||
return model.ToolDefinition{
|
|
||||||
Name: "iot_control",
|
|
||||||
Description: "【仅当开拓者明确要求控制设备时才使用此工具】控制家中智能设备。可以开关灯光、空调、窗帘、门锁等设备,也可以调节温度、亮度、位置、模式、颜色等属性。" +
|
|
||||||
"\n⚠️ 重要约束:" +
|
|
||||||
"\n - 不要在开拓者只是询问设备状态时调用此工具(查询设备请用 iot_query)" +
|
|
||||||
"\n - 不要自行决定执行操作,必须等开拓者明确说出「打开」「关闭」「调到」「设置」等控制指令" +
|
|
||||||
"\n - 不要因为之前对话中提到过某个设备就主动控制它" +
|
|
||||||
"\n支持的操作:toggle(切换开关状态)、turn_on(打开设备)、turn_off(关闭设备)、" +
|
|
||||||
"set_temperature(设置空调温度,需要 value 参数,单位°C)、" +
|
|
||||||
"set_brightness(设置灯光亮度,需要 value 参数,0-100)、" +
|
|
||||||
"set_position(设置窗帘位置,需要 value 参数,0-100,0=关闭 100=全开)、" +
|
|
||||||
"set_mode(设置空调模式,需要 value 参数,可选值: cool/heat/auto)、" +
|
|
||||||
"set_color(设置灯光颜色,需要 value 参数,可选值: warm_white/cool_white/colorful)",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"device_id": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "要控制的设备ID。可选值: light-livingroom, light-bedroom, ac-livingroom, ac-bedroom, curtain-livingroom, lock-door",
|
|
||||||
},
|
|
||||||
"action": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"},
|
|
||||||
"description": "要执行的操作。toggle:切换开关状态;turn_on:打开设备;turn_off:关闭设备;set_temperature:设置空调温度(需配合value参数);set_brightness:设置灯光亮度(需配合value参数);set_position:设置窗帘位置(需配合value参数);set_mode:设置空调模式(需配合value参数);set_color:设置灯光颜色(需配合value参数)",
|
|
||||||
},
|
|
||||||
"value": map[string]interface{}{
|
|
||||||
"type": "number",
|
|
||||||
"description": "操作的值。set_temperature 时表示目标温度(°C),set_brightness 时表示亮度百分比(0-100),set_position 时表示窗帘开合程度(0-100)。action 为 set_temperature/set_brightness/set_position 时必须提供。set_mode 时为字符串(cool/heat/auto),set_color 时为字符串(warm_white/cool_white/colorful)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"device_id", "action"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{}) (*model.ToolResult, error) {
|
|
||||||
if t.iotClient == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "IoT 客户端未初始化",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 参数别名:entity_id → device_id
|
|
||||||
deviceID, _ := arguments["device_id"].(string)
|
|
||||||
if deviceID == "" {
|
|
||||||
deviceID, _ = arguments["entity_id"].(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
action := normalizeAction(arguments)
|
|
||||||
|
|
||||||
if deviceID == "" {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少设备ID(请使用 device_id 参数)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先获取设备名用于友好的返回消息(失败不影响后续流程)
|
|
||||||
deviceName := deviceID
|
|
||||||
if dev, err := t.iotClient.GetDevice(deviceID); err == nil {
|
|
||||||
deviceName = dev.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理属性设置类操作
|
|
||||||
switch action {
|
|
||||||
case "set_temperature":
|
|
||||||
return t.handleSetTemperature(deviceID, arguments)
|
|
||||||
case "set_brightness":
|
|
||||||
return t.handleSetBrightness(deviceID, arguments)
|
|
||||||
case "set_position":
|
|
||||||
return t.handleSetPosition(deviceID, arguments)
|
|
||||||
case "set_mode":
|
|
||||||
return t.handleSetMode(deviceID, arguments)
|
|
||||||
case "set_color":
|
|
||||||
return t.handleSetColor(deviceID, arguments)
|
|
||||||
case "turn_off":
|
|
||||||
// 声明式关闭:使用 SetDeviceProperty status/off 而非 toggle
|
|
||||||
// 即使设备已经关闭,SetProperty 也会幂等处理
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "status", "off"); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("关闭设备失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已关闭设备: %s", deviceName),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
case "turn_on":
|
|
||||||
// 声明式打开:使用 SetDeviceProperty status/on 而非 toggle
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "status", "on"); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("打开设备失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已打开设备: %s", deviceName),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
default: // "toggle"
|
|
||||||
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("操作设备失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取切换后的状态
|
|
||||||
updatedDevice, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已成功切换设备 %s 的状态。", deviceName),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已成功操作设备: %s\n当前状态: %s", updatedDevice.Name, formatDeviceLine(*updatedDevice)),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractValue 从 arguments 中提取 value 参数(支持 value/Value 及数字/字符串类型)
|
|
||||||
func extractValue(arguments map[string]interface{}) interface{} {
|
|
||||||
if v, ok := arguments["value"]; ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSetTemperature 处理设置温度
|
|
||||||
func (t *IoTControlTool) handleSetTemperature(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
val := extractValue(arguments)
|
|
||||||
if val == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 value 参数,请指定目标温度(如 24)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先获取当前设备信息
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("获取设备状态失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
temperature, ok := toFloat64(val)
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("温度值无效: %v", val),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "temperature", temperature); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("设置温度失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已将 %s 温度从 %.1f°C 调整为 %.1f°C", currentDevice.Name, currentDevice.Temperature, temperature),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSetBrightness 处理设置亮度
|
|
||||||
func (t *IoTControlTool) handleSetBrightness(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
val := extractValue(arguments)
|
|
||||||
if val == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 value 参数,请指定亮度值(0-100)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先获取当前设备信息
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("获取设备状态失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
brightness, ok := toFloat64(val)
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("亮度值无效: %v", val),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "brightness", brightness); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("设置亮度失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已将 %s 亮度调整为 %d%%", currentDevice.Name, int(brightness)),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSetPosition 处理设置窗帘位置
|
|
||||||
func (t *IoTControlTool) handleSetPosition(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
val := extractValue(arguments)
|
|
||||||
if val == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 value 参数,请指定位置值(0=关闭, 100=全开)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("获取设备状态失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
position, ok := toFloat64(val)
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("位置值无效: %v", val),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "position", position); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("设置窗帘位置失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已将 %s 窗帘调整为 %d%%", currentDevice.Name, int(position)),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSetMode 处理设置空调模式
|
|
||||||
func (t *IoTControlTool) handleSetMode(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
val := extractValue(arguments)
|
|
||||||
if val == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 value 参数,请指定模式(cool/heat/auto)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
mode, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("模式值无效: %v", val),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("获取设备状态失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "mode", mode); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("设置模式失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已将 %s 模式切换为 %s", currentDevice.Name, mode),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSetColor 处理设置灯光颜色
|
|
||||||
func (t *IoTControlTool) handleSetColor(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
val := extractValue(arguments)
|
|
||||||
if val == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 value 参数,请指定颜色(warm_white/cool_white/colorful)",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
color, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("颜色值无效: %v", val),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("获取设备状态失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.iotClient.SetDeviceProperty(deviceID, "color", color); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("设置颜色失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: fmt.Sprintf("已将 %s 灯光颜色切换为 %s", currentDevice.Name, color),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// toFloat64 将 interface{} 转换为 float64
|
|
||||||
func toFloat64(v interface{}) (float64, bool) {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return val, true
|
|
||||||
case float32:
|
|
||||||
return float64(val), true
|
|
||||||
case int:
|
|
||||||
return float64(val), true
|
|
||||||
case int64:
|
|
||||||
return float64(val), true
|
|
||||||
case json.Number:
|
|
||||||
f, err := val.Float64()
|
|
||||||
return f, err == nil
|
|
||||||
default:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IoTQueryTool IoT 设备查询工具
|
|
||||||
type IoTQueryTool struct {
|
|
||||||
iotClient IoTClientInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIoTQueryTool 创建 IoT 查询工具
|
|
||||||
func NewIoTQueryTool(iotClient IoTClientInterface) *IoTQueryTool {
|
|
||||||
return &IoTQueryTool{iotClient: iotClient}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definition 返回工具定义
|
|
||||||
func (t *IoTQueryTool) Definition() model.ToolDefinition {
|
|
||||||
return model.ToolDefinition{
|
|
||||||
Name: "iot_query",
|
|
||||||
Description: "查询家中智能设备状态。注意:当前设备状态通常已自动注入到系统提示词中,你通常不需要调用此工具即可回答设备状态问题。只有在设备状态信息陈旧或明显不完整时才调用此工具刷新。",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"device_id": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "要查询的设备ID(可选,不填则返回所有设备)。可选值: light-livingroom, light-bedroom, ac-livingroom, ac-bedroom, curtain-livingroom, sensor-temperature, sensor-humidity, lock-door",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute 执行查询
|
|
||||||
func (t *IoTQueryTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
if t.iotClient == nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "IoT 客户端未初始化",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceID, _ := arguments["device_id"].(string)
|
|
||||||
|
|
||||||
if deviceID != "" {
|
|
||||||
// 查询单个设备
|
|
||||||
device, err := t.iotClient.GetDevice(deviceID)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: err.Error(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: formatSingleDevice(device),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询所有设备
|
|
||||||
devices, err := t.iotClient.GetAllDevices()
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: err.Error(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
result.WriteString(fmt.Sprintf("当前共有 %d 台智能设备:\n\n", len(devices)))
|
|
||||||
for _, d := range devices {
|
|
||||||
result.WriteString(formatDeviceLine(d) + "\n")
|
|
||||||
}
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: result.String(),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatSingleDevice(d *IoTDevice) string {
|
|
||||||
return fmt.Sprintf("设备: %s (%s)\n状态: %s", d.Name, d.Type, formatDeviceLine(*d))
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDeviceLine(d IoTDevice) string {
|
|
||||||
switch d.Type {
|
|
||||||
case "light":
|
|
||||||
if d.Status == "on" {
|
|
||||||
return fmt.Sprintf("💡 %s: 开启 (亮度%d%%, %s)", d.Name, d.Brightness, d.Color)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("💡 %s: 关闭", d.Name)
|
|
||||||
case "ac":
|
|
||||||
if d.Status == "on" {
|
|
||||||
mode := d.Mode
|
|
||||||
switch mode {
|
|
||||||
case "cool":
|
|
||||||
mode = "制冷"
|
|
||||||
case "heat":
|
|
||||||
mode = "制热"
|
|
||||||
case "auto":
|
|
||||||
mode = "自动"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("❄️ %s: 运行中 (%s %.0f°C)", d.Name, mode, d.Temperature)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("❄️ %s: 关闭", d.Name)
|
|
||||||
case "curtain":
|
|
||||||
if d.Status == "open" {
|
|
||||||
return fmt.Sprintf("🪟 %s: 已打开", d.Name)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("🪟 %s: 已关闭", d.Name)
|
|
||||||
case "sensor":
|
|
||||||
unit := d.Unit
|
|
||||||
if unit == "celsius" {
|
|
||||||
unit = "°C"
|
|
||||||
} else if unit == "percent" {
|
|
||||||
unit = "%"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("🌡️ %s: %.1f%s", d.Name, d.Value, unit)
|
|
||||||
case "lock":
|
|
||||||
status := "已锁定"
|
|
||||||
if d.Status == "unlocked" {
|
|
||||||
status = "已解锁"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("🔒 %s: %s (电量%d%%)", d.Name, status, d.Battery)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%s: %s", d.Name, d.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonStr, ok := arguments["json_string"].(string)
|
|
||||||
if !ok || jsonStr == "" {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: parse, query, validate", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *JSONTool) handleParse(jsonStr string) (*model.ToolResult, error) {
|
|
||||||
var data interface{}
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("JSON解析失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pretty, err := json.MarshalIndent(data, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("JSON格式化失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("解析成功\n格式化输出:\n%s", string(pretty)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *JSONTool) handleQuery(jsonStr, path string) (*model.ToolResult, error) {
|
|
||||||
if path == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "query 操作需要 path 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var data interface{}
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("JSON解析失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := queryPath(data, path)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pretty, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("路径: %s\n值: %v", path, value),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("路径: %s\n值:\n%s", path, string(pretty)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *JSONTool) handleValidate(jsonStr string) (*model.ToolResult, error) {
|
|
||||||
var data interface{}
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
||||||
errStr := err.Error()
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("❌ JSON不合法\n错误: %s", errStr),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("✅ JSON合法\n类型: %s\n大小: %d bytes", typeName, size),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryPath(data interface{}, path string) (interface{}, error) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MarkdownTool provides Markdown processing utilities for the LLM.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
md, ok := arguments["markdown"].(string)
|
|
||||||
if !ok || strings.TrimSpace(md) == "" {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: to_html, to_text, extract_links, extract_code, table_of_contents", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *MarkdownTool) handleToHTML(md string) (*model.ToolResult, error) {
|
|
||||||
html := md
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
reImage := regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
|
|
||||||
html = reImage.ReplaceAllString(html, `<img src="$2" alt="$1">`)
|
|
||||||
|
|
||||||
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
|
||||||
html = reLink.ReplaceAllString(html, `<a href="$2">$1</a>`)
|
|
||||||
|
|
||||||
reBold := regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
|
||||||
html = reBold.ReplaceAllString(html, `<strong>$1</strong>`)
|
|
||||||
reBold2 := regexp.MustCompile(`__([^_]+)__`)
|
|
||||||
html = reBold2.ReplaceAllString(html, `<strong>$1</strong>`)
|
|
||||||
|
|
||||||
reItalic := regexp.MustCompile(`\*([^*]+)\*`)
|
|
||||||
html = reItalic.ReplaceAllString(html, `<em>$1</em>`)
|
|
||||||
reItalic2 := regexp.MustCompile(`_([^_]+)_`)
|
|
||||||
html = reItalic2.ReplaceAllString(html, `<em>$1</em>`)
|
|
||||||
|
|
||||||
reStrike := regexp.MustCompile(`~~([^~]+)~~`)
|
|
||||||
html = reStrike.ReplaceAllString(html, `<del>$1</del>`)
|
|
||||||
|
|
||||||
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>`)
|
|
||||||
|
|
||||||
reHR := regexp.MustCompile(`(?m)^(---|\*\*\*|___)\s*$`)
|
|
||||||
html = reHR.ReplaceAllString(html, `<hr>`)
|
|
||||||
|
|
||||||
html = t.processLists(html, `(?m)^[\-*]\s+`, "ul")
|
|
||||||
html = t.processLists(html, `(?m)^\d+\.\s+`, "ol")
|
|
||||||
|
|
||||||
reBlockquote := regexp.MustCompile(`(?m)^>\s?(.+)$`)
|
|
||||||
html = reBlockquote.ReplaceAllString(html, `<blockquote>$1</blockquote>`)
|
|
||||||
|
|
||||||
html = t.wrapParagraphs(html)
|
|
||||||
|
|
||||||
for i, cb := range codeBlocks {
|
|
||||||
content := strings.TrimPrefix(cb, "```")
|
|
||||||
content = strings.TrimSuffix(content, "```")
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{ID: "", Output: html}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *MarkdownTool) handleToText(md string) (*model.ToolResult, error) {
|
|
||||||
text := md
|
|
||||||
|
|
||||||
reFence := regexp.MustCompile("(?s)```[^`]*```")
|
|
||||||
text = reFence.ReplaceAllString(text, "[代码块]")
|
|
||||||
|
|
||||||
reInlineCode := regexp.MustCompile("`[^`]+`")
|
|
||||||
text = reInlineCode.ReplaceAllString(text, "[代码]")
|
|
||||||
|
|
||||||
reImage := regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`)
|
|
||||||
text = reImage.ReplaceAllString(text, "$1")
|
|
||||||
|
|
||||||
reLink := regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
|
|
||||||
text = reLink.ReplaceAllString(text, "$1")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
|
|
||||||
text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1")
|
|
||||||
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
|
|
||||||
text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(text, "")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`(?m)^(---|\*\*\*|___)\s*$`).ReplaceAllString(text, "")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`(?m)^[\-*]\s+`).ReplaceAllString(text, "")
|
|
||||||
text = regexp.MustCompile(`(?m)^\d+\.\s+`).ReplaceAllString(text, "")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(text, "")
|
|
||||||
|
|
||||||
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("纯文本提取结果 (%d 字符):\n\n%s",
|
|
||||||
len([]rune(text)), strings.TrimSpace(text)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *MarkdownTool) handleExtractLinks(md string) (*model.ToolResult, error) {
|
|
||||||
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
|
||||||
matches := reLink.FindAllStringSubmatch(md, -1)
|
|
||||||
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Output: "未找到任何链接"}, 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 &model.ToolResult{ID: "", Output: strings.TrimSpace(result.String())}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *MarkdownTool) handleExtractCode(md string) (*model.ToolResult, error) {
|
|
||||||
reFence := regexp.MustCompile("(?s)```([^`]*)```")
|
|
||||||
matches := reFence.FindAllStringSubmatch(md, -1)
|
|
||||||
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Output: "未找到任何代码块"}, 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 &model.ToolResult{ID: "", Output: strings.TrimSpace(result.String())}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *MarkdownTool) handleTableOfContents(md string) (*model.ToolResult, error) {
|
|
||||||
reHeading := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
|
|
||||||
matches := reHeading.FindAllStringSubmatch(md, -1)
|
|
||||||
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Output: "未找到任何标题,无法生成目录"}, 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 &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeHTML(s string) string {
|
|
||||||
replacer := strings.NewReplacer(
|
|
||||||
"&", "&"+"amp;",
|
|
||||||
"<", "&"+"lt;",
|
|
||||||
">", "&"+"gt;",
|
|
||||||
"\"", "&"+"quot;",
|
|
||||||
)
|
|
||||||
return replacer.Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateText(s string, maxLen int) string {
|
|
||||||
runes := []rune(s)
|
|
||||||
if len(runes) <= maxLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return string(runes[:maxLen]) + "..."
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
mathrand "math/rand"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RandomTool provides random generation utilities for the LLM.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: number, uuid, password, pick, shuffle", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RandomTool) handleNumber(arguments map[string]interface{}) (*model.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)
|
|
||||||
|
|
||||||
rangeVal := maxI - minI + 1
|
|
||||||
if rangeVal <= 0 {
|
|
||||||
return &model.ToolResult{ID: "", Error: "无效的数值范围"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(rangeVal))
|
|
||||||
if err != nil {
|
|
||||||
result := minI + mathrand.Int63n(rangeVal)
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("随机整数 [%d, %d]: %d", minI, maxI, result),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := minI + n.Int64()
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("随机整数 [%d, %d]: %d", minI, maxI, result),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RandomTool) handleUUID() (*model.ToolResult, error) {
|
|
||||||
uuid := make([]byte, 16)
|
|
||||||
_, err := rand.Read(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("生成UUID失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
|
||||||
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
|
||||||
|
|
||||||
uuidStr := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
|
||||||
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("UUID v4: %s", uuidStr),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RandomTool) handlePassword(arguments map[string]interface{}) (*model.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)
|
|
||||||
|
|
||||||
password[0] = uppercase[secureIndex(len(uppercase))]
|
|
||||||
password[1] = lowercase[secureIndex(len(lowercase))]
|
|
||||||
password[2] = digits[secureIndex(len(digits))]
|
|
||||||
password[3] = symbols[secureIndex(len(symbols))]
|
|
||||||
|
|
||||||
for i := 4; i < length; i++ {
|
|
||||||
password[i] = allChars[secureIndex(len(allChars))]
|
|
||||||
}
|
|
||||||
|
|
||||||
shuffleBytes(password)
|
|
||||||
|
|
||||||
passwordStr := string(password)
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("安全密码 (长度: %d):\n%s\n\n字符集: 大写字母 + 小写字母 + 数字 + 特殊符号",
|
|
||||||
length, passwordStr),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RandomTool) handlePick(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
items := getStringSliceArg(arguments, "items")
|
|
||||||
if len(items) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 items 参数或列表为空"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
count := getIntArg(arguments, "count", 1)
|
|
||||||
if count < 1 {
|
|
||||||
count = 1
|
|
||||||
}
|
|
||||||
if count > len(items) {
|
|
||||||
count = len(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *RandomTool) handleShuffle(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
items := getStringSliceArg(arguments, "items")
|
|
||||||
if len(items) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 items 参数或列表为空"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helper functions ---
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
func shuffleBytes(data []byte) {
|
|
||||||
for i := len(data) - 1; i > 0; i-- {
|
|
||||||
j := secureIndex(i + 1)
|
|
||||||
data[i], data[j] = data[j], data[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shuffleInts(data []int) {
|
|
||||||
for i := len(data) - 1; i > 0; i-- {
|
|
||||||
j := secureIndex(i + 1)
|
|
||||||
data[i], data[j] = data[j], data[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shuffleStrings(data []string) {
|
|
||||||
for i := len(data) - 1; i > 0; i-- {
|
|
||||||
j := secureIndex(i + 1)
|
|
||||||
data[i], data[j] = data[j], data[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tool 工具接口
|
|
||||||
type Tool interface {
|
|
||||||
Definition() model.ToolDefinition
|
|
||||||
Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IoTClientFactory 用于创建 IoT 客户端的工厂函数类型
|
|
||||||
type IoTClientFactory func() IoTClientInterface
|
|
||||||
|
|
||||||
// IoTClientInterface IoT 客户端接口(解耦对 ai-core 的依赖)
|
|
||||||
type IoTClientInterface interface {
|
|
||||||
GetAllDevices() ([]IoTDevice, error)
|
|
||||||
GetDevice(id string) (*IoTDevice, error)
|
|
||||||
ToggleDevice(id string) error
|
|
||||||
SetDeviceProperty(id string, field string, value interface{}) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// IoTDevice IoT 设备结构体
|
|
||||||
type IoTDevice struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Brightness int `json:"brightness,omitempty"`
|
|
||||||
Color string `json:"color,omitempty"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
|
||||||
Mode string `json:"mode,omitempty"`
|
|
||||||
Position int `json:"position,omitempty"`
|
|
||||||
Value float64 `json:"value,omitempty"`
|
|
||||||
Unit string `json:"unit,omitempty"`
|
|
||||||
Battery int `json:"battery,omitempty"`
|
|
||||||
LastUpdated string `json:"last_updated"`
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TextTool provides text processing operations for the LLM.
|
|
||||||
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() model.ToolDefinition {
|
|
||||||
return model.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{}) (*model.ToolResult, error) {
|
|
||||||
action, ok := arguments["action"].(string)
|
|
||||||
if !ok || action == "" {
|
|
||||||
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
text, ok := arguments["text"].(string)
|
|
||||||
if !ok || strings.TrimSpace(text) == "" {
|
|
||||||
return &model.ToolResult{ID: "", 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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Error: fmt.Sprintf("未知操作: %s,支持: count, summarize, translate, extract", action),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TextTool) handleCount(text string) (*model.ToolResult, error) {
|
|
||||||
charCount := len([]rune(text))
|
|
||||||
byteCount := len(text)
|
|
||||||
|
|
||||||
words := strings.Fields(text)
|
|
||||||
wordCount := len(words)
|
|
||||||
|
|
||||||
lines := strings.Split(text, "\n")
|
|
||||||
lineCount := len(lines)
|
|
||||||
|
|
||||||
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(text, -1)
|
|
||||||
paraCount := 0
|
|
||||||
for _, p := range paragraphs {
|
|
||||||
if strings.TrimSpace(p) != "" {
|
|
||||||
paraCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chineseCount := 0
|
|
||||||
for _, r := range text {
|
|
||||||
if unicode.Is(unicode.Han, r) {
|
|
||||||
chineseCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TextTool) handleSummarize(text string) (*model.ToolResult, error) {
|
|
||||||
var result strings.Builder
|
|
||||||
result.WriteString("文本摘要:\n\n")
|
|
||||||
|
|
||||||
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")
|
|
||||||
runes := []rune(firstPara)
|
|
||||||
if len(runes) > 300 {
|
|
||||||
firstPara = string(runes[:300]) + "..."
|
|
||||||
}
|
|
||||||
result.WriteString(firstPara)
|
|
||||||
result.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TextTool) splitSentences(text string) []string {
|
|
||||||
re := regexp.MustCompile(`[^。!?.!?\n]+[。!?.!?\n]?`)
|
|
||||||
return re.FindAllString(text, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
lower := strings.ToLower(trimmed)
|
|
||||||
for _, kw := range keywords {
|
|
||||||
if strings.Contains(lower, kw) {
|
|
||||||
score += 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scoredList = append(scoredList, scored{text: trimmed, score: score})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TextTool) handleTranslate(arguments map[string]interface{}) (*model.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 &model.ToolResult{
|
|
||||||
ID: "",
|
|
||||||
Output: fmt.Sprintf("【翻译请求】\n目标语言: %s (%s)\n原文 (%d 字符):\n---\n%s\n---\n\n提示: 实际翻译由LLM完成,请基于以上原文和目标语言进行翻译。",
|
|
||||||
langName, targetLang, len([]rune(text)), text),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TextTool) handleExtract(arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
text, _ := arguments["text"].(string)
|
|
||||||
pattern, _ := arguments["pattern"].(string)
|
|
||||||
|
|
||||||
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 == "" {
|
|
||||||
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 &model.ToolResult{ID: "", Output: "未提取到匹配的内容(邮箱、电话、URL)"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
re, err := regexp.Compile(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{ID: "", Error: fmt.Sprintf("正则表达式无效: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
matches := re.FindAllString(text, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return &model.ToolResult{ID: "", Output: 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 &model.ToolResult{ID: "", Output: result.String()}, nil
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebFetchTool 网络访问工具 - 允许昔涟获取网页内容
|
|
||||||
type WebFetchTool struct {
|
|
||||||
client *http.Client
|
|
||||||
timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWebFetchTool 创建网络访问工具
|
|
||||||
func NewWebFetchTool() *WebFetchTool {
|
|
||||||
return &WebFetchTool{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
},
|
|
||||||
timeout: 15 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definition 返回工具定义
|
|
||||||
func (t *WebFetchTool) Definition() model.ToolDefinition {
|
|
||||||
return model.ToolDefinition{
|
|
||||||
Name: "web_fetch",
|
|
||||||
Description: "获取指定URL的网页内容。用于查阅新闻、文档、资料等。返回纯文本摘要(前2000字符)。仅支持 HTTP/HTTPS URL。",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"url": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "要获取的网页URL,必须是完整的 http:// 或 https:// 链接",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"url"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute 执行网页获取
|
|
||||||
func (t *WebFetchTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
rawURL, ok := arguments["url"].(string)
|
|
||||||
if !ok || rawURL == "" {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 url 参数",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安全检查:只允许 HTTP/HTTPS
|
|
||||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "仅支持 http:// 或 https:// 链接",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟常见浏览器 User-Agent,避免被拒
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0; +https://github.com/AskaEth/Cyrene)")
|
|
||||||
req.Header.Set("Accept", "text/html,text/plain,*/*")
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("请求失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限制读取大小(最多 100KB)
|
|
||||||
limitedReader := io.LimitReader(resp.Body, 100*1024)
|
|
||||||
body, err := io.ReadAll(limitedReader)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("读取响应失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取纯文本摘要(去除 HTML 标签)
|
|
||||||
text := extractText(string(body))
|
|
||||||
|
|
||||||
// 截断到 2000 字符
|
|
||||||
if len([]rune(text)) > 2000 {
|
|
||||||
runes := []rune(text)
|
|
||||||
text = string(runes[:2000]) + "\n\n... [内容已截断,共" + fmt.Sprintf("%d", len(runes)) + "字符]"
|
|
||||||
}
|
|
||||||
|
|
||||||
result := fmt.Sprintf("URL: %s\n状态: %d\n内容类型: %s\n\n%s",
|
|
||||||
rawURL, resp.StatusCode, resp.Header.Get("Content-Type"), text)
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: result,
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractText 从 HTML/文本中提取纯文本
|
|
||||||
func extractText(raw string) string {
|
|
||||||
// 简单的 HTML 标签去除
|
|
||||||
text := raw
|
|
||||||
inTag := false
|
|
||||||
var result []rune
|
|
||||||
for _, r := range text {
|
|
||||||
if r == '<' {
|
|
||||||
inTag = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '>' {
|
|
||||||
inTag = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inTag {
|
|
||||||
result = append(result, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去除多余空白
|
|
||||||
trimmed := strings.TrimSpace(string(result))
|
|
||||||
// 压缩连续空行
|
|
||||||
lines := strings.Split(trimmed, "\n")
|
|
||||||
var cleanLines []string
|
|
||||||
for _, line := range lines {
|
|
||||||
trimLine := strings.TrimSpace(line)
|
|
||||||
if trimLine != "" {
|
|
||||||
cleanLines = append(cleanLines, trimLine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(cleanLines, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebSearchTool 网页搜索工具 - 基于 DuckDuckGo Instant Answer API
|
|
||||||
type WebSearchTool struct {
|
|
||||||
client *http.Client
|
|
||||||
timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWebSearchTool 创建网页搜索工具
|
|
||||||
func NewWebSearchTool() *WebSearchTool {
|
|
||||||
return &WebSearchTool{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definition 返回工具定义
|
|
||||||
func (t *WebSearchTool) Definition() model.ToolDefinition {
|
|
||||||
return model.ToolDefinition{
|
|
||||||
Name: "web_search",
|
|
||||||
Description: "搜索互联网信息。用于查找新闻、资料、知识等。返回搜索结果摘要(最多5条)。",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"query": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "搜索关键词",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"query"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// duckDuckGoResponse DuckDuckGo API 响应
|
|
||||||
type duckDuckGoResponse struct {
|
|
||||||
AbstractText string `json:"AbstractText"`
|
|
||||||
AbstractURL string `json:"AbstractURL"`
|
|
||||||
AbstractSource string `json:"AbstractSource"`
|
|
||||||
Heading string `json:"Heading"`
|
|
||||||
Answer string `json:"Answer"`
|
|
||||||
AnswerType string `json:"AnswerType"`
|
|
||||||
RelatedTopics []duckDuckGoRelated `json:"RelatedTopics"`
|
|
||||||
Results []duckDuckGoResult `json:"Results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type duckDuckGoRelated struct {
|
|
||||||
Text string `json:"Text"`
|
|
||||||
FirstURL string `json:"FirstURL"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type duckDuckGoResult struct {
|
|
||||||
Text string `json:"Text"`
|
|
||||||
FirstURL string `json:"FirstURL"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute 执行网页搜索
|
|
||||||
func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
||||||
query, ok := arguments["query"].(string)
|
|
||||||
if !ok || query == "" {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: "缺少 query 参数",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 DuckDuckGo Instant Answer API
|
|
||||||
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1&skip_disambig=1",
|
|
||||||
url.QueryEscape(query))
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("请求失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
|
|
||||||
if err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("读取响应失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var ddg duckDuckGoResponse
|
|
||||||
if err := json.Unmarshal(body, &ddg); err != nil {
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: "",
|
|
||||||
Error: fmt.Sprintf("解析响应失败: %v", err),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
result.WriteString(fmt.Sprintf("搜索关键词: %s\n\n", query))
|
|
||||||
|
|
||||||
// 1. 如果有即时答案
|
|
||||||
if ddg.Answer != "" {
|
|
||||||
result.WriteString(fmt.Sprintf("📌 即时答案: %s\n\n", ddg.Answer))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 摘要
|
|
||||||
if ddg.AbstractText != "" {
|
|
||||||
abstract := ddg.AbstractText
|
|
||||||
if len([]rune(abstract)) > 500 {
|
|
||||||
runes := []rune(abstract)
|
|
||||||
abstract = string(runes[:500]) + "..."
|
|
||||||
}
|
|
||||||
result.WriteString(fmt.Sprintf("摘要: %s\n", abstract))
|
|
||||||
if ddg.AbstractURL != "" {
|
|
||||||
result.WriteString(fmt.Sprintf("来源: %s\n", ddg.AbstractURL))
|
|
||||||
}
|
|
||||||
result.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 相关话题
|
|
||||||
topics := ddg.RelatedTopics
|
|
||||||
if len(ddg.Results) > 0 {
|
|
||||||
// 优先用 Results
|
|
||||||
count := 0
|
|
||||||
for _, r := range ddg.Results {
|
|
||||||
if count >= 5 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if r.Text != "" {
|
|
||||||
text := stripHTML(r.Text)
|
|
||||||
if len([]rune(text)) > 200 {
|
|
||||||
runes := []rune(text)
|
|
||||||
text = string(runes[:200]) + "..."
|
|
||||||
}
|
|
||||||
result.WriteString(fmt.Sprintf("\n🔗 %s\n", text))
|
|
||||||
if r.FirstURL != "" {
|
|
||||||
result.WriteString(fmt.Sprintf(" %s\n", r.FirstURL))
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
count := 0
|
|
||||||
for _, topic := range topics {
|
|
||||||
if count >= 5 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if topic.Text != "" {
|
|
||||||
text := stripHTML(topic.Text)
|
|
||||||
if len([]rune(text)) > 200 {
|
|
||||||
runes := []rune(text)
|
|
||||||
text = string(runes[:200]) + "..."
|
|
||||||
}
|
|
||||||
result.WriteString(fmt.Sprintf("\n🔗 %s\n", text))
|
|
||||||
if topic.FirstURL != "" {
|
|
||||||
result.WriteString(fmt.Sprintf(" %s\n", topic.FirstURL))
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Len() == 0 {
|
|
||||||
result.WriteString("未找到相关结果。")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.ToolResult{
|
|
||||||
Output: result.String(),
|
|
||||||
Error: "",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripHTML 去除 HTML 标签
|
|
||||||
func stripHTML(s string) string {
|
|
||||||
inTag := false
|
|
||||||
var result []rune
|
|
||||||
for _, r := range s {
|
|
||||||
if r == '<' {
|
|
||||||
inTag = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '>' {
|
|
||||||
inTag = false
|
|
||||||
// 替换常见块级标签为空格
|
|
||||||
result = append(result, ' ')
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inTag {
|
|
||||||
result = append(result, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(result))
|
|
||||||
}
|
|
||||||
+404
-8
@@ -727,6 +727,9 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
<span class="nav-icon">🎤</span><span class="nav-label">语音识别</span>
|
<span class="nav-icon">🎤</span><span class="nav-label">语音识别</span>
|
||||||
<span class="nav-badge" id="stt-badge" style="display:none">0</span>
|
<span class="nav-badge" id="stt-badge" style="display:none">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-panel="plugins">
|
||||||
|
<span class="nav-icon">🔌</span><span class="nav-label">插件管理</span>
|
||||||
|
</button>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="nav-group">
|
<details class="nav-group">
|
||||||
@@ -748,6 +751,9 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
<button class="nav-item" data-panel="llmCalls">
|
<button class="nav-item" data-panel="llmCalls">
|
||||||
<span class="nav-icon">📊</span><span class="nav-label">LLM 调用</span>
|
<span class="nav-icon">📊</span><span class="nav-label">LLM 调用</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-panel="thinkingSchedule">
|
||||||
|
<span class="nav-icon">⏰</span><span class="nav-label">思考调度</span>
|
||||||
|
</button>
|
||||||
</details>
|
</details>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -781,6 +787,7 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
<div class="panel" id="panel-toolCalls"></div>
|
<div class="panel" id="panel-toolCalls"></div>
|
||||||
<!-- 语音识别日志 -->
|
<!-- 语音识别日志 -->
|
||||||
<div class="panel" id="panel-stt"></div>
|
<div class="panel" id="panel-stt"></div>
|
||||||
|
<div class="panel" id="panel-plugins"></div>
|
||||||
<!-- 自主思考日志 -->
|
<!-- 自主思考日志 -->
|
||||||
<div class="panel" id="panel-thinking"></div>
|
<div class="panel" id="panel-thinking"></div>
|
||||||
<!-- 记忆时间线 -->
|
<!-- 记忆时间线 -->
|
||||||
@@ -790,6 +797,7 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
<!-- 客户端管理 -->
|
<!-- 客户端管理 -->
|
||||||
<div class="panel" id="panel-clients"></div>
|
<div class="panel" id="panel-clients"></div>
|
||||||
<div class="panel" id="panel-modelConfig"></div>
|
<div class="panel" id="panel-modelConfig"></div>
|
||||||
|
<div class="panel" id="panel-thinkingSchedule"></div>
|
||||||
<div class="panel" id="panel-llmCalls"></div>
|
<div class="panel" id="panel-llmCalls"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -808,11 +816,11 @@ const STATE = {
|
|||||||
serviceStatus: {},
|
serviceStatus: {},
|
||||||
// 日志
|
// 日志
|
||||||
activeLogTab: 'ai-core',
|
activeLogTab: 'ai-core',
|
||||||
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
|
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [], 'iot-debug-service': [], 'memory-service': [], 'voice-service': [] },
|
||||||
maxLogLines: 500,
|
maxLogLines: 500,
|
||||||
logLayout: 'grid',
|
logLayout: 'grid',
|
||||||
// 性能
|
// 性能
|
||||||
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [], 'memory-service': [], 'tool-engine': [], 'voice-service': [] },
|
perfHistory: { 'ai-core': [], 'gateway': [], 'iot-debug-service': [], 'frontend': [], 'memory-service': [], 'voice-service': [] },
|
||||||
// 会话
|
// 会话
|
||||||
sessionsData: [],
|
sessionsData: [],
|
||||||
sessionsAutoRefresh: null,
|
sessionsAutoRefresh: null,
|
||||||
@@ -996,10 +1004,10 @@ function statusBadge(status) {
|
|||||||
return map[status] || 'badge-stopped';
|
return map[status] || 'badge-stopped';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_SVC_IDS = ['ai-core', 'gateway', 'frontend', 'iot-debug-service', 'memory-service', 'tool-engine', 'voice-service'];
|
const ALL_SVC_IDS = ['ai-core', 'gateway', 'frontend', 'iot-debug-service', 'memory-service', 'voice-service'];
|
||||||
|
|
||||||
function escapeId(id) {
|
function escapeId(id) {
|
||||||
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug', 'memory-service': 'Memory', 'tool-engine': 'Tool Engine', 'voice-service': 'Voice' };
|
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend', 'iot-debug-service': 'IoT Debug', 'memory-service': 'Memory', 'voice-service': 'Voice' };
|
||||||
return map[id] || id;
|
return map[id] || id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,10 +1093,11 @@ function switchPanel(name) {
|
|||||||
const titles = {
|
const titles = {
|
||||||
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
|
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
|
||||||
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
||||||
toolCalls: '🔧 工具调用记录', stt: '🎤 语音识别日志', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
|
toolCalls: '🔧 工具调用记录', stt: '🎤 语音识别日志', plugins: '🔌 插件管理', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
|
||||||
chatPlatforms: '💬 第三方聊天配置与消息日志',
|
chatPlatforms: '💬 第三方聊天配置与消息日志',
|
||||||
clients: '📱 客户端管理',
|
clients: '📱 客户端管理',
|
||||||
modelConfig: '🤖 模型配置管理',
|
modelConfig: '🤖 模型配置管理',
|
||||||
|
thinkingSchedule: '⏰ 思考调度配置',
|
||||||
llmCalls: '📊 LLM 调用日志',
|
llmCalls: '📊 LLM 调用日志',
|
||||||
};
|
};
|
||||||
document.getElementById('panel-title').textContent = titles[name] || name;
|
document.getElementById('panel-title').textContent = titles[name] || name;
|
||||||
@@ -1111,11 +1120,13 @@ function switchPanel(name) {
|
|||||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
|
case 'plugins': renderPluginsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
||||||
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
||||||
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
|
case 'thinkingSchedule': renderThinkingSchedulePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2207,7 +2218,6 @@ function renderServicesPanel() {
|
|||||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-iot-debug-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div></div>
|
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-iot-debug-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>IoT Debug</div></div></div>
|
||||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div></div>
|
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div></div>
|
||||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-memory-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Memory</div></div></div>
|
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-memory-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Memory</div></div></div>
|
||||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-tool-engine" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Tool Engine</div></div></div>
|
|
||||||
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-voice-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Voice</div></div></div>
|
<div class="svc-log-col" style="min-width:320px;flex:1"><div class="log-container" id="log-panel-voice-service" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Voice</div></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4417,6 +4427,392 @@ async function deleteRoutingRule(purpose) {
|
|||||||
renderRoutingTab();
|
renderRoutingTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 思考调度配置面板 ==========
|
||||||
|
|
||||||
|
const ALL_DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
|
const DAY_LABELS = { monday: '一', tuesday: '二', wednesday: '三', thursday: '四', friday: '五', saturday: '六', sunday: '日' };
|
||||||
|
|
||||||
|
function computeCurrentInterval(cfg) {
|
||||||
|
var now = new Date();
|
||||||
|
var wd = ALL_DAYS[(now.getDay() + 6) % 7];
|
||||||
|
var mins = now.getHours() * 60 + now.getMinutes();
|
||||||
|
for (var i = 0; i < cfg.rules.length; i++) {
|
||||||
|
var rule = cfg.rules[i];
|
||||||
|
if (!rule.days || rule.days.indexOf(wd) < 0) continue;
|
||||||
|
var tr = parseTimeRange(rule.time_range);
|
||||||
|
if (!tr) continue;
|
||||||
|
var inRange = tr.start <= tr.end ? (mins >= tr.start && mins < tr.end) : (mins >= tr.start || mins < tr.end);
|
||||||
|
if (!inRange) continue;
|
||||||
|
var excepted = false;
|
||||||
|
for (var e = 0; e < (rule.except || []).length; e++) {
|
||||||
|
var er = parseTimeRange(rule.except[e]);
|
||||||
|
if (er) {
|
||||||
|
var eIn = er.start <= er.end ? (mins >= er.start && mins < er.end) : (mins >= er.start || mins < er.end);
|
||||||
|
if (eIn) { excepted = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!excepted) return rule.interval_minutes;
|
||||||
|
}
|
||||||
|
return cfg.default_interval_minutes || 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeRange(r) {
|
||||||
|
var parts = r.split('-');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
var start = parseHM(parts[0].trim());
|
||||||
|
var end = parseHM(parts[1].trim());
|
||||||
|
if (start === null || end === null) return null;
|
||||||
|
return { start: start, end: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHM(s) {
|
||||||
|
var parts = s.split(':');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
var h = parseInt(parts[0], 10), m = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThinkingSchedulePanel() {
|
||||||
|
var panel = document.getElementById('panel-thinkingSchedule');
|
||||||
|
panel.innerHTML = '<div class="card"><div class="card-body">加载中...</div></div>';
|
||||||
|
|
||||||
|
api('/api/thinking-schedule').then(function(cfg) {
|
||||||
|
if (cfg.error) {
|
||||||
|
panel.innerHTML = '<div class="card"><div class="card-body"><div class="empty-state">⚠ 加载失败: ' + escHtml(cfg.error) + '</div></div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawScheduleForm(panel, cfg);
|
||||||
|
scheduleAutoSave(cfg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var _scheduleAutoSaveTimer = null;
|
||||||
|
function scheduleAutoSave(cfg) {
|
||||||
|
if (_scheduleAutoSaveTimer) clearInterval(_scheduleAutoSaveTimer);
|
||||||
|
_scheduleAutoSaveTimer = setInterval(function() {
|
||||||
|
var cur = computeCurrentInterval(cfg);
|
||||||
|
var el = document.getElementById('schedule-current-interval');
|
||||||
|
if (el) el.textContent = '当前间隔: ' + cur + ' 分钟';
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScheduleForm(panel, cfg) {
|
||||||
|
var curInterval = computeCurrentInterval(cfg);
|
||||||
|
|
||||||
|
var rulesHtml = '';
|
||||||
|
for (var i = 0; i < cfg.rules.length; i++) {
|
||||||
|
rulesHtml += buildRuleRow(cfg.rules[i], i);
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div class="card" style="margin-bottom:12px">' +
|
||||||
|
'<div class="card-header">' +
|
||||||
|
'<span class="card-title">⏰ 思考调度配置</span>' +
|
||||||
|
'<div class="btn-group">' +
|
||||||
|
'<span id="schedule-current-interval" style="font-size:13px;color:var(--accent);margin-right:12px">当前间隔: ' + curInterval + ' 分钟</span>' +
|
||||||
|
'<button class="btn btn-sm btn-accent" onclick="saveSchedule()">💾 保存</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<div style="margin-bottom:12px;display:flex;align-items:center;gap:10px">' +
|
||||||
|
'<label style="white-space:nowrap;font-weight:600">默认间隔 (分钟):</label>' +
|
||||||
|
'<input type="number" id="sched-default-interval" value="' + (cfg.default_interval_minutes || 5) + '" min="1" max="120" style="width:80px" class="form-input"/>' +
|
||||||
|
'<span style="color:var(--text2);font-size:12px">无规则匹配时使用此间隔</span>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<div class="table-wrap"><table><thead><tr>' +
|
||||||
|
'<th>规则名称</th><th>适用日期</th><th>时间段</th><th>排除时段</th><th>间隔(分)</th><th>操作</th>' +
|
||||||
|
'</tr></thead><tbody id="sched-rules-tbody">' +
|
||||||
|
rulesHtml +
|
||||||
|
'</tbody></table></div>' +
|
||||||
|
|
||||||
|
'<button class="btn btn-sm" onclick="addScheduleRule()" style="margin-top:8px">➕ 添加规则</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuleRow(rule, idx) {
|
||||||
|
var daysHtml = '';
|
||||||
|
for (var d = 0; d < ALL_DAYS.length; d++) {
|
||||||
|
var day = ALL_DAYS[d];
|
||||||
|
var checked = (rule.days || []).indexOf(day) >= 0 ? ' checked' : '';
|
||||||
|
daysHtml += '<label style="display:inline-flex;align-items:center;gap:2px;margin-right:4px;font-size:12px;cursor:pointer">' +
|
||||||
|
'<input type="checkbox" data-sched-days="' + idx + '" value="' + day + '"' + checked + ' style="margin:0"/>' +
|
||||||
|
DAY_LABELS[day] + '</label>';
|
||||||
|
}
|
||||||
|
var exceptVal = (rule.except || []).join(', ');
|
||||||
|
return '<tr>' +
|
||||||
|
'<td><input type="text" id="sched-name-' + idx + '" value="' + escHtml(rule.name || '') + '" class="form-input" style="width:120px"/></td>' +
|
||||||
|
'<td>' + daysHtml + '</td>' +
|
||||||
|
'<td><input type="text" id="sched-range-' + idx + '" value="' + escHtml(rule.time_range || '') + '" class="form-input" placeholder="HH:MM-HH:MM" style="width:100px"/></td>' +
|
||||||
|
'<td><input type="text" id="sched-except-' + idx + '" value="' + escHtml(exceptVal) + '" class="form-input" placeholder="HH:MM-HH:MM, ..." style="width:140px"/></td>' +
|
||||||
|
'<td><input type="number" id="sched-interval-' + idx + '" value="' + (rule.interval_minutes || 5) + '" min="1" max="120" class="form-input" style="width:60px"/></td>' +
|
||||||
|
'<td><button class="btn btn-xs btn-red" onclick="deleteScheduleRule(' + idx + ')">🗑</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectScheduleConfig() {
|
||||||
|
var cfg = {
|
||||||
|
version: '1.0',
|
||||||
|
default_interval_minutes: parseInt(document.getElementById('sched-default-interval').value, 10) || 5,
|
||||||
|
rules: []
|
||||||
|
};
|
||||||
|
var tbody = document.getElementById('sched-rules-tbody');
|
||||||
|
var rows = tbody.querySelectorAll('tr');
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var nameEl = document.getElementById('sched-name-' + i);
|
||||||
|
if (!nameEl) continue;
|
||||||
|
var days = [];
|
||||||
|
var checks = document.querySelectorAll('[data-sched-days="' + i + '"]:checked');
|
||||||
|
for (var c = 0; c < checks.length; c++) {
|
||||||
|
days.push(checks[c].value);
|
||||||
|
}
|
||||||
|
var exceptRaw = document.getElementById('sched-except-' + i).value.trim();
|
||||||
|
var exceptList = [];
|
||||||
|
if (exceptRaw) {
|
||||||
|
exceptList = exceptRaw.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
|
||||||
|
}
|
||||||
|
cfg.rules.push({
|
||||||
|
name: nameEl.value.trim(),
|
||||||
|
days: days,
|
||||||
|
time_range: document.getElementById('sched-range-' + i).value.trim(),
|
||||||
|
except: exceptList,
|
||||||
|
interval_minutes: parseInt(document.getElementById('sched-interval-' + i).value, 10) || 5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addScheduleRule() {
|
||||||
|
var cfg = collectScheduleConfig();
|
||||||
|
cfg.rules.push({ name: '', days: [], time_range: '09:00-17:00', except: [], interval_minutes: 5 });
|
||||||
|
var panel = document.getElementById('panel-thinkingSchedule');
|
||||||
|
drawScheduleForm(panel, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteScheduleRule(idx) {
|
||||||
|
var cfg = collectScheduleConfig();
|
||||||
|
cfg.rules.splice(idx, 1);
|
||||||
|
var panel = document.getElementById('panel-thinkingSchedule');
|
||||||
|
drawScheduleForm(panel, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule() {
|
||||||
|
var cfg = collectScheduleConfig();
|
||||||
|
var result = await api('/api/thinking-schedule', { method: 'PUT', body: JSON.stringify(cfg) });
|
||||||
|
if (result.error) {
|
||||||
|
alert('保存失败: ' + result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-render to refresh the current interval preview
|
||||||
|
api('/api/thinking-schedule').then(function(cfg) {
|
||||||
|
var panel = document.getElementById('panel-thinkingSchedule');
|
||||||
|
drawScheduleForm(panel, cfg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 插件管理面板 ==========
|
||||||
|
|
||||||
|
var pluginsTab = 'list'; // 'list' or 'tools'
|
||||||
|
var pluginListData = [];
|
||||||
|
var toolListData = [];
|
||||||
|
|
||||||
|
function renderPluginsPanel() {
|
||||||
|
var panel = document.getElementById('panel-plugins');
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div class="card">' +
|
||||||
|
'<div class="card-header">' +
|
||||||
|
'<span class="card-title">🔌 插件管理</span>' +
|
||||||
|
'<div class="btn-group">' +
|
||||||
|
'<button class="btn btn-sm' + (pluginsTab === 'list' ? ' btn-accent' : '') + '" onclick="switchPluginsTab(\'list\')">📦 插件列表</button>' +
|
||||||
|
'<button class="btn btn-sm' + (pluginsTab === 'tools' ? ' btn-accent' : '') + '" onclick="switchPluginsTab(\'tools\')">🔧 工具注册表</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-body" id="plugins-tab-content">' +
|
||||||
|
'<div class="empty-state">加载中...</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (pluginsTab === 'list') {
|
||||||
|
loadPluginList();
|
||||||
|
} else {
|
||||||
|
loadToolList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPluginsTab(tab) {
|
||||||
|
pluginsTab = tab;
|
||||||
|
renderPluginsPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPluginList() {
|
||||||
|
var content = document.getElementById('plugins-tab-content');
|
||||||
|
try {
|
||||||
|
var result = await api('/api/plugins');
|
||||||
|
if (result.error) {
|
||||||
|
content.innerHTML = '<div class="empty-state">⚠ 加载失败: ' + escHtml(result.error) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pluginListData = result.plugins || [];
|
||||||
|
var html = '';
|
||||||
|
if (pluginListData.length === 0) {
|
||||||
|
html = '<div class="empty-state">📦 暂无已安装的插件</div>';
|
||||||
|
} else {
|
||||||
|
html = '<div style="margin-bottom:8px;color:var(--text2);font-size:13px">共 ' + pluginListData.length + ' 个插件</div>';
|
||||||
|
html += '<div class="table-wrap"><table><thead><tr>' +
|
||||||
|
'<th>名称</th><th>版本</th><th>作者</th><th>分类</th><th>状态</th><th>工具</th><th>操作</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
for (var i = 0; i < pluginListData.length; i++) {
|
||||||
|
var p = pluginListData[i];
|
||||||
|
var m = p.metadata || {};
|
||||||
|
var statusBadge = getStatusBadge(p.status, p.enabled);
|
||||||
|
var catIcon = getCategoryIcon(m.category);
|
||||||
|
var toolCount = (p.tools || []).length;
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td><strong>' + escHtml(m.displayName || m.name) + '</strong><br><span style="font-size:11px;color:var(--text2)">' + escHtml(m.name) + '</span></td>' +
|
||||||
|
'<td>' + escHtml(m.version || '-') + '</td>' +
|
||||||
|
'<td>' + escHtml((m.author && m.author.name) || '-') + '</td>' +
|
||||||
|
'<td>' + catIcon + ' ' + escHtml(m.category || '-') + '</td>' +
|
||||||
|
'<td>' + statusBadge + '</td>' +
|
||||||
|
'<td><span class="badge">' + toolCount + '</span></td>' +
|
||||||
|
'<td>' + buildPluginActions(p) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
content.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
content.innerHTML = '<div class="empty-state">⚠ 请求失败: ' + escHtml(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToolList(filterText) {
|
||||||
|
var content = document.getElementById('plugins-tab-content');
|
||||||
|
try {
|
||||||
|
var result = await api('/api/tools');
|
||||||
|
if (result.error) {
|
||||||
|
content.innerHTML = '<div class="empty-state">⚠ 加载失败: ' + escHtml(result.error) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toolListData = result.tools || [];
|
||||||
|
var filtered = toolListData;
|
||||||
|
if (filterText) {
|
||||||
|
var q = filterText.toLowerCase();
|
||||||
|
filtered = toolListData.filter(function(t) {
|
||||||
|
return (t.id || '').toLowerCase().indexOf(q) >= 0 ||
|
||||||
|
(t.name || '').toLowerCase().indexOf(q) >= 0 ||
|
||||||
|
(t.category || '').toLowerCase().indexOf(q) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">' +
|
||||||
|
'<input type="text" class="form-input" id="tool-search" placeholder="搜索工具名称/ID/Category..." style="width:260px" oninput="loadToolList(this.value)"/>' +
|
||||||
|
'<span style="color:var(--text2);font-size:13px">共 ' + toolListData.length + ' 个工具' + (filtered.length !== toolListData.length ? ',显示 ' + filtered.length + ' 个' : '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
html += '<div class="empty-state">🔧 ' + (filterText ? '无匹配工具' : '暂无已注册的工具') + '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="table-wrap"><table><thead><tr>' +
|
||||||
|
'<th>Tool ID</th><th>Category</th><th>Complexity</th><th>DangerLevel</th><th>参数</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
for (var i = 0; i < filtered.length; i++) {
|
||||||
|
var t = filtered[i];
|
||||||
|
var paramCount = 0;
|
||||||
|
var paramNames = [];
|
||||||
|
if (t.parameters && t.parameters.properties) {
|
||||||
|
paramNames = Object.keys(t.parameters.properties);
|
||||||
|
paramCount = paramNames.length;
|
||||||
|
}
|
||||||
|
var dangerBadge = '';
|
||||||
|
if (t.danger_level && t.danger_level !== 'low') {
|
||||||
|
var dc = t.danger_level === 'high' ? 'var(--red)' : 'var(--orange)';
|
||||||
|
dangerBadge = '<span class="badge" style="background:' + dc + '">' + escHtml(t.danger_level) + '</span>';
|
||||||
|
}
|
||||||
|
html += '<tr style="cursor:pointer" onclick="toggleToolParams(\'' + escHtml(t.id) + '\')">' +
|
||||||
|
'<td><strong>' + escHtml(t.name || t.id) + '</strong><br><span style="font-size:11px;color:var(--text2)">' + escHtml(t.id) + '</span></td>' +
|
||||||
|
'<td>' + escHtml(t.category || '-') + '</td>' +
|
||||||
|
'<td><span class="badge">' + escHtml(t.complexity || 'simple') + '</span></td>' +
|
||||||
|
'<td>' + (dangerBadge || escHtml(t.danger_level || 'low')) + '</td>' +
|
||||||
|
'<td><span style="color:var(--accent)">' + paramCount + ' 参数</span></td>' +
|
||||||
|
'</tr>' +
|
||||||
|
'<tr id="tool-params-' + escHtml(t.id) + '" style="display:none">' +
|
||||||
|
'<td colspan="5">' +
|
||||||
|
'<div class="card" style="margin:4px 0">' +
|
||||||
|
'<div class="card-body" style="font-size:12px">' +
|
||||||
|
'<div style="margin-bottom:4px"><strong>描述:</strong> ' + escHtml(t.description || '-') + '</div>' +
|
||||||
|
'<pre style="background:var(--bg);padding:8px;border-radius:4px;white-space:pre-wrap;max-height:200px;overflow-y:auto">' + escHtml(JSON.stringify(t.parameters, null, 2)) + '</pre>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
content.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
content.innerHTML = '<div class="empty-state">⚠ 请求失败: ' + escHtml(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleToolParams(toolId) {
|
||||||
|
var row = document.getElementById('tool-params-' + toolId);
|
||||||
|
if (row) {
|
||||||
|
row.style.display = row.style.display === 'none' ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status, enabled) {
|
||||||
|
if (status === 'error') {
|
||||||
|
return '<span class="badge" style="background:var(--red)">错误</span>';
|
||||||
|
}
|
||||||
|
if (!enabled || status === 'disabled') {
|
||||||
|
return '<span class="badge" style="background:var(--text2)">已禁用</span>';
|
||||||
|
}
|
||||||
|
if (status === 'running') {
|
||||||
|
return '<span class="badge" style="background:var(--green)">运行中</span>';
|
||||||
|
}
|
||||||
|
return '<span class="badge" style="background:var(--green)">' + escHtml(status) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryIcon(cat) {
|
||||||
|
var icons = {
|
||||||
|
utility: '🔧', text: '📝', security: '🔒', data: '📊', filesystem: '📁',
|
||||||
|
network: '🌐', web: '🌍', iot: '🏠',
|
||||||
|
};
|
||||||
|
return icons[cat] || '📦';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPluginActions(p) {
|
||||||
|
var name = escHtml((p.metadata && p.metadata.name) || '');
|
||||||
|
var enabled = p.enabled;
|
||||||
|
var canEnable = !enabled;
|
||||||
|
var canDisable = enabled && (p.metadata && p.metadata.name !== '');
|
||||||
|
var html = '';
|
||||||
|
if (canEnable) {
|
||||||
|
html += '<button class="btn btn-xs btn-accent" onclick="pluginAction(\'' + name + '\', \'enable\')" title="启用">▶</button> ';
|
||||||
|
}
|
||||||
|
if (canDisable) {
|
||||||
|
html += '<button class="btn btn-xs" onclick="pluginAction(\'' + name + '\', \'disable\')" title="禁用">⏸</button> ';
|
||||||
|
}
|
||||||
|
html += '<button class="btn btn-xs" onclick="pluginAction(\'' + name + '\', \'reload\')" title="重载">🔄</button>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pluginAction(id, action) {
|
||||||
|
try {
|
||||||
|
var result = await api('/api/plugins/' + encodeURIComponent(id) + '/' + action, { method: 'POST' });
|
||||||
|
if (result.error) {
|
||||||
|
alert('操作失败 (' + action + '): ' + result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPluginList();
|
||||||
|
} catch (e) {
|
||||||
|
alert('请求异常: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 客户端管理面板 ==========
|
// ========== 客户端管理面板 ==========
|
||||||
|
|
||||||
function renderClientsPanel() {
|
function renderClientsPanel() {
|
||||||
@@ -4597,7 +4993,7 @@ function formatTokens(n) {
|
|||||||
// Listen for browser back/forward navigation.
|
// Listen for browser back/forward navigation.
|
||||||
window.addEventListener('hashchange', function() {
|
window.addEventListener('hashchange', function() {
|
||||||
var hash = location.hash.replace('#', '');
|
var hash = location.hash.replace('#', '');
|
||||||
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls'];
|
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls', 'thinkingSchedule', 'plugins'];
|
||||||
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
|
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
|
||||||
switchPanel(hash);
|
switchPanel(hash);
|
||||||
}
|
}
|
||||||
@@ -4608,7 +5004,7 @@ refreshStatus();
|
|||||||
|
|
||||||
// Restore last panel from URL hash, or default to dashboard.
|
// Restore last panel from URL hash, or default to dashboard.
|
||||||
var initHash = location.hash.replace('#', '');
|
var initHash = location.hash.replace('#', '');
|
||||||
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls'];
|
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls', 'thinkingSchedule', 'plugins'];
|
||||||
if (initHash && validPanels.indexOf(initHash) >= 0) {
|
if (initHash && validPanels.indexOf(initHash) >= 0) {
|
||||||
switchPanel(initHash);
|
switchPanel(initHash);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-15
@@ -57,6 +57,7 @@ export const DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
|
|||||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||||
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
|
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
|
||||||
export const TOOL_ENGINE_URL = process.env.TOOL_ENGINE_URL || 'http://localhost:8092';
|
export const TOOL_ENGINE_URL = process.env.TOOL_ENGINE_URL || 'http://localhost:8092';
|
||||||
|
export const PLUGIN_MANAGER_URL = process.env.PLUGIN_MANAGER_URL || 'http://localhost:8094';
|
||||||
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
|
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
|
||||||
|
|
||||||
@@ -124,21 +125,6 @@ export const SERVICES = {
|
|||||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||||
goBin: GO_BIN,
|
goBin: GO_BIN,
|
||||||
},
|
},
|
||||||
'tool-engine': {
|
|
||||||
name: '工具引擎',
|
|
||||||
cwd: path.join(ROOT, 'backend/tool-engine'),
|
|
||||||
command: './main',
|
|
||||||
env: {
|
|
||||||
PORT: '8092',
|
|
||||||
DB_URL: process.env.DB_URL || 'postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable',
|
|
||||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
|
||||||
},
|
|
||||||
healthUrl: 'http://localhost:8092/api/v1/health',
|
|
||||||
port: 8092,
|
|
||||||
buildCommand: 'go',
|
|
||||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
|
||||||
goBin: GO_BIN,
|
|
||||||
},
|
|
||||||
'voice-service': {
|
'voice-service': {
|
||||||
name: '语音识别服务',
|
name: '语音识别服务',
|
||||||
cwd: path.join(ROOT, 'backend/voice-service'),
|
cwd: path.join(ROOT, 'backend/voice-service'),
|
||||||
|
|||||||
+60
-14
@@ -17,7 +17,7 @@ import { execSync, spawn } from 'child_process';
|
|||||||
|
|
||||||
import { processManager } from './process-manager.js';
|
import { processManager } from './process-manager.js';
|
||||||
import { performanceMonitor } from './performance.js';
|
import { performanceMonitor } from './performance.js';
|
||||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, PLUGIN_MANAGER_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||||||
|
|
||||||
const AI_CORE_URL = process.env.AI_CORE_URL || 'http://localhost:8081';
|
const AI_CORE_URL = process.env.AI_CORE_URL || 'http://localhost:8081';
|
||||||
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
||||||
@@ -594,16 +594,14 @@ app.get('/api/iot/devices/:id/history', async (req, res) => {
|
|||||||
res.status(result.status).json(result.body);
|
res.status(result.status).json(result.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- 工具调用记录代理 (转发到 tool-engine) ----
|
// ---- 插件管理代理 (转发到 plugin-manager) ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理请求到 Tool-Engine
|
* 代理请求到 Plugin-Manager
|
||||||
* @param {string} path - Tool-Engine API 路径
|
|
||||||
* @param {object} opts - fetch 选项
|
|
||||||
*/
|
*/
|
||||||
async function proxyToToolEngine(path, opts = {}) {
|
async function proxyToPluginManager(path, opts = {}) {
|
||||||
const url = `${TOOL_ENGINE_URL}${path}`;
|
const url = `${PLUGIN_MANAGER_URL}${path}`;
|
||||||
const logPrefix = `[ToolEngine代理]`;
|
const logPrefix = `[PluginManager代理]`;
|
||||||
try {
|
try {
|
||||||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
@@ -622,11 +620,11 @@ async function proxyToToolEngine(path, opts = {}) {
|
|||||||
return {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
body: {
|
body: {
|
||||||
error: `Tool-Engine 不可达: ${err.message}`,
|
error: `插件管理器不可达: ${err.message}`,
|
||||||
errorType: isConnRefused ? 'tool_engine_not_running' : 'tool_engine_unreachable',
|
errorType: isConnRefused ? 'plugin_manager_not_running' : 'plugin_manager_unreachable',
|
||||||
hint: isConnRefused
|
hint: isConnRefused
|
||||||
? 'Tool-Engine 服务未启动,请先在「服务管理」面板中启动 Tool-Engine'
|
? '插件管理器服务未启动,请先在「服务管理」面板中启动 plugin-manager'
|
||||||
: 'Tool-Engine 服务无响应,请检查网络连接和服务状态',
|
: '插件管理器服务无响应,请检查网络连接和服务状态',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -808,6 +806,18 @@ app.get('/api/model-config/fetch-models/:name', async (req, res) => {
|
|||||||
res.status(result.status).json(result.body);
|
res.status(result.status).json(result.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 思考调度配置代理 ----
|
||||||
|
app.get('/api/thinking-schedule', async (_req, res) => {
|
||||||
|
const result = await proxyToGateway('/api/v1/admin/thinking-schedule');
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.put('/api/thinking-schedule', async (req, res) => {
|
||||||
|
const result = await proxyToGateway('/api/v1/admin/thinking-schedule', {
|
||||||
|
method: 'PUT', body: JSON.stringify(req.body),
|
||||||
|
});
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/tool-calls — 查询工具调用记录
|
// GET /api/tool-calls — 查询工具调用记录
|
||||||
app.get('/api/tool-calls', async (req, res) => {
|
app.get('/api/tool-calls', async (req, res) => {
|
||||||
const { tool_name, page, limit } = req.query;
|
const { tool_name, page, limit } = req.query;
|
||||||
@@ -815,13 +825,49 @@ app.get('/api/tool-calls', async (req, res) => {
|
|||||||
if (tool_name) params.set('tool_name', tool_name);
|
if (tool_name) params.set('tool_name', tool_name);
|
||||||
params.set('page', page || '1');
|
params.set('page', page || '1');
|
||||||
params.set('limit', limit || '20');
|
params.set('limit', limit || '20');
|
||||||
const result = await proxyToToolEngine(`/api/v1/tools/calls?${params.toString()}`);
|
const result = await proxyToAICore(`/api/v1/tools/calls?${params.toString()}`);
|
||||||
res.status(result.status).json(result.body);
|
res.status(result.status).json(result.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/tool-calls/stats — 工具调用统计
|
// GET /api/tool-calls/stats — 工具调用统计
|
||||||
app.get('/api/tool-calls/stats', async (_req, res) => {
|
app.get('/api/tool-calls/stats', async (_req, res) => {
|
||||||
const result = await proxyToToolEngine('/api/v1/tools/calls/stats');
|
const result = await proxyToAICore('/api/v1/tools/calls/stats');
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 插件管理代理 (转发到 plugin-manager) ----
|
||||||
|
app.get('/api/plugins', async (_req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/plugins');
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.get('/api/plugins/:id', async (req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id);
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.post('/api/plugins/:id/enable', async (req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/enable', { method: 'POST' });
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.post('/api/plugins/:id/disable', async (req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/disable', { method: 'POST' });
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.post('/api/plugins/:id/reload', async (req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/reload', { method: 'POST' });
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.get('/api/plugins/:id/tools', async (req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/plugins/' + req.params.id + '/tools');
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.get('/api/tools', async (_req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/tools');
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
app.post('/api/tools/:id/execute', async (req, res) => {
|
||||||
|
const result = await proxyToPluginManager('/api/v1/tools/' + req.params.id + '/execute', {
|
||||||
|
method: 'POST', body: JSON.stringify(req.body),
|
||||||
|
});
|
||||||
res.status(result.status).json(result.body);
|
res.status(result.status).json(result.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class ProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 对需要数据库的服务做前置检查
|
// 对需要数据库的服务做前置检查
|
||||||
if (['gateway', 'ai-core', 'memory-service', 'tool-engine', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
|
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
|
||||||
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
||||||
await ensureDBOnline(serviceId, this);
|
await ensureDBOnline(serviceId, this);
|
||||||
}
|
}
|
||||||
@@ -450,11 +450,11 @@ class ProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按顺序启动所有服务 (memory → tool-engine → iot → voice → ai-core → gateway → frontend)
|
* 按顺序启动所有服务 (memory → iot → voice → ai-core → gateway → frontend)
|
||||||
* 每步等待健康检查通过后再启动下一个
|
* 每步等待健康检查通过后再启动下一个
|
||||||
*/
|
*/
|
||||||
async startAllSequential() {
|
async startAllSequential() {
|
||||||
const order = ['memory-service', 'tool-engine', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
|
const order = ['memory-service', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
|
|||||||
@@ -41,3 +41,7 @@
|
|||||||
## 2026-05-24
|
## 2026-05-24
|
||||||
|
|
||||||
- [Phase 6 - 多模型配置系统 + 视觉集成](2026-05-24-phase6-model-config-vision.md) — ModelSelector 路由 + LLM 调用日志 + Vision/OCR 集成 + Bug 修复 (9 文件)
|
- [Phase 6 - 多模型配置系统 + 视觉集成](2026-05-24-phase6-model-config-vision.md) — ModelSelector 路由 + LLM 调用日志 + Vision/OCR 集成 + Bug 修复 (9 文件)
|
||||||
|
|
||||||
|
## 2026-05-25
|
||||||
|
|
||||||
|
- [思考调度 + 多端广播 + 插件管理器](2026-05-25-thinking-schedule-broadcast-plugins.md) — JSON 动态间隔调度 + 消息全端广播 + 【主动消息】解析修复 + 时间感知思考 + DevTools 插件管理面板 (12 文件)
|
||||||
|
|||||||
Reference in New Issue
Block a user