mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
feat: output channel redesign - per-channel output gates, LLM chain events, SDKConfig
- Output channels generate per-channel tools: output_send__{name} (type=output) + output_send__{name}_help
- content is JSON string transparently passed to plugin handler for routing
- EventAgentLLMChain: full LLM response forwarded after each turn for webui/logs
- sdk.New refactored to SDKConfig struct (no more 13 positional args)
- RegisterOutputChannel adds desc param for JSON format documentation
- channelDevice simplified (no Tools method), desc field added
- Child agent permission updated for output_send__ prefix
- System prompt: output gates, multi-call, long messages split
- WebUI: subscribes to EventAgentLLMChain in SSE, no output channel
- Tests updated for new naming convention
This commit is contained in:
@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -75,8 +76,9 @@ type Agent struct {
|
||||
currentOutputChannel string
|
||||
|
||||
// 阶段管道:插件消息流编辑
|
||||
stageHost *StageHost
|
||||
eventBus *events.Bus
|
||||
stageHost *StageHost
|
||||
eventBus *events.Bus
|
||||
pluginHealth *pluginHealthTracker
|
||||
|
||||
// 自循环输入通道:核心内部任务(记忆消歧、系统维护),不经过 IO 层
|
||||
selfInputCh chan string
|
||||
@ -190,6 +192,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
selfInputCh: make(chan string, 64),
|
||||
childResults: make(map[string]string),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
@ -227,6 +230,13 @@ func (a *Agent) injectSelf(task string) {
|
||||
}
|
||||
|
||||
func (a *Agent) eventLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.eventLoop()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
@ -244,6 +254,13 @@ func (a *Agent) eventLoop() {
|
||||
// a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求
|
||||
// b) 通过 a.io.InjectInput() → InputChan → eventLoop(代理空闲时触发新处理循环)
|
||||
func (a *Agent) interceptLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] interceptLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.interceptLoop()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.InputInterruptChan():
|
||||
@ -619,7 +636,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
a.personality != nil && a.personality.Content != "",
|
||||
a.docStoreSize())
|
||||
|
||||
// === Stage: pre_action — 上下文就绪,即将调用 LLM ===
|
||||
if a.runStage(sdk.StagePreAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, nil
|
||||
}
|
||||
@ -745,6 +761,23 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
resp.Content = stageCtx.LLMText
|
||||
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
|
||||
|
||||
// === 发布完整 LLM 响应(含 tool_calls)供插件消费(如 webui 展示) ===
|
||||
chainPayload := map[string]interface{}{
|
||||
"content": resp.Content,
|
||||
"reasoning": resp.ReasoningContent,
|
||||
"tool_calls": resp.ToolCalls,
|
||||
"phase": "intermediate",
|
||||
"turn": turn,
|
||||
}
|
||||
if resp.TokenUsage.Total > 0 {
|
||||
chainPayload["usage"] = map[string]int{
|
||||
"prompt": resp.TokenUsage.Prompt,
|
||||
"completion": resp.TokenUsage.Completion,
|
||||
"total": resp.TokenUsage.Total,
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, nil
|
||||
}
|
||||
@ -754,7 +787,6 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
pluginName := a.resolveToolPlugin(tc.Name)
|
||||
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
||||
|
||||
// === Stage: before_toolcall — 插件可拒绝/改参 ===
|
||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
||||
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||
stageCtx.ToolResults = nil
|
||||
@ -773,6 +805,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
tc.Arguments = stageCtx.ToolCalls[0].Arguments
|
||||
|
||||
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
|
||||
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
|
||||
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
continue
|
||||
}
|
||||
|
||||
result := a.executeToolCall(tc)
|
||||
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
|
||||
|
||||
@ -972,7 +1012,21 @@ func (a *Agent) buildMessages(sysPrompt, input string) []agentAPI.Message {
|
||||
return msgs
|
||||
}
|
||||
|
||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stack := debug.Stack()
|
||||
log.Printf("[agent] tool %s panic: %v\n%s", tc.Name, r, stack)
|
||||
|
||||
if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
|
||||
if a.pluginHealth.recordCrash(pluginName) {
|
||||
log.Printf("[agent] plugin %s exceeded crash threshold, scheduling reload", pluginName)
|
||||
}
|
||||
}
|
||||
|
||||
ret = fmt.Sprintf("工具 %s 执行崩溃: %v", tc.Name, r)
|
||||
}
|
||||
}()
|
||||
switch {
|
||||
case strings.HasPrefix(tc.Name, "memory_"):
|
||||
return a.executeMemoryTool(tc)
|
||||
@ -982,7 +1036,9 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
||||
return a.executeKnowledgeTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "doc_"):
|
||||
return a.executeDocTool(tc)
|
||||
case tc.Name == "output_send":
|
||||
case strings.HasPrefix(tc.Name, "output_send__") && strings.HasSuffix(tc.Name, "_help"):
|
||||
return a.executeOutputSendHelp(tc)
|
||||
case strings.HasPrefix(tc.Name, "output_send__"):
|
||||
return a.executeOutputSendTool(tc)
|
||||
case tc.Name == "output_list_channels":
|
||||
return a.executeOutputListChannels()
|
||||
@ -1512,6 +1568,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 输出指令:使用 output_send__{channel} 作为回复手段
|
||||
prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
|
||||
prompt += "- content 参数是 JSON 字符串,包含要发送的内容。具体格式因通道而异,用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明。\n"
|
||||
prompt += "- output_send__{通道名}_help 是普通 function 类型工具,调用后返回该通道的 JSON 格式详情和示例。\n"
|
||||
prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
|
||||
prompt += "- 直接返回纯文本不会到达任何用户端。"
|
||||
|
||||
if a.skills != nil {
|
||||
if sp := a.skills.GetInjectedPrompt(); sp != "" {
|
||||
prompt += "\n\n" + sp
|
||||
@ -1943,56 +2006,65 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
})
|
||||
}
|
||||
|
||||
// 输出通道工具 — 从已注册 Device 动态生成
|
||||
// 输出通道工具 — 每注册通道生成两个工具:
|
||||
// output_send__{name} (type=output) — 向该通道发送内容
|
||||
// output_send__{name}_help (type=function) — 查看该通道的 JSON 格式说明
|
||||
channels := a.io.ListChannels()
|
||||
chanNames := make([]interface{}, 0, len(channels))
|
||||
chanDesc := "输出通道名称: "
|
||||
for i, ch := range channels {
|
||||
if ch.Type == agentIO.DeviceOutput || ch.Type == agentIO.DeviceIO {
|
||||
chanNames = append(chanNames, ch.Name)
|
||||
if i > 0 {
|
||||
chanDesc += ", "
|
||||
}
|
||||
chanDesc += ch.Name
|
||||
for _, ch := range channels {
|
||||
if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(chanNames) == 0 {
|
||||
chanNames = []interface{}{"default"}
|
||||
chanDesc = "输出通道名称: default"
|
||||
capStr := a.io.GetChannelCapabilities(ch.Name).String()
|
||||
desc := ch.Description
|
||||
if desc == "" {
|
||||
desc = ch.Name + " 输出通道"
|
||||
}
|
||||
|
||||
// 输出门工具
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "output",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_send__" + ch.Name,
|
||||
"description": desc + "。能力: " + capStr + "。content 参数为 JSON 字符串,具体格式请调用 output_send__" + ch.Name + "_help 查看。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "JSON 字符串,包含要发送的内容和路由信息。格式因通道而异,用 _help 工具查看详情。",
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 帮助工具
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_send__" + ch.Name + "_help",
|
||||
"description": "查看 " + ch.Name + " 输出通道的 JSON 格式说明和示例",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// output_list_channels — 列出所有可用输出通道
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_list_channels",
|
||||
"description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和可调用工具。",
|
||||
"description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_send",
|
||||
"description": "通过指定输出通道立即发送一条消息,不等待主回复。用于异步通知、中间进度等场景。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"channel": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": chanDesc,
|
||||
},
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "消息内容",
|
||||
},
|
||||
},
|
||||
"required": []string{"channel", "content"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体处理工具:仅当本轮有未处理的媒体数据时注册
|
||||
if a.pendingMedia != nil {
|
||||
@ -2075,6 +2147,13 @@ func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
|
||||
|
||||
// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
|
||||
func (a *Agent) distillLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] distillLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.distillLoop()
|
||||
}
|
||||
}()
|
||||
if a.docStore == nil && a.memory == nil {
|
||||
return
|
||||
}
|
||||
@ -2088,6 +2167,7 @@ func (a *Agent) distillLoop() {
|
||||
a.distillContext()
|
||||
a.syncGraphToDocs()
|
||||
a.reorgGraph()
|
||||
a.autoReloadPlugins()
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
@ -2461,24 +2541,90 @@ func (a *Agent) processConsolidation(input string) {
|
||||
|
||||
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
|
||||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
channel, _ := tc.Arguments["channel"].(string)
|
||||
// tool name is "output_send__{channel}"
|
||||
channel := strings.TrimPrefix(tc.Name, "output_send__")
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
if channel == "" || content == "" {
|
||||
return "channel 和 content 不能为空"
|
||||
return "工具名称格式: output_send__{channel},content 不能为空"
|
||||
}
|
||||
|
||||
// content 是一个 JSON 字符串,插件通过解析它确定如何发送消息
|
||||
// === Stage: before_output — 输出前插件可审查/改写/拦截 ===
|
||||
stageCtx := &sdk.StageContext{
|
||||
FinalText: content,
|
||||
Phase: sdk.StageBeforeOutput,
|
||||
}
|
||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||
if stageCtx.Response != nil {
|
||||
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
|
||||
}
|
||||
content = stageCtx.FinalText
|
||||
if content == "" {
|
||||
return "输出被插件清空"
|
||||
}
|
||||
tc.Arguments["content"] = content
|
||||
|
||||
// 通道能力检查
|
||||
caps := a.io.GetChannelCapabilities(channel)
|
||||
if caps == 0 {
|
||||
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用通道请用 output_list_channels 查看", channel)
|
||||
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
|
||||
}
|
||||
if !caps.Supports(agentIO.CapText) {
|
||||
return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s)", channel, caps.String())
|
||||
}
|
||||
|
||||
// 通过设备处理器投递
|
||||
if dev := a.io.GetDevice(channel); dev != nil {
|
||||
result, err := dev.Execute("output", tc.Arguments)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
|
||||
}
|
||||
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
|
||||
}
|
||||
|
||||
// 降级:发送到 outputCh(供 OutputChan 消费者)
|
||||
a.io.EmitTextTo("agent_io", channel, content)
|
||||
return fmt.Sprintf("已通过 [%s] 通道发送", channel)
|
||||
}
|
||||
|
||||
// executeOutputSendHelp — 返回指定通道的 JSON 格式说明
|
||||
func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
|
||||
// tool name is "output_send__{channel}_help"
|
||||
suffix := strings.TrimPrefix(tc.Name, "output_send__")
|
||||
channel := strings.TrimSuffix(suffix, "_help")
|
||||
if channel == "" {
|
||||
return "工具名称格式: output_send__{channel}_help"
|
||||
}
|
||||
|
||||
dev := a.io.GetDevice(channel)
|
||||
if dev == nil {
|
||||
return fmt.Sprintf("通道 [%s] 不存在", channel)
|
||||
}
|
||||
|
||||
caps := a.io.GetChannelCapabilities(channel)
|
||||
capStr := "无"
|
||||
if caps != 0 {
|
||||
capStr = caps.String()
|
||||
}
|
||||
|
||||
desc := dev.Description()
|
||||
if desc == "" {
|
||||
desc = channel + " 输出通道"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`通道 [%s]
|
||||
描述: %s
|
||||
能力: %s
|
||||
|
||||
【content JSON 格式说明】
|
||||
发送到此通道时 content 必须是 JSON 字符串,包含以下字段:
|
||||
- "content": 消息正文(必填)
|
||||
- 根据通道不同可能还需要路由字段(如 "group_id", "user_id" 等)
|
||||
|
||||
请在通道描述中查看具体字段要求。
|
||||
示例: {"content":"你好"}`, channel, desc, capStr)
|
||||
}
|
||||
|
||||
// executeOutputListChannels — 列出所有可用通道及其能力
|
||||
func (a *Agent) executeOutputListChannels() string {
|
||||
channels := a.io.ListChannels()
|
||||
@ -2520,6 +2666,28 @@ func (a *Agent) executePluginReload() string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (a *Agent) autoReloadPlugins() {
|
||||
if a.pluginReg == nil {
|
||||
return
|
||||
}
|
||||
for _, name := range a.pluginHealth.pendingReloads() {
|
||||
if !a.pluginReg.AutoRestartEnabled(name) {
|
||||
log.Printf("[agent] skip auto-reload plugin %s: auto-restart disabled by plugin", name)
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] auto-reloading unhealthy plugin: %s", name)
|
||||
if a.stageHost != nil {
|
||||
a.stageHost.UnregisterPluginTools(name)
|
||||
}
|
||||
if err := a.pluginReg.ReloadOne(name); err != nil {
|
||||
log.Printf("[agent] auto-reload plugin %s failed: %v", name, err)
|
||||
} else {
|
||||
a.pluginHealth.markReloaded(name)
|
||||
log.Printf("[agent] plugin %s reloaded successfully", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeSpawnChild 创建子 Agent 异步执行独立任务
|
||||
// 不阻塞主 Agent,子任务完成后通过 selfInputCh 通知主 Agent 查看结果
|
||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||
@ -2560,7 +2728,6 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
// 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
|
||||
allTools := a.buildToolDefs()
|
||||
childTools := make([]interface{}, 0, len(allTools))
|
||||
outputTools := map[string]bool{"output_send": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
|
||||
for _, t := range allTools {
|
||||
toolMap, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
@ -2571,9 +2738,10 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
continue
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
if !outputTools[name] {
|
||||
childTools = append(childTools, t)
|
||||
if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" {
|
||||
continue
|
||||
}
|
||||
childTools = append(childTools, t)
|
||||
}
|
||||
|
||||
var finalResult string
|
||||
@ -2604,7 +2772,7 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
for _, ct := range resp.ToolCalls {
|
||||
var result string
|
||||
switch {
|
||||
case ct.Name == "output_send" || ct.Name == "output_list_channels":
|
||||
case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
|
||||
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||
@ -2796,7 +2964,14 @@ func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
|
||||
return false
|
||||
}
|
||||
ctx.Phase = stage
|
||||
a.stageHost.RunStage(stage, ctx)
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] stage %q plugin panic: %v\n%s", stage, r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
a.stageHost.RunStage(stage, ctx)
|
||||
}()
|
||||
return ctx.Response != nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// mockOutputDevice implements agentIO.Device for testing output tools
|
||||
type mockOutputDevice struct {
|
||||
name string
|
||||
caps agentIO.OutputCapability
|
||||
@ -61,24 +61,11 @@ func TestExecuteOutputSendTool(t *testing.T) {
|
||||
})
|
||||
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "screen",
|
||||
"content": "hello world",
|
||||
tc := agentAPI.ToolCall{Name: "output_send__screen", Arguments: map[string]interface{}{
|
||||
"content": `{"content":"hello world"}`,
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "已通过 [screen] 通道发送" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendToolMissingChannel(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"content": "hello",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "channel 和 content 不能为空" {
|
||||
if !strings.Contains(result, "screen") {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
@ -86,25 +73,22 @@ func TestExecuteOutputSendToolMissingChannel(t *testing.T) {
|
||||
func TestExecuteOutputSendToolEmptyContent(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "screen",
|
||||
}}
|
||||
tc := agentAPI.ToolCall{Name: "output_send__screen", Arguments: map[string]interface{}{}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "channel 和 content 不能为空" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
if result == "" || strings.Contains(result, "已通过") {
|
||||
t.Errorf("expected error for empty content, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendToolChannelNotExist(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "nonexistent",
|
||||
tc := agentAPI.ToolCall{Name: "output_send__nonexistent", Arguments: map[string]interface{}{
|
||||
"content": "hello",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result != "通道 [nonexistent] 不存在或不可用。可用通道请用 output_list_channels 查看" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
if !strings.Contains(result, "不存在") && !strings.Contains(result, "不可用") {
|
||||
t.Errorf("expected error for nonexistent channel, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,23 +100,26 @@ func TestExecuteOutputSendToolNoTextCap(t *testing.T) {
|
||||
})
|
||||
|
||||
a := &Agent{io: io}
|
||||
tc := agentAPI.ToolCall{Name: "output_send", Arguments: map[string]interface{}{
|
||||
"channel": "camera",
|
||||
tc := agentAPI.ToolCall{Name: "output_send__camera", Arguments: map[string]interface{}{
|
||||
"content": "hello",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if result == "已通过 [camera] 通道发送" {
|
||||
if strings.Contains(result, "已通过") {
|
||||
t.Errorf("should reject channel without text capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
io.RegisterDevice(&mockOutputDevice{
|
||||
name: "screen",
|
||||
caps: agentIO.CapText,
|
||||
})
|
||||
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
foundSend := false
|
||||
foundList := false
|
||||
foundHelp := false
|
||||
for _, td := range tools {
|
||||
m, ok := td.(map[string]interface{})
|
||||
if !ok {
|
||||
@ -144,17 +131,17 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
switch name {
|
||||
case "output_send":
|
||||
case "output_send__screen":
|
||||
foundSend = true
|
||||
case "output_list_channels":
|
||||
foundList = true
|
||||
case "output_send__screen_help":
|
||||
foundHelp = true
|
||||
}
|
||||
}
|
||||
if !foundSend {
|
||||
t.Error("output_send should always be in tools")
|
||||
t.Error("output_send__screen should be in tools when device registered")
|
||||
}
|
||||
if !foundList {
|
||||
t.Error("output_list_channels should always be in tools")
|
||||
if !foundHelp {
|
||||
t.Error("output_send__screen_help should be in tools when device registered")
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,8 +149,7 @@ func TestGetAllToolsEmpty(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tools := a.buildToolDefs()
|
||||
// should have at least output_send, output_list_channels
|
||||
if len(tools) < 2 {
|
||||
t.Errorf("expected at least 2 tools, got %d", len(tools))
|
||||
if len(tools) < 1 {
|
||||
t.Errorf("expected at least 1 tool, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
@ -28,18 +29,18 @@ const contextFlushInterval = 5 * time.Second
|
||||
|
||||
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
|
||||
type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
savePath string // 持久化路径,空则不持久化
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
embedder *memory.LocalWordEmbedder
|
||||
trained bool
|
||||
savePath string
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string) *RelevanceContext {
|
||||
rc := &RelevanceContext{
|
||||
veczer: vector.NewTFIDFVectorizer(2),
|
||||
embedder: memory.NewLocalWordEmbedder(),
|
||||
savePath: savePath,
|
||||
}
|
||||
if savePath != "" {
|
||||
@ -59,7 +60,7 @@ func (c *RelevanceContext) load() {
|
||||
return
|
||||
}
|
||||
for _, evt := range events {
|
||||
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
|
||||
evt.Vector = c.embedder.Vectorize(evt.Input + " " + evt.Response)
|
||||
}
|
||||
c.events = events
|
||||
}
|
||||
@ -83,10 +84,9 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
|
||||
evt.Vector = c.embedder.Vectorize(evt.Input + " " + evt.Response)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
// 增量训练向量化器
|
||||
c.trained = false
|
||||
|
||||
c.save()
|
||||
@ -146,10 +146,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return 0
|
||||
}
|
||||
|
||||
// 确保向量化器已训练
|
||||
c.ensureTrained()
|
||||
|
||||
queryVec := c.veczer.Vectorize(currentInput)
|
||||
queryVec := c.embedder.Vectorize(currentInput)
|
||||
|
||||
// 计算每条候选上下文与当前输入的相关性
|
||||
type scored struct {
|
||||
@ -263,10 +262,9 @@ func (c *RelevanceContext) ensureTrained() {
|
||||
for i, evt := range c.events {
|
||||
texts[i] = evt.Input + " " + evt.Response
|
||||
}
|
||||
c.veczer.Train(texts)
|
||||
// 重算所有事件向量,与新的向量化器特征空间对齐
|
||||
c.embedder.Train(texts)
|
||||
for _, evt := range c.events {
|
||||
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
|
||||
evt.Vector = c.embedder.Vectorize(evt.Input + " " + evt.Response)
|
||||
}
|
||||
c.trained = true
|
||||
}
|
||||
|
||||
128
internal/agent/core/plugin_health.go
Normal file
128
internal/agent/core/plugin_health.go
Normal file
@ -0,0 +1,128 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPluginCrashes = 3
|
||||
crashWindow = 5 * time.Minute
|
||||
reloadCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
type pluginHealthTracker struct {
|
||||
mu sync.Mutex
|
||||
records map[string]*pluginHealthRecord
|
||||
}
|
||||
|
||||
type pluginHealthRecord struct {
|
||||
CrashCount int
|
||||
FirstCrash time.Time
|
||||
LastCrash time.Time
|
||||
Unhealthy bool
|
||||
LastReload time.Time
|
||||
}
|
||||
|
||||
func newPluginHealthTracker() *pluginHealthTracker {
|
||||
return &pluginHealthTracker{
|
||||
records: make(map[string]*pluginHealthRecord),
|
||||
}
|
||||
}
|
||||
|
||||
// recordCrash 记录一次崩溃,返回 true 表示需要触发重载
|
||||
func (t *pluginHealthTracker) recordCrash(plugin string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
r, ok := t.records[plugin]
|
||||
if !ok {
|
||||
r = &pluginHealthRecord{}
|
||||
t.records[plugin] = r
|
||||
}
|
||||
|
||||
if now.Sub(r.LastCrash) > crashWindow {
|
||||
r.CrashCount = 0
|
||||
r.FirstCrash = now
|
||||
}
|
||||
|
||||
r.CrashCount++
|
||||
r.LastCrash = now
|
||||
|
||||
if r.CrashCount >= maxPluginCrashes {
|
||||
r.Unhealthy = true
|
||||
log.Printf("[plugin] %s: %d crashes within %v, marking unhealthy", plugin, r.CrashCount, crashWindow)
|
||||
return true
|
||||
}
|
||||
|
||||
log.Printf("[plugin] %s: crash #%d", plugin, r.CrashCount)
|
||||
return false
|
||||
}
|
||||
|
||||
// isHealthy 检查插件是否健康;冷却期后自动恢复
|
||||
func (t *pluginHealthTracker) isHealthy(plugin string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
r, ok := t.records[plugin]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if !r.Unhealthy {
|
||||
return true
|
||||
}
|
||||
if time.Since(r.LastReload) > reloadCooldown {
|
||||
r.Unhealthy = false
|
||||
r.CrashCount = 0
|
||||
log.Printf("[plugin] %s: cooldown passed, restored to healthy", plugin)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// markReloaded 标记插件已重载
|
||||
func (t *pluginHealthTracker) markReloaded(plugin string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
r, ok := t.records[plugin]
|
||||
if ok {
|
||||
r.Unhealthy = false
|
||||
r.CrashCount = 0
|
||||
r.LastReload = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// pendingReloads 返回已过冷却期、需要重载的插件列表
|
||||
func (t *pluginHealthTracker) pendingReloads() []string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
var result []string
|
||||
now := time.Now()
|
||||
for name, r := range t.records {
|
||||
if !r.Unhealthy {
|
||||
continue
|
||||
}
|
||||
if now.Sub(r.LastReload) > reloadCooldown {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// unhealthyPlugins 返回当前所有不健康的插件名
|
||||
func (t *pluginHealthTracker) unhealthyPlugins() []string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
var result []string
|
||||
for name, r := range t.records {
|
||||
if r.Unhealthy {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
@ -53,7 +54,13 @@ func (h *StageHost) GetToolDefs() []sdk.ToolDef {
|
||||
return defs
|
||||
}
|
||||
|
||||
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[stage] tool %s handler panic: %v\n%s", name, r, debug.Stack())
|
||||
err = fmt.Errorf("tool %s handler panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
h.mu.RLock()
|
||||
handler, ok := h.tools[name]
|
||||
h.mu.RUnlock()
|
||||
@ -72,6 +79,22 @@ func (h *StageHost) ToolPlugin(name string) string {
|
||||
return h.toolPlugins[name]
|
||||
}
|
||||
|
||||
func (h *StageHost) UnregisterPluginTools(pluginName string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
var keepDefs []sdk.ToolDef
|
||||
for _, def := range h.toolDefs {
|
||||
if def.Plugin == pluginName {
|
||||
delete(h.tools, def.Name)
|
||||
delete(h.toolPlugins, def.Name)
|
||||
} else {
|
||||
keepDefs = append(keepDefs, def)
|
||||
}
|
||||
}
|
||||
h.toolDefs = keepDefs
|
||||
}
|
||||
|
||||
func inferToolPlugin(name string) string {
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] == '_' {
|
||||
@ -100,6 +123,11 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
wg.Add(1)
|
||||
go func(fn sdk.StageHandler) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[stage] handler panic: %v", r)
|
||||
}
|
||||
}()
|
||||
if err := fn(ctx); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package io
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@ -143,6 +144,12 @@ func (m *IOManager) RegisterDevice(dev Device) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *IOManager) GetDevice(name string) Device {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.devices[name]
|
||||
}
|
||||
|
||||
func (m *IOManager) StartAll() error {
|
||||
m.mu.RLock()
|
||||
devices := make([]Device, 0, len(m.devices))
|
||||
@ -336,7 +343,7 @@ func (m *IOManager) GetAllTools() []ToolDef {
|
||||
return tools
|
||||
}
|
||||
|
||||
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||
m.mu.RLock()
|
||||
type nameDevice struct {
|
||||
name string
|
||||
@ -356,6 +363,12 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (inter
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[io] tool %s execute panic: %v\n%s", name, r, debug.Stack())
|
||||
err = fmt.Errorf("tool %s execute panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return candidates[0].dev.Execute(name, args)
|
||||
}
|
||||
|
||||
|
||||
@ -350,8 +350,9 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.agent.workdir", "")
|
||||
set("core.agent.system_prompt", `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的回复默认发送到用户的输入来源,无需额外工具。
|
||||
如需异步发送消息到其他通道,使用 output_send。
|
||||
使用 output_list_channels 查看可用通道。
|
||||
输出回复请使用 output_send__{通道名} 工具,content 为 JSON 字符串。用 output_list_channels 查看可用通道。
|
||||
使用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明。
|
||||
输出通道可多次调用,长消息应当分多次发出而不是一口气发完。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。
|
||||
|
||||
|
||||
@ -2,18 +2,21 @@ package events
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventSystem EventType = "system"
|
||||
EventAll EventType = "*"
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
EventSystem EventType = "system"
|
||||
EventAll EventType = "*"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
@ -45,13 +48,22 @@ func (b *Bus) Publish(evt *Event) {
|
||||
b.mu.RUnlock()
|
||||
|
||||
for _, h := range allHandlers {
|
||||
h(evt)
|
||||
b.safeCall(h, evt)
|
||||
}
|
||||
for _, h := range typeHandlers {
|
||||
h(evt)
|
||||
b.safeCall(h, evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) safeCall(h Handler, evt *Event) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[bus] handler panic: %v", r)
|
||||
}
|
||||
}()
|
||||
h(evt)
|
||||
}
|
||||
|
||||
func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
|
||||
b.mu.Lock()
|
||||
b.subs[eventType] = append(b.subs[eventType], handler)
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
@ -165,7 +166,7 @@ func (s *Store) Add(name, content string) error {
|
||||
Content: content,
|
||||
Path: path,
|
||||
Category: sanitize(category),
|
||||
Tags: extractKeywords(name + " " + content),
|
||||
Tags: memory.ExtractKeywords(name + " " + content),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.items[id] = k
|
||||
@ -381,7 +382,7 @@ func (s *Store) scanDir(category, dirName string) {
|
||||
Content: content,
|
||||
Path: contentPath,
|
||||
Category: category,
|
||||
Tags: extractKeywords(dirName + " " + content),
|
||||
Tags: memory.ExtractKeywords(dirName + " " + content),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.items[name] = k
|
||||
@ -411,33 +412,4 @@ func sanitize(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func extractKeywords(text string) []string {
|
||||
stopWords := map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "都": true,
|
||||
"一": true, "一个": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"什么": true, "怎么": true, "为什么": true, "如何": true,
|
||||
"我们": true, "你们": true, "他们": true, "这个": true,
|
||||
"那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
runes := []rune(text)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for i := 0; i < len(runes)-1; i++ {
|
||||
word := string(runes[i : i+2])
|
||||
if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) && !seen[word] {
|
||||
seen[word] = true
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
}
|
||||
|
||||
if len(keywords) > 10 {
|
||||
keywords = keywords[:10]
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
118
internal/memory/cut.go
Normal file
118
internal/memory/cut.go
Normal file
@ -0,0 +1,118 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
)
|
||||
|
||||
var (
|
||||
jiebaOnce sync.Once
|
||||
jiebaInst *gojieba.Jieba
|
||||
)
|
||||
|
||||
func GetJieba() *gojieba.Jieba {
|
||||
jiebaOnce.Do(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[jieba] init panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
d := jiebaDictDir()
|
||||
if d == "" {
|
||||
log.Printf("[jieba] no dictionary directory found, jieba disabled")
|
||||
return
|
||||
}
|
||||
jiebaInst = gojieba.NewJieba(
|
||||
filepath.Join(d, "jieba.dict.utf8"),
|
||||
filepath.Join(d, "hmm_model.utf8"),
|
||||
filepath.Join(d, "user.dict.utf8"),
|
||||
filepath.Join(d, "idf.utf8"),
|
||||
filepath.Join(d, "stop_words.utf8"),
|
||||
)
|
||||
})
|
||||
return jiebaInst
|
||||
}
|
||||
|
||||
func jiebaDictDir() string {
|
||||
candidates := []string{
|
||||
os.Getenv("GOMODCACHE"),
|
||||
os.Getenv("GOPATH"),
|
||||
filepath.Join(os.Getenv("HOME"), "go"),
|
||||
"/root/go",
|
||||
"/go",
|
||||
"/home/program/go",
|
||||
}
|
||||
for _, base := range candidates {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
d := filepath.Join(base, "pkg", "mod", "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
|
||||
if info, err := os.Stat(d); err == nil && info.IsDir() {
|
||||
return d
|
||||
}
|
||||
// also try without "pkg/mod" (in case GOPATH is already the mod cache)
|
||||
d2 := filepath.Join(base, "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
|
||||
if info, err := os.Stat(d2); err == nil && info.IsDir() {
|
||||
return d2
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var stopWords = map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "人": true, "都": true,
|
||||
"一": true, "一个": true, "上": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true, "你": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"我": true, "我们": true, "你们": true, "他们": true,
|
||||
"吗": true, "吧": true, "啊": true,
|
||||
"嗯": true, "哦": true, "哈": true, "呀": true, "嘛": true,
|
||||
"然后": true, "因为": true, "所以": true, "如果": true, "但是": true,
|
||||
"可能": true, "还是": true, "已经": true,
|
||||
"就是": true, "不是": true, "是的": true,
|
||||
"非常": true, "比较": true, "应该": true, "需要": true,
|
||||
"能够": true, "目前": true, "现在": true, "今天": true, "昨天": true,
|
||||
"明天": true, "知道": true, "觉得": true, "认为": true,
|
||||
"能": true, "没": true, "对": true,
|
||||
"the": true, "a": true, "an": true, "is": true, "are": true,
|
||||
"was": true, "were": true, "be": true, "been": true, "being": true,
|
||||
"have": true, "has": true, "had": true, "do": true, "does": true,
|
||||
"did": true, "will": true, "would": true, "could": true, "should": true,
|
||||
"may": true, "might": true, "can": true, "shall": true, "this": true,
|
||||
"that": true, "these": true, "those": true, "it": true, "its": true,
|
||||
"and": true, "or": true, "but": true, "in": true, "on": true,
|
||||
"at": true, "to": true, "for": true, "of": true, "with": true,
|
||||
"what": true, "how": true, "why": true, "which": true, "where": true,
|
||||
"when": true, "who": true, "whom": true,
|
||||
}
|
||||
|
||||
func ExtractKeywords(text string) []string {
|
||||
x := GetJieba()
|
||||
if x == nil {
|
||||
return nil
|
||||
}
|
||||
words := x.Cut(text, true)
|
||||
var keywords []string
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range words {
|
||||
if stopWords[w] || seen[w] {
|
||||
continue
|
||||
}
|
||||
r := []rune(w)
|
||||
if len(r) < 2 {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
keywords = append(keywords, w)
|
||||
}
|
||||
if len(keywords) > 5 {
|
||||
keywords = keywords[:5]
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
@ -388,7 +389,7 @@ func summarizeEntries(entries []ContextEntry) string {
|
||||
var topics []string
|
||||
for _, e := range entries {
|
||||
sources[e.Source]++
|
||||
words := extractKeywords(e.Content)
|
||||
words := memory.ExtractKeywords(e.Content)
|
||||
topics = append(topics, words...)
|
||||
}
|
||||
|
||||
@ -420,7 +421,7 @@ func summarizeEntries(entries []ContextEntry) string {
|
||||
func extractTags(entries []ContextEntry) []string {
|
||||
tagSet := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
for _, kw := range extractKeywords(e.Content) {
|
||||
for _, kw := range memory.ExtractKeywords(e.Content) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
}
|
||||
@ -439,7 +440,7 @@ func extractEntities(entries []ContextEntry) []string {
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
for _, kw := range extractKeywords(e.Content) {
|
||||
for _, kw := range memory.ExtractKeywords(e.Content) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
@ -452,32 +453,6 @@ func extractEntities(entries []ContextEntry) []string {
|
||||
return entities
|
||||
}
|
||||
|
||||
func extractKeywords(text string) []string {
|
||||
stopWords := map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "人": true, "都": true,
|
||||
"一": true, "一个": true, "上": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true, "你": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"什么": true, "怎么": true, "为什么": true, "如何": true,
|
||||
"我": true, "我们": true, "你们": true, "他们": true, "这个": true,
|
||||
"那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
runes := []rune(text)
|
||||
|
||||
// bi-gram
|
||||
for i := 0; i < len(runes)-1; i++ {
|
||||
word := string(runes[i : i+2])
|
||||
if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) {
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > max {
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
func TestInsertAndQuery(t *testing.T) {
|
||||
@ -186,7 +188,7 @@ func TestSummarizeEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
kws := extractKeywords("今天天气很好")
|
||||
kws := memory.ExtractKeywords("今天天气很好")
|
||||
if len(kws) == 0 {
|
||||
t.Error("should extract keywords from Chinese text")
|
||||
}
|
||||
|
||||
221
internal/memory/embedder.go
Normal file
221
internal/memory/embedder.go
Normal file
@ -0,0 +1,221 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
type LocalWordEmbedder struct {
|
||||
mu sync.RWMutex
|
||||
jieba *gojieba.Jieba
|
||||
stopWords map[string]bool
|
||||
|
||||
docFreq map[string]float64
|
||||
totalDocs int
|
||||
|
||||
coOccur map[string]map[string]float64
|
||||
|
||||
vocab map[string]bool
|
||||
trained bool
|
||||
}
|
||||
|
||||
func NewLocalWordEmbedder() *LocalWordEmbedder {
|
||||
sw := make(map[string]bool)
|
||||
for k, v := range stopWords {
|
||||
sw[k] = v
|
||||
}
|
||||
return &LocalWordEmbedder{
|
||||
jieba: GetJieba(),
|
||||
stopWords: sw,
|
||||
docFreq: make(map[string]float64),
|
||||
coOccur: make(map[string]map[string]float64),
|
||||
vocab: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) tokenize(text string) []string {
|
||||
if e.jieba == nil {
|
||||
return nil
|
||||
}
|
||||
words := e.jieba.Cut(text, true)
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range words {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" || e.stopWords[w] || seen[w] {
|
||||
continue
|
||||
}
|
||||
runes := []rune(w)
|
||||
if len(runes) < 2 {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
result = append(result, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) Train(docs []string) {
|
||||
if e.jieba == nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
e.docFreq = make(map[string]float64)
|
||||
e.coOccur = make(map[string]map[string]float64)
|
||||
e.vocab = make(map[string]bool)
|
||||
|
||||
tokenized := make([][]string, len(docs))
|
||||
|
||||
for i, doc := range docs {
|
||||
tokens := e.tokenize(doc)
|
||||
tokenized[i] = tokens
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range tokens {
|
||||
e.vocab[t] = true
|
||||
if !seen[t] {
|
||||
e.docFreq[t]++
|
||||
seen[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
e.totalDocs = len(docs)
|
||||
|
||||
windowSize := 5
|
||||
for _, tokens := range tokenized {
|
||||
for i, word := range tokens {
|
||||
start := i - windowSize
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i + windowSize + 1
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
for j := start; j < end; j++ {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
ctx := tokens[j]
|
||||
if e.coOccur[word] == nil {
|
||||
e.coOccur[word] = make(map[string]float64)
|
||||
}
|
||||
e.coOccur[word][ctx]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for word, ctxs := range e.coOccur {
|
||||
totalPairs := 0.0
|
||||
for _, count := range ctxs {
|
||||
totalPairs += count
|
||||
}
|
||||
pWord := e.docFreq[word] / float64(e.totalDocs)
|
||||
for ctx, count := range ctxs {
|
||||
pCtx := e.docFreq[ctx] / float64(e.totalDocs)
|
||||
pJoint := count / totalPairs
|
||||
pmi := math.Log2(pJoint / (pWord * pCtx))
|
||||
if pmi <= 0 {
|
||||
delete(ctxs, ctx)
|
||||
} else {
|
||||
ctxs[ctx] = pmi
|
||||
}
|
||||
}
|
||||
e.coOccur[word] = pruneTopK(ctxs, 50)
|
||||
}
|
||||
|
||||
e.trained = true
|
||||
}
|
||||
|
||||
func pruneTopK(m map[string]float64, k int) map[string]float64 {
|
||||
if len(m) <= k {
|
||||
return m
|
||||
}
|
||||
type kv struct {
|
||||
k string
|
||||
v float64
|
||||
}
|
||||
var sorted []kv
|
||||
for key, val := range m {
|
||||
sorted = append(sorted, kv{key, val})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].v > sorted[j].v
|
||||
})
|
||||
result := make(map[string]float64, k)
|
||||
for i := 0; i < k; i++ {
|
||||
result[sorted[i].k] = sorted[i].v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) Vectorize(text string) vector.Vector {
|
||||
e.mu.RLock()
|
||||
useEmbedding := e.trained
|
||||
e.mu.RUnlock()
|
||||
|
||||
tokens := e.tokenize(text)
|
||||
if len(tokens) == 0 {
|
||||
return vector.Vector{}
|
||||
}
|
||||
|
||||
tf := make(map[string]float64)
|
||||
for _, t := range tokens {
|
||||
tf[t]++
|
||||
}
|
||||
maxTF := 0.0
|
||||
for _, count := range tf {
|
||||
if count > maxTF {
|
||||
maxTF = count
|
||||
}
|
||||
}
|
||||
|
||||
vec := make(vector.Vector)
|
||||
|
||||
if useEmbedding {
|
||||
e.mu.RLock()
|
||||
for word, count := range tf {
|
||||
tfidf := (count / maxTF) * idf(e.docFreq[word], e.totalDocs)
|
||||
|
||||
if ctxs, ok := e.coOccur[word]; ok {
|
||||
for ctx, pmi := range ctxs {
|
||||
vec[ctx] += tfidf * pmi
|
||||
}
|
||||
}
|
||||
|
||||
vec["__w__"+word] += tfidf
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
} else {
|
||||
for word, count := range tf {
|
||||
tfNorm := count / maxTF
|
||||
var df float64
|
||||
e.mu.RLock()
|
||||
df = e.docFreq[word]
|
||||
e.mu.RUnlock()
|
||||
vec[word] = tfNorm * idf(df, e.totalDocs)
|
||||
}
|
||||
}
|
||||
|
||||
return vec
|
||||
}
|
||||
|
||||
func idf(df float64, total int) float64 {
|
||||
if df <= 0 || total <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
return math.Log(float64(total+1)/(df+1)+1) + 1
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) Trained() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.trained
|
||||
}
|
||||
@ -132,6 +132,9 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
if t.Subject == "" || t.Relation == "" || t.Object == "" {
|
||||
continue
|
||||
}
|
||||
if !validEntityName(t.Subject) || !validEntityName(t.Object) {
|
||||
continue
|
||||
}
|
||||
|
||||
subjType := t.SubjectType
|
||||
if subjType == "" {
|
||||
@ -161,11 +164,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
var sourceID, targetID int64
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||
}
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
@ -186,7 +189,27 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
return entitiesCreated, relationsCreated, nil
|
||||
}
|
||||
|
||||
func validEntityName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
r := []rune(name)
|
||||
if len(r) < 2 || len(r) > 50 {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
for _, ch := range r {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '\u4e00' && ch <= '\u9fff') || ch == '-' || ch == '_' {
|
||||
hasLetter = true
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) {
|
||||
if !validEntityName(name) {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Exec(
|
||||
`INSERT INTO entities (name, type) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
|
||||
@ -94,7 +94,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
|
||||
vectorEntities := idx.vectorSearchEntities(userInput)
|
||||
|
||||
// 2. 关键词搜索:已有逻辑
|
||||
keywords := extractKeywords(userInput)
|
||||
keywords := ExtractKeywords(userInput)
|
||||
if len(keywords) == 0 && len(vectorEntities) == 0 {
|
||||
keywords = []string{userInput}
|
||||
}
|
||||
@ -282,46 +282,6 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func extractKeywords(input string) []string {
|
||||
stopWords := map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "人": true, "都": true,
|
||||
"一": true, "一个": true, "上": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true, "你": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"什么": true, "怎么": true, "为什么": true, "如何": true,
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
runes := []rune(input)
|
||||
|
||||
bigram := []rune{}
|
||||
for _, r := range runes {
|
||||
bigram = append(bigram, r)
|
||||
if len(bigram) >= 2 {
|
||||
word := string(bigram)
|
||||
if !stopWords[word] && !seen[word] {
|
||||
seen[word] = true
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
bigram = bigram[1:]
|
||||
}
|
||||
}
|
||||
|
||||
if len(keywords) == 0 && len(runes) > 0 {
|
||||
keywords = []string{string(runes)}
|
||||
}
|
||||
|
||||
if len(keywords) > 5 {
|
||||
keywords = keywords[:5]
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
func buildIndexSummary(entities []Entity) string {
|
||||
if len(entities) == 0 {
|
||||
return ""
|
||||
|
||||
@ -186,9 +186,9 @@ func TestExtractKeywords(t *testing.T) {
|
||||
{"的了的", 0}, // all stop words
|
||||
}
|
||||
for _, tt := range tests {
|
||||
kw := extractKeywords(tt.input)
|
||||
kw := ExtractKeywords(tt.input)
|
||||
if len(kw) < tt.min {
|
||||
t.Errorf("extractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
|
||||
t.Errorf("ExtractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ func TestBridgeE2E_WebPlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
sett := sdk.NewSettings("web", nil)
|
||||
psdk := sdk.New("web", nil, nil, nil, nil, nil, nil, nil, sett, regTool, regStage, regAPI)
|
||||
psdk := sdk.New("web", sdk.SDKConfig{Settings: sett, RegTool: regTool, RegStage: regStage, RegAPI: regAPI})
|
||||
|
||||
plg, err := newDLLPlugin(dllPath, "web", nil)
|
||||
if err != nil {
|
||||
@ -136,11 +136,11 @@ func TestBridgeE2E_SanitizerStages(t *testing.T) {
|
||||
}
|
||||
|
||||
sett := sdk.NewSettings("sanitizer", nil)
|
||||
psdk := sdk.New("sanitizer", nil, nil, nil, nil, nil, nil, nil, sett,
|
||||
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
|
||||
regStage,
|
||||
func(name string) error { return nil },
|
||||
)
|
||||
psdk := sdk.New("sanitizer", sdk.SDKConfig{Settings: sett,
|
||||
RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
|
||||
RegStage: regStage,
|
||||
RegAPI: func(name string) error { return nil },
|
||||
})
|
||||
|
||||
plg, err := newDLLPlugin(dllPath, "sanitizer", nil)
|
||||
if err != nil {
|
||||
|
||||
@ -60,6 +60,8 @@ type Registry struct {
|
||||
instances []sdk.Plugin
|
||||
factories map[string]NativeFactory
|
||||
|
||||
pluginAutoRestart map[string]bool
|
||||
|
||||
iom *agentIO.IOManager
|
||||
evBus *events.Bus
|
||||
memDB *memory.GraphDB
|
||||
@ -77,8 +79,9 @@ type Registry struct {
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
plugins: make(map[string]sdk.Plugin),
|
||||
factories: make(map[string]NativeFactory),
|
||||
plugins: make(map[string]sdk.Plugin),
|
||||
factories: make(map[string]NativeFactory),
|
||||
pluginAutoRestart: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,6 +105,24 @@ func (r *Registry) RegisterNative(name string, factory NativeFactory) {
|
||||
globalFactories.Store(name, factory)
|
||||
}
|
||||
|
||||
type channelDevice struct {
|
||||
name string
|
||||
desc string
|
||||
caps agentIO.OutputCapability
|
||||
handler sdk.ToolHandler
|
||||
}
|
||||
|
||||
func (d *channelDevice) Name() string { return d.name }
|
||||
func (d *channelDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *channelDevice) Description() string { return d.desc }
|
||||
func (d *channelDevice) Start() error { return nil }
|
||||
func (d *channelDevice) Stop() error { return nil }
|
||||
func (d *channelDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
func (d *channelDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *channelDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
return d.handler(args)
|
||||
}
|
||||
|
||||
func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
sett := sdk.NewSettings(name, r.cfgReg)
|
||||
|
||||
@ -120,16 +141,32 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
regAPI = func(name string) error { return nil }
|
||||
}
|
||||
|
||||
return sdk.New(name,
|
||||
r.iom, r.evBus,
|
||||
sdk.NewGraphMemory(r.memDB),
|
||||
sdk.NewTextMemory(r.textMem),
|
||||
sdk.NewDocMemory(r.docStore),
|
||||
sdk.NewKnowledge(r.ks),
|
||||
sdk.NewLLM(r.mgr),
|
||||
sett,
|
||||
regTool, regStage, regAPI,
|
||||
)
|
||||
regOutput := func(chName string, caps int, desc string, handler sdk.ToolHandler) error {
|
||||
if r.iom == nil {
|
||||
return nil
|
||||
}
|
||||
return r.iom.RegisterDevice(&channelDevice{
|
||||
name: chName,
|
||||
caps: agentIO.OutputCapability(caps),
|
||||
desc: desc,
|
||||
handler: handler,
|
||||
})
|
||||
}
|
||||
|
||||
return sdk.New(name, sdk.SDKConfig{
|
||||
IOManager: r.iom,
|
||||
EventBus: r.evBus,
|
||||
Memory: sdk.NewGraphMemory(r.memDB),
|
||||
TextMemory: sdk.NewTextMemory(r.textMem),
|
||||
DocMemory: sdk.NewDocMemory(r.docStore),
|
||||
Knowledge: sdk.NewKnowledge(r.ks),
|
||||
LLM: sdk.NewLLM(r.mgr),
|
||||
Settings: sett,
|
||||
RegTool: regTool,
|
||||
RegStage: regStage,
|
||||
RegAPI: regAPI,
|
||||
RegOutput: regOutput,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Registry) Load(dir string) error {
|
||||
@ -201,6 +238,7 @@ func (r *Registry) Load(dir string) error {
|
||||
|
||||
r.mu.Lock()
|
||||
r.plugins[name] = p
|
||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||
r.instances = append(r.instances, p)
|
||||
r.mu.Unlock()
|
||||
log.Printf("[plugin] loaded: %s", name)
|
||||
@ -263,6 +301,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
||||
|
||||
r.mu.Lock()
|
||||
r.plugins[name] = plg
|
||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||
r.instances = append(r.instances, plg)
|
||||
r.mu.Unlock()
|
||||
log.Printf("[plugin] loaded: %s", name)
|
||||
@ -279,6 +318,7 @@ func (r *Registry) StopAll() {
|
||||
}
|
||||
r.plugins = make(map[string]sdk.Plugin)
|
||||
r.instances = nil
|
||||
r.pluginAutoRestart = make(map[string]bool)
|
||||
}
|
||||
|
||||
func (r *Registry) Reload(dir string) (string, error) {
|
||||
@ -289,6 +329,32 @@ func (r *Registry) Reload(dir string) (string, error) {
|
||||
return fmt.Sprintf("loaded %d plugins", len(r.instances)), nil
|
||||
}
|
||||
|
||||
func (r *Registry) ReloadOne(name string) error {
|
||||
plgDir := filepath.Join(r.plgDir, name)
|
||||
|
||||
r.mu.Lock()
|
||||
if p, ok := r.plugins[name]; ok {
|
||||
if err := p.Stop(); err != nil {
|
||||
log.Printf("[plugin] stop %s for reload: %v", name, err)
|
||||
}
|
||||
delete(r.plugins, name)
|
||||
for i, inst := range r.instances {
|
||||
if inst.Name() == name {
|
||||
r.instances = append(r.instances[:i], r.instances[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
ok := r.loadOne(plgDir, name)
|
||||
if !ok {
|
||||
return fmt.Errorf("reload plugin %s failed", name)
|
||||
}
|
||||
log.Printf("[plugin] reloaded: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
@ -306,6 +372,16 @@ func (r *Registry) Get(name string) sdk.Plugin {
|
||||
return r.plugins[name]
|
||||
}
|
||||
|
||||
func (r *Registry) AutoRestartEnabled(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
enabled, ok := r.pluginAutoRestart[name]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (r *Registry) PluginMetas() map[string]PluginMeta {
|
||||
metas := make(map[string]PluginMeta)
|
||||
globalPluginMeta.Range(func(key, val interface{}) bool {
|
||||
|
||||
@ -177,6 +177,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "default_timeout", Type: "string", DisplayName: "默认终端超时",
|
||||
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h(默认 5m)",
|
||||
|
||||
@ -31,8 +31,7 @@ func (tc *toolCapture) RegisterAPI(name string) error {
|
||||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
p := New("agentcli")
|
||||
tc := newToolCapture()
|
||||
// Use nil for fields we don't need (iom, eventBus, etc.)
|
||||
sdk := sdk.New("agentcli", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
sdk := sdk.New("agentcli", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@ -70,6 +70,7 @@ func New(name, socketPath string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "api_key", Type: "password", DisplayName: "CLI API 密钥",
|
||||
Description: "CLI 客户端连接时需提供的认证密钥(留空则使用 WebUI 密钥)",
|
||||
|
||||
@ -81,6 +81,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
|
||||
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)",
|
||||
|
||||
@ -34,7 +34,7 @@ func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
p := New("cmd")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("cmd", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
sdk := sdk.New("cmd", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "dir",
|
||||
|
||||
@ -113,6 +113,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.autoInterval = 30 * time.Minute
|
||||
p.llmTimeout = 120 * time.Second
|
||||
p.llmMaxTurns = 20
|
||||
|
||||
@ -42,7 +42,7 @@ func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
Configure(sh, iom, pr, nil, nil, nil, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -192,7 +192,7 @@ func TestHealthcheckWithMemory(t *testing.T) {
|
||||
Configure(sh, iom, pr, memDB, nil, nil, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -235,7 +235,7 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
|
||||
Configure(sh, iom, pr, nil, ks, nil, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -289,7 +289,7 @@ func TestHealthcheckWithDocStore(t *testing.T) {
|
||||
Configure(sh, iom, pr, nil, nil, ds, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
sdk := sdk.New("healthcheck", sdk.SDKConfig{RegTool: tc.RegisterTool, RegStage: tc.RegisterStage, RegAPI: tc.RegisterAPI})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
cfgs, err := p.loadConfig(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mcp config: %w", err)
|
||||
|
||||
@ -68,6 +68,7 @@ func New(name, skillsDir string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
|
||||
@ -326,13 +326,13 @@ func TestLoadOCPluginViaPluginStart(t *testing.T) {
|
||||
|
||||
var registeredTools []string
|
||||
registeredHandlers := make(map[string]sdk.ToolHandler)
|
||||
sdk := sdk.New("openclaw", nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
sdk := sdk.New("openclaw", sdk.SDKConfig{
|
||||
RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
registeredTools = append(registeredTools, name)
|
||||
registeredHandlers[name] = handler
|
||||
return nil
|
||||
},
|
||||
nil, nil)
|
||||
})
|
||||
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatalf("start plugin: %v", err)
|
||||
|
||||
@ -63,6 +63,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "http_addr",
|
||||
Default: HTTPAddr,
|
||||
|
||||
@ -40,6 +40,7 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.maxDur = 24 * time.Hour
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "max_duration", Type: "string", DisplayName: "最大定时时长",
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HomeAgent Dashboard</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" onerror="window._THREE_FAILED=true"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js" onerror="window._THREE_FAILED=true"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js" onerror="console.warn('marked CDN failed')"></script>
|
||||
<script>setTimeout(function(){if(!window.THREE)window._THREE_FAILED=true},8000)</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
@ -152,9 +153,9 @@ code { font-family:monospace; font-size:12px; color:var(--pre-color) }
|
||||
.empty-state { text-align:center; padding:40px 20px; color:var(--text-muted) }
|
||||
.empty-state p { font-size:14px; margin-bottom:8px }
|
||||
.empty-state .icon { font-size:36px; margin-bottom:12px; opacity:.5 }
|
||||
.chat-layout { display:flex; gap:16px; height:calc(100vh - 100px); min-height:60vh }
|
||||
.chat-main { flex:2; min-width:0; display:flex; flex-direction:column }
|
||||
.chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0 }
|
||||
.chat-layout { display:flex; gap:16px; height:calc(100vh - 100px); min-height:60vh; overflow:hidden }
|
||||
.chat-main { flex:2; min-width:0; min-height:0; display:flex; flex-direction:column }
|
||||
.chat-main .card { flex:1; display:flex; flex-direction:column; margin-bottom:0; min-height:0 }
|
||||
.chat-main .card h2 { flex-shrink:0 }
|
||||
.chat-messages { flex:1; overflow-y:auto; padding:12px; border:1px solid var(--border-color); border-radius:8px; background:var(--chat-bg); margin-bottom:0; display:flex; flex-direction:column; gap:4px; min-height:0 }
|
||||
.msg { display:flex; gap:8px; margin-bottom:2px; align-items:flex-start; max-width:85% }
|
||||
@ -229,6 +230,20 @@ code { font-family:monospace; font-size:12px; color:var(--pre-color) }
|
||||
#sm-container-chat { height:260px; background:var(--bg-input); border-radius:6px; border:1px solid var(--border-color); overflow:hidden; position:relative }
|
||||
#sm-container-chat canvas { display:block }
|
||||
|
||||
.toggle-row { margin-top:8px; display:flex; align-items:center; gap:12px }
|
||||
.toggle-row .label-text { color:#8888aa; font-size:12px }
|
||||
.toggle-switch { position:relative; width:36px; height:20px; cursor:pointer; flex-shrink:0 }
|
||||
.toggle-track { position:absolute; inset:0; background:rgba(60,60,80,0.8); border-radius:10px; transition:all 0.3s; border:1px solid rgba(100,100,255,0.2) }
|
||||
.toggle-track.on { background:rgba(68,136,255,0.5); border-color:#4488ff }
|
||||
.toggle-knob { position:absolute; width:16px; height:16px; left:2px; top:2px; background:#6666aa; border-radius:50%; transition:all 0.3s }
|
||||
.toggle-knob.on { left:18px; background:#4488ff }
|
||||
.toggle-btn { display:flex; align-items:center; gap:4px; padding:2px 8px; border-radius:4px; border:1px solid rgba(100,100,255,0.15); background:transparent; color:#8888aa; font-size:12px; font-family:inherit; cursor:pointer; transition:all 0.2s }
|
||||
.toggle-btn:hover { background:rgba(68,136,255,0.15); color:#fff }
|
||||
.toggle-btn.on { background:rgba(68,136,255,0.3); color:#4488ff; border-color:#4488ff }
|
||||
.label-text { color:#8888aa; font-size:12px }
|
||||
.loading-spinner { width:32px; height:32px; border:3px solid rgba(68,136,255,0.15); border-top:3px solid #4488ff; border-radius:50%; animation:spin 0.8s linear infinite }
|
||||
@keyframes spin { to { transform:rotate(360deg) } }
|
||||
|
||||
.sidebar-subnav { display:flex; gap:0; border-bottom:1px solid var(--border-color); margin-bottom:10px }
|
||||
.sidebar-subnav span { padding:6px 12px; font-size:12px; cursor:pointer; color:var(--text-muted); border-bottom:2px solid transparent; transition:all .15s }
|
||||
.sidebar-subnav span:hover { color:var(--text-primary) }
|
||||
@ -298,6 +313,7 @@ let state = {
|
||||
selectedSection: 'core',
|
||||
messages: [],
|
||||
chatLoading: false,
|
||||
chatStage: '',
|
||||
healthResult: null,
|
||||
starmapInit: false,
|
||||
starmapLoading: false,
|
||||
@ -412,7 +428,7 @@ function switchTab(n) {
|
||||
|
||||
// ===== Tab Render Dispatch =====
|
||||
async function renderAll() {
|
||||
try { var s = await api('/status'); state.status = s } catch(e) {}
|
||||
try { var s = await api('/status'); state.status = s; state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null } catch(e) {}
|
||||
try { state.kernel = await api('/kernel') } catch(e) {}
|
||||
try {
|
||||
var s = await api('/settings');
|
||||
@ -434,6 +450,30 @@ async function renderAll() {
|
||||
applyI18n();
|
||||
}
|
||||
|
||||
function fmtUptime(ms) {
|
||||
var s = Math.floor(ms / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
var m = Math.floor(s / 60); s = s % 60;
|
||||
if (m < 60) return m + 'm ' + s + 's';
|
||||
var h = Math.floor(m / 60); m = m % 60;
|
||||
return h + 'h ' + m + 'm ' + s + 's';
|
||||
}
|
||||
|
||||
var uptimeTick = null;
|
||||
function startUptimeTicker() {
|
||||
if (uptimeTick) clearInterval(uptimeTick);
|
||||
uptimeTick = setInterval(function() {
|
||||
var el = document.querySelector('#uptime-val');
|
||||
if (el && state.startedAt) {
|
||||
var now = Date.now();
|
||||
el.textContent = fmtUptime(now - state.startedAt);
|
||||
} else if (!state.startedAt) {
|
||||
var el2 = document.querySelector('#uptime-val');
|
||||
if (el2) el2.textContent = '-';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ===== Overview =====
|
||||
function statCard(l, v) {
|
||||
return '<div class="card stat-card"><div class="stat-value">' + v + '</div><div class="stat-label">' + l + '</div></div>';
|
||||
@ -444,7 +484,7 @@ function renderOverview() {
|
||||
var k = state.kernel;
|
||||
var html = '<div class="grid-4">'
|
||||
+ statCard(__('运行状态','Status'), s.status || 'unknown', 'running')
|
||||
+ statCard(__('运行时间','Uptime'), s.uptime || '-', 'uptime')
|
||||
+ statCard(__('运行时间','Uptime'), '<span id="uptime-val">' + (state.startedAt ? fmtUptime(Date.now() - state.startedAt) : '-') + '</span>', 'uptime')
|
||||
+ statCard(__('插件','Plugins'), (k?.plugins || []).length || 0, 'plugin')
|
||||
+ statCard(__('版本','Version'), s.version || '0.1.0', 'version')
|
||||
+ '</div>';
|
||||
@ -471,59 +511,15 @@ function renderOverview() {
|
||||
}
|
||||
|
||||
// ===== Chat =====
|
||||
function renderChat() {
|
||||
var _chatLayoutBuilt = false;
|
||||
|
||||
function buildChatLayout() {
|
||||
var cont = document.getElementById('tab-chat');
|
||||
var msgs = state.messages;
|
||||
var k = state.kernel;
|
||||
var k = state.kernel || {};
|
||||
var html = '<div class="chat-layout"><div class="chat-main">';
|
||||
html += '<div class="card"><h2>' + __('对话','Chat') + '</h2><div class="chat-messages" id="chat-msgs">';
|
||||
if (msgs.length === 0) {
|
||||
html += '<div class="card"><h2>' + __('对话','Chat') + ' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:' + (state.chatLoading ? 'inline' : 'none') + '">' + escHtml(state.chatStage || '') + '</span></h2><div class="chat-messages" id="chat-msgs">';
|
||||
if (state.messages.length === 0) {
|
||||
html += '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>';
|
||||
} else {
|
||||
msgs.forEach(function(m, i) {
|
||||
var role = m.role || 'user';
|
||||
var c = m.content || '';
|
||||
// Format content based on role
|
||||
if (role === 'assistant') {
|
||||
if (typeof marked !== 'undefined') { c = marked.parse(c) } else { c = '<pre>' + escHtml(c) + '</pre>' }
|
||||
} else if (role === 'system') {
|
||||
c = escHtml(c);
|
||||
} else {
|
||||
c = escHtml(c);
|
||||
}
|
||||
// Reasoning section
|
||||
var rc = '';
|
||||
if (m.reasoning_content) {
|
||||
var rcBody = (typeof marked !== 'undefined' ? marked.parse(m.reasoning_content) : escHtml(m.reasoning_content));
|
||||
rc = '<div class="reasoning">'
|
||||
+ '<div class="reasoning-title" onclick="var n=this.nextElementSibling;n.style.display=n.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'' + __('收起思考','Collapse') + '\'?\'' + __('展开思考','Expand') + '\':\'' + __('收起思考','Collapse') + '\'">' + __('收起思考','Collapse') + '</div>'
|
||||
+ '<div class="reasoning-body" style="display:none">' + rcBody + '</div></div>';
|
||||
}
|
||||
// Tool calls section
|
||||
var tcs = '';
|
||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||
m.tool_calls.forEach(function(tc) {
|
||||
var argsStr = typeof tc.args === 'object' ? JSON.stringify(tc.args, null, 1) : (tc.args || '');
|
||||
var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1).substring(0, 200) : String(tc.result).substring(0, 200)) : '';
|
||||
var statusIcon = tc.status === 'denied' ? '⛔' : '🔧';
|
||||
tcs += '<div class="tool-call">'
|
||||
+ '<div><span class="tc-name">' + statusIcon + ' ' + escHtml(tc.tool || tc.name || '') + '</span></div>'
|
||||
+ (argsStr && argsStr !== '{}' ? '<div class="tc-args">' + escHtml(argsStr) + '</div>' : '')
|
||||
+ (resultStr ? '<div class="tc-result">→ ' + escHtml(resultStr) + '</div>' : '')
|
||||
+ '</div>';
|
||||
});
|
||||
}
|
||||
// Build bubble content
|
||||
var body = rc + tcs + '<div class="text">' + c + '</div>';
|
||||
if (role === 'system') {
|
||||
html += '<div class="msg msg-system"><div class="msg-bubble">' + body + '</div></div>';
|
||||
} else {
|
||||
html += '<div class="msg msg-' + role + '">'
|
||||
+ '<div class="msg-avatar">' + (role === 'user' ? 'U' : 'A') + '</div>'
|
||||
+ '<div class="msg-content"><div class="msg-bubble">' + body + '</div></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
html += '</div>'
|
||||
+ '<div class="chat-input-row">'
|
||||
@ -532,7 +528,7 @@ function renderChat() {
|
||||
+ '</div></div>';
|
||||
html += '</div><div class="chat-sidebar">'
|
||||
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('星图','Star Map') + '</h2>'
|
||||
+ '<div id="sm-container-chat" style="height:160px"><p style="color:var(--text-muted);padding:12px;text-align:center;font-size:11px">' + __('加载中..','Loading..') + '</p></div></div>'
|
||||
+ '<div id="sm-container-chat" style="height:160px;display:flex;align-items:center;justify-content:center"><div class="loading-spinner"></div></div></div>'
|
||||
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('终端','Terminal') + ' <span id="term-count-badge" class="badge badge-blue">0</span></h2>'
|
||||
+ '<div id="term-list" style="max-height:160px;overflow-y:auto;font-size:11px"></div></div>'
|
||||
+ '<div class="card" style="padding:12px"><h2 style="font-size:13px;margin-bottom:8px">' + __('命令历史','Command History') + ' <span id="cmd-count-badge" class="badge badge-blue">0</span></h2>'
|
||||
@ -569,24 +565,86 @@ function renderChat() {
|
||||
+ '<button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' + __('创建','Create') + '</button>'
|
||||
+ '</div></div></div></div></div>';
|
||||
cont.innerHTML = html;
|
||||
var el = document.getElementById('chat-msgs');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
renderChatStarmap();
|
||||
renderTerminals();
|
||||
renderCmdHistory();
|
||||
_chatLayoutBuilt = true;
|
||||
}
|
||||
|
||||
function rerenderChat() { renderChat(); renderChatStarmap() }
|
||||
function renderChat() {
|
||||
if (!_chatLayoutBuilt) { buildChatLayout(); renderChatStarmap(); renderTerminals(); renderCmdHistory() }
|
||||
var msgsEl = document.getElementById('chat-msgs');
|
||||
if (!msgsEl) return;
|
||||
var msgs = state.messages;
|
||||
var html = '';
|
||||
if (msgs.length === 0) {
|
||||
html = '<div class="empty-state" style="flex:1;display:flex;align-items:center;justify-content:center"><p>' + __('开始对话以测试 Agent 回复','Start a conversation to test Agent replies') + '</p></div>';
|
||||
} else {
|
||||
msgs.forEach(function(m, i) {
|
||||
var role = m.role || 'user';
|
||||
var c = m.content || '';
|
||||
if (role === 'assistant') {
|
||||
if (typeof marked !== 'undefined') { c = marked.parse(c) } else { c = '<pre>' + escHtml(c) + '</pre>' }
|
||||
} else if (role === 'system') {
|
||||
c = escHtml(c);
|
||||
} else {
|
||||
c = escHtml(c);
|
||||
}
|
||||
var rc = '';
|
||||
if (m.reasoning_content) {
|
||||
var rcBody = (typeof marked !== 'undefined' ? marked.parse(m.reasoning_content) : escHtml(m.reasoning_content));
|
||||
rc = '<div class="reasoning">'
|
||||
+ '<div class="reasoning-title" onclick="var n=this.nextElementSibling;n.style.display=n.style.display===\'none\'?\'block\':\'none\';this.textContent=this.textContent===\'' + __('收起思考','Collapse') + '\'?\'' + __('展开思考','Expand') + '\':\'' + __('收起思考','Collapse') + '\'">' + __('收起思考','Collapse') + '</div>'
|
||||
+ '<div class="reasoning-body" style="display:none">' + rcBody + '</div></div>';
|
||||
}
|
||||
var tcs = '';
|
||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||
m.tool_calls.forEach(function(tc) {
|
||||
var argsStr = typeof tc.args === 'object' ? JSON.stringify(tc.args, null, 1) : (tc.args || '');
|
||||
var resultStr = tc.result ? (typeof tc.result === 'object' ? JSON.stringify(tc.result, null, 1).substring(0, 200) : String(tc.result).substring(0, 200)) : '';
|
||||
var statusIcon = tc.status === 'denied' ? '⛔' : '🔧';
|
||||
tcs += '<div class="tool-call">'
|
||||
+ '<div><span class="tc-name">' + statusIcon + ' ' + escHtml(tc.tool || tc.name || '') + '</span></div>'
|
||||
+ (argsStr && argsStr !== '{}' ? '<div class="tc-args">' + escHtml(argsStr) + '</div>' : '')
|
||||
+ (resultStr ? '<div class="tc-result">→ ' + escHtml(resultStr) + '</div>' : '')
|
||||
+ '</div>';
|
||||
});
|
||||
}
|
||||
var body = rc + tcs + '<div class="text">' + c + '</div>';
|
||||
if (role === 'system') {
|
||||
html += '<div class="msg msg-system"><div class="msg-bubble">' + body + '</div></div>';
|
||||
} else {
|
||||
html += '<div class="msg msg-' + role + '">'
|
||||
+ '<div class="msg-avatar">' + (role === 'user' ? 'U' : 'A') + '</div>'
|
||||
+ '<div class="msg-content"><div class="msg-bubble">' + body + '</div></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
msgsEl.innerHTML = html;
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
updateChatBadge();
|
||||
}
|
||||
|
||||
function updateChatBadge() {
|
||||
var badge = document.getElementById('chat-stage');
|
||||
if (!badge) return;
|
||||
badge.textContent = state.chatStage || '';
|
||||
badge.style.display = state.chatLoading ? 'inline' : 'none';
|
||||
}
|
||||
|
||||
function rerenderChat() { renderChat(); renderChatStarmap(); renderTerminals(); renderCmdHistory() }
|
||||
|
||||
function renderChatStarmap() {
|
||||
var cont = document.getElementById('sm-container-chat');
|
||||
if (!cont) return;
|
||||
if (window._THREE_FAILED || (!window.THREE && window._THREE_FAILED !== undefined)) {
|
||||
cont.innerHTML = '<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:11px">' + __('3D 星图不可用(CDN 加载失败)','Star map unavailable (CDN load failed)') + '</p>';
|
||||
state.starmapInit = true;
|
||||
state.starmapLoading = false;
|
||||
return;
|
||||
}
|
||||
if (!window.THREE) {
|
||||
if (!state.starmapLoading) {
|
||||
cont.innerHTML = '<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:12px">'
|
||||
+ __('Three.js 未加载','Three.js not loaded') + '</p>';
|
||||
state.starmapInit = true;
|
||||
}
|
||||
cont.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;padding:20px"><div class="loading-spinner"></div></div>';
|
||||
state.starmapInit = false;
|
||||
state.starmapLoading = false;
|
||||
return;
|
||||
}
|
||||
if (cont.querySelector('canvas')) {
|
||||
@ -600,6 +658,10 @@ function renderChatStarmap() {
|
||||
if (rect.width > 0) starmapRen.setSize(rect.width, Math.max(rect.height, 250));
|
||||
cont.appendChild(starmapRen.domElement);
|
||||
starmapRen.domElement.style.display = 'block';
|
||||
} else {
|
||||
// starmapRen was destroyed (e.g. re-render cycle), restart
|
||||
state.starmapInit = false;
|
||||
state.starmapLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -635,8 +697,7 @@ async function loadChatStarmapData() {
|
||||
}
|
||||
|
||||
function getStarmapBg() {
|
||||
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||
return isLight ? 0xf0f4f8 : 0x0a0a1a;
|
||||
return 0x0a0a1a;
|
||||
}
|
||||
|
||||
function initChatStarmap() {
|
||||
@ -651,15 +712,14 @@ function initChatStarmap() {
|
||||
starmapRen.domElement.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
var bg = getStarmapBg();
|
||||
starmapScene = new THREE.Scene();
|
||||
starmapScene.fog = new THREE.FogExp2(bg, 0.02);
|
||||
starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015);
|
||||
starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000);
|
||||
starmapCam.position.set(0, 20, 40);
|
||||
starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
starmapRen.setSize(w, h);
|
||||
starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
starmapRen.setClearColor(bg, 1);
|
||||
starmapRen.setClearColor(0x0a0a1a, 1);
|
||||
cont.innerHTML = '';
|
||||
cont.appendChild(starmapRen.domElement);
|
||||
starmapCtrl = new THREE.OrbitControls(starmapCam, starmapRen.domElement);
|
||||
@ -673,6 +733,7 @@ function initChatStarmap() {
|
||||
dl.position.set(50, 100, 50);
|
||||
starmapScene.add(dl);
|
||||
createStarField();
|
||||
createNebula();
|
||||
buildChatStarmapGraph();
|
||||
starmapRen.domElement.addEventListener('mousemove', onStarmapMove);
|
||||
starmapRen.domElement.addEventListener('click', onStarmapClick);
|
||||
@ -687,111 +748,154 @@ function buildChatStarmapGraph() {
|
||||
starmapNodeMeshes = [];
|
||||
starmapEdgeLines = [];
|
||||
if (starmapNodes.length === 0) return;
|
||||
var nMap = {};
|
||||
starmapNodes.forEach(function(n) { nMap[n.id] = n });
|
||||
var sorted = [...starmapNodes].sort(function(a, b) {
|
||||
// Calculate node degrees for leaf node detection
|
||||
var nodeDegs = {};
|
||||
starmapNodes.forEach(function(n) { nodeDegs[n.id] = 0 });
|
||||
starmapEdges.forEach(function(e) {
|
||||
nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1;
|
||||
nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1;
|
||||
});
|
||||
var nodeMap = {};
|
||||
starmapNodes.forEach(function(n) { nodeMap[n.id] = n });
|
||||
var sorted = starmapNodes.slice().sort(function(a, b) {
|
||||
return (b.mention_count || 0) - (a.mention_count || 0);
|
||||
});
|
||||
var mc = sorted.map(function(n) { return n.mention_count || 0 });
|
||||
var maxMc = Math.max(...mc, 1), minMc = Math.min(...mc, 0), rng = maxMc - minMc || 1;
|
||||
// Layout positions
|
||||
var pos = {};
|
||||
var baseR = 10, maxR = 50;
|
||||
var baseR = 15, maxR = 80;
|
||||
var total = sorted.length;
|
||||
var acc = 0;
|
||||
sorted.forEach(function(n, i) {
|
||||
var m = n.mention_count || 0, mn = rng > 0 ? (m - minMc) / rng : 0;
|
||||
var rad = baseR + mn * (maxR - baseR);
|
||||
var baseStep = (Math.PI * 2) / sorted.length;
|
||||
var step = baseStep + mn * baseStep * 2;
|
||||
var angle = acc + step / 2;
|
||||
acc += step;
|
||||
var radius = baseR + mn * (maxR - baseR);
|
||||
var baseStep = (Math.PI * 2) / total;
|
||||
var extra = mn * baseStep * 2;
|
||||
var angle = acc + extra / 2;
|
||||
acc += baseStep + extra;
|
||||
pos[n.id] = {
|
||||
x: rad * Math.cos(angle),
|
||||
y: (Math.random() - 0.5) * (5 + mn * 15),
|
||||
z: rad * Math.sin(angle),
|
||||
mn: n,
|
||||
rad: rad
|
||||
x: radius * Math.cos(angle),
|
||||
y: (Math.random() - 0.5) * (10 + mn * 20),
|
||||
z: radius * Math.sin(angle),
|
||||
mn: mn,
|
||||
rad: radius
|
||||
};
|
||||
});
|
||||
// Leaf nodes (degree 1) reposition near parent
|
||||
sorted.forEach(function(n) {
|
||||
var deg = nodeDegs[n.id] || 0;
|
||||
if (deg !== 1) return;
|
||||
var edge = starmapEdges.find(function(e) { return e.source_id === n.id || e.target_id === n.id });
|
||||
if (!edge) return;
|
||||
var parentId = edge.source_id === n.id ? edge.target_id : edge.source_id;
|
||||
if (!pos[parentId]) return;
|
||||
var pp = pos[parentId];
|
||||
var m = n.mention_count || 0, mn = rng > 0 ? (m - minMc) / rng : 0;
|
||||
var off = 6 + mn * 8 + Math.random() * 4;
|
||||
var a2 = Math.random() * Math.PI * 2;
|
||||
pos[n.id] = {
|
||||
x: pp.x + off * Math.cos(a2),
|
||||
y: pp.y + (Math.random() - 0.5) * (4 + mn * 6),
|
||||
z: pp.z + off * Math.sin(a2),
|
||||
mn: mn,
|
||||
rad: off
|
||||
};
|
||||
});
|
||||
// Force-directed simulation
|
||||
for (var it = 0; it < 50; it++) {
|
||||
Object.keys(pos).forEach(function(i) {
|
||||
Object.keys(pos).forEach(function(j) {
|
||||
if (i >= j) return;
|
||||
var a = pos[i], b = pos[j];
|
||||
var ids = Object.keys(pos);
|
||||
// Repulsion
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
for (var j = i + 1; j < ids.length; j++) {
|
||||
var a = pos[ids[i]], b = pos[ids[j]];
|
||||
var dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z, d = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||
var f = (0.5 + (a.mn + b.mn) * 0.5);
|
||||
if (d < 20) {
|
||||
var force = (0.08 * f) / Math.max(d, 0.5);
|
||||
var rf = 0.5 + (a.mn + b.mn) * 0.5;
|
||||
if (d < 25) {
|
||||
var force = (0.06 * rf) / Math.max(d, 0.5);
|
||||
a.x += dx / d * force; a.y += dy / d * force; a.z += dz / d * force;
|
||||
b.x -= dx / d * force; b.y -= dy / d * force; b.z -= dz / d * force;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// Attraction along edges
|
||||
starmapEdges.forEach(function(e) {
|
||||
var a = pos[e.source_id], b = pos[e.target_id];
|
||||
if (!a || !b) return;
|
||||
var dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z, d = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||
if (d > 15) {
|
||||
var f = 0.05 * Math.max(0.3, 1 - (a.mn + b.mn) * 0.3);
|
||||
a.x += dx / d * f; a.y += dy / d * f; a.z += dz / d * f;
|
||||
b.x -= dx / d * f; b.y -= dy / d * f; b.z -= dz / d * f;
|
||||
var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3);
|
||||
if (d > 20) {
|
||||
var force = 0.04 * af;
|
||||
a.x += dx / d * force; a.y += dy / d * force; a.z += dz / d * force;
|
||||
b.x -= dx / d * force; b.y -= dy / d * force; b.z -= dz / d * force;
|
||||
}
|
||||
});
|
||||
Object.keys(pos).forEach(function(i) {
|
||||
var p = pos[i];
|
||||
var d = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z);
|
||||
// Centering constraint
|
||||
ids.forEach(function(id) {
|
||||
var p = pos[id];
|
||||
var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z);
|
||||
var maxA = maxR * 1.5;
|
||||
if (d > maxA) {
|
||||
var s = maxA / d;
|
||||
p.x *= s; p.y *= s; p.z *= s;
|
||||
}
|
||||
if (dist > maxA) { var s = maxA / dist; p.x *= s; p.y *= s; p.z *= s }
|
||||
});
|
||||
}
|
||||
// Create nodes
|
||||
starmapNodes.forEach(function(n) {
|
||||
var p = pos[n.id];
|
||||
if (!p) return;
|
||||
var mn = n.mention_count || 0, mnr = rng > 0 ? (mn - minMc) / rng : 0;
|
||||
var rad = 0.4 + mnr * 1.5;
|
||||
var rad = 0.5 + mnr * 2.0;
|
||||
var col = smTypeColors[n.type] || 0xcccccc;
|
||||
var ei = 0.3 + mnr * 0.7;
|
||||
var g = new THREE.SphereGeometry(rad, 16, 12);
|
||||
var mat = new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: ei });
|
||||
var mat = new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: ei, shininess: 30 });
|
||||
var mesh = new THREE.Mesh(g, mat);
|
||||
mesh.position.set(p.x, p.y, p.z);
|
||||
mesh.userData.nodeData = n;
|
||||
mesh.userData.nodeId = n.id;
|
||||
starmapScene.add(mesh);
|
||||
starmapNodeMeshes.push(mesh);
|
||||
// label sprite
|
||||
mesh.userData.baseEmissive = ei;
|
||||
// Glow sphere
|
||||
var gr = rad * 1.2 + mnr * 0.5;
|
||||
var gg = new THREE.SphereGeometry(gr, 16, 12);
|
||||
var gm = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.12 + mnr * 0.08, side: THREE.BackSide, blending: THREE.AdditiveBlending });
|
||||
var gs = new THREE.Mesh(gg, gm);
|
||||
mesh.add(gs);
|
||||
mesh.userData.glowSphere = gs;
|
||||
// Label sprite
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||||
ctx.fillRect(0, 0, 256, 64);
|
||||
ctx.font = 'Bold 28px Arial';
|
||||
ctx.clearRect(0, 0, 256, 64);
|
||||
ctx.font = 'Bold 24px Courier New';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.fillText(n.name || n.id, 128, 34);
|
||||
ctx.shadowColor = '#aaccff';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText((n.name || n.id).substring(0, 12), 128, 32);
|
||||
var tex = new THREE.CanvasTexture(canvas);
|
||||
var spMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
|
||||
tex.needsUpdate = true;
|
||||
var spMat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.9, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending });
|
||||
var sprite = new THREE.Sprite(spMat);
|
||||
sprite.position.set(p.x, p.y + rad + 1.5, p.z);
|
||||
sprite.scale.set(6, 1.5, 1);
|
||||
starmapScene.add(sprite);
|
||||
starmapNodeMeshes.push(sprite);
|
||||
sprite.scale.set(8, 2, 1);
|
||||
sprite.position.y = rad + 2;
|
||||
mesh.add(sprite);
|
||||
starmapScene.add(mesh);
|
||||
starmapNodeMeshes.push(mesh);
|
||||
});
|
||||
// Create edges
|
||||
starmapEdges.forEach(function(e) {
|
||||
var a = pos[e.source_id], b = pos[e.target_id];
|
||||
if (!a || !b) return;
|
||||
var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466;
|
||||
var pts = [
|
||||
new THREE.Vector3(a.x, a.y, a.z),
|
||||
new THREE.Vector3(b.x, b.y, b.z)
|
||||
];
|
||||
var col = smEdgeColors[e.type] || 0x888888;
|
||||
var geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
var mat = new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.35 });
|
||||
var mat = new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.4 });
|
||||
var line = new THREE.Line(geo, mat);
|
||||
line.userData = { edgeId: e.id, edgeData: e };
|
||||
starmapScene.add(line);
|
||||
starmapEdgeLines.push(line);
|
||||
});
|
||||
@ -806,12 +910,17 @@ async function sendChat() {
|
||||
inp.value = '';
|
||||
rerenderChat();
|
||||
state.chatLoading = true;
|
||||
state.chatStage = __('等待AI回复...','Waiting for AI...');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
btn.textContent = '';
|
||||
rerenderChat();
|
||||
try {
|
||||
var r = await api('/chat', { method: 'POST', body: JSON.stringify({ message: text }) });
|
||||
state.chatStage = '';
|
||||
var last = state.messages[state.messages.length - 1];
|
||||
console.log('[sendChat] POST returned, last msg:', last ? {role:last.role, _streaming:last._streaming, _final:last._final, tool_calls:last.tool_calls?.length, content_len:last.content?.length} : null);
|
||||
if (last && last.role === 'assistant' && last._streaming) {
|
||||
console.log('[sendChat] updating existing streaming msg, tool_calls before:', last.tool_calls?.length);
|
||||
last.content = r.response || __('(无响应)','(no response)');
|
||||
last.reasoning_content = r.reasoning_content || '';
|
||||
last._final = true;
|
||||
@ -832,8 +941,10 @@ async function sendChat() {
|
||||
toast(__('请求失败: ','Request failed: ') + e.message, true);
|
||||
} finally {
|
||||
state.chatLoading = false;
|
||||
state.chatStage = '';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送';
|
||||
btn.textContent = __('发送','Send');
|
||||
rerenderChat();
|
||||
}
|
||||
}
|
||||
|
||||
@ -996,23 +1107,26 @@ function renderCmdHistory() {
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (state.eventSource) state.eventSource.close();
|
||||
if (state.eventSource) { console.log('[SSE] closing old connection'); state.eventSource.close() }
|
||||
var es;
|
||||
try { es = new EventSource('/api/v1/chat/events'); state.eventSource = es } catch(ex) {}
|
||||
if (!es) return;
|
||||
try { es = new EventSource('/api/v1/chat/events', { withCredentials: true }); state.eventSource = es } catch(ex) { console.error('[SSE] create failed', ex); return }
|
||||
if (!es) { console.error('[SSE] es is null'); return }
|
||||
console.log('[SSE] connected');
|
||||
es.addEventListener('agent_output', function(e) {
|
||||
try {
|
||||
var ev = JSON.parse(e.data);
|
||||
var p = ev.payload || {};
|
||||
console.log('[SSE] agent_output received', p.content ? p.content.substring(0,50) : '(empty)');
|
||||
if (!p.content) return;
|
||||
state.chatStage = __('AI 回复中...','AI replying...');
|
||||
if (state.messages.length > 0 && state.messages[state.messages.length - 1].role === 'assistant' && !state.messages[state.messages.length - 1]._final) {
|
||||
state.messages[state.messages.length - 1].content += p.content;
|
||||
renderChat(); renderChatStarmap();
|
||||
rerenderChat();
|
||||
return;
|
||||
}
|
||||
state.messages.push({ role: 'assistant', content: p.content, _streaming: true });
|
||||
renderChat(); renderChatStarmap();
|
||||
} catch(ex) {}
|
||||
rerenderChat();
|
||||
} catch(ex) { console.error('[SSE] agent_output error', ex) }
|
||||
});
|
||||
es.addEventListener('reasoning', function(e) {
|
||||
try {
|
||||
@ -1021,8 +1135,9 @@ function connectSSE() {
|
||||
if (p.content && state.messages.length > 0) {
|
||||
var last = state.messages[state.messages.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
state.chatStage = __('AI 思考中...','AI thinking...');
|
||||
last.reasoning_content = (last.reasoning_content || '') + p.content;
|
||||
renderChat(); renderChatStarmap();
|
||||
rerenderChat();
|
||||
}
|
||||
}
|
||||
} catch(ex) {}
|
||||
@ -1031,23 +1146,62 @@ function connectSSE() {
|
||||
try {
|
||||
var ev = JSON.parse(e.data);
|
||||
var p = ev.payload || {};
|
||||
console.log('[SSE] tool_call', p);
|
||||
if (!p.tool) return;
|
||||
var last = state.messages.length > 0 ? state.messages[state.messages.length - 1] : null;
|
||||
if (last && last.role === 'assistant') {
|
||||
if (!last.tool_calls) last.tool_calls = [];
|
||||
last.tool_calls.push({
|
||||
tool: p.tool,
|
||||
name: p.tool,
|
||||
args: p.args || {},
|
||||
result: p.result || '',
|
||||
status: p.status || 'ok',
|
||||
plugin: p.plugin || ''
|
||||
});
|
||||
renderChat(); renderChatStarmap();
|
||||
if (!last || last.role !== 'assistant') {
|
||||
state.messages.push({ role: 'assistant', content: '', tool_calls: [], _streaming: true });
|
||||
last = state.messages[state.messages.length - 1];
|
||||
}
|
||||
if (!last.tool_calls) last.tool_calls = [];
|
||||
last.tool_calls.push({
|
||||
tool: p.tool,
|
||||
name: p.tool,
|
||||
args: p.args || {},
|
||||
result: p.result || '',
|
||||
status: p.status || 'ok',
|
||||
plugin: p.plugin || ''
|
||||
});
|
||||
state.chatStage = __('工具调用: ','Tool: ') + (p.tool || '');
|
||||
rerenderChat();
|
||||
} catch(ex) { console.error('[SSE] tool_call error', ex) }
|
||||
});
|
||||
es.addEventListener('tool_result', function(e) {
|
||||
try {
|
||||
var ev = JSON.parse(e.data);
|
||||
var p = ev.payload || {};
|
||||
state.chatStage = __('工具结果已返回','Tool result received');
|
||||
var badge = document.getElementById('chat-stage');
|
||||
if (badge) badge.textContent = state.chatStage;
|
||||
} catch(ex) {}
|
||||
});
|
||||
es.onerror = function() { setTimeout(connectSSE, 5000) };
|
||||
es.addEventListener('stage', function(e) {
|
||||
try {
|
||||
var ev = JSON.parse(e.data);
|
||||
var p = ev.payload || {};
|
||||
var phase = p.phase || '';
|
||||
var tool = p.tool || '';
|
||||
console.log('[SSE] stage event', phase, tool);
|
||||
if (phase === 'pre_action') {
|
||||
state.chatStage = __('AI 思考中...','AI thinking...');
|
||||
} else if (phase === 'before_toolcall') {
|
||||
state.chatStage = __('工具调用: ','Tool: ') + (tool || '');
|
||||
} else if (phase === 'before_output') {
|
||||
state.chatStage = __('生成回复中...','Generating response...');
|
||||
}
|
||||
var badge = document.getElementById('chat-stage');
|
||||
if (badge) { badge.textContent = state.chatStage || ''; badge.style.display = state.chatLoading ? 'inline' : 'none' }
|
||||
} catch(ex) { console.error('[SSE] stage error', ex) }
|
||||
});
|
||||
es.onopen = function() { console.log('[SSE] connection opened') };
|
||||
es.onerror = function(e) { console.error('[SSE] error', e); setTimeout(connectSSE, 5000) };
|
||||
// Periodically refresh sidebar data
|
||||
if (state._sidebarRefresh) clearInterval(state._sidebarRefresh);
|
||||
state._sidebarRefresh = setInterval(async function() {
|
||||
try { var td = await api('/terminals'); if (td && td.terminals) state.terminals = td.terminals } catch(e) {}
|
||||
try { var ch = await api('/cmd/history'); if (ch && ch.history) state.cmdHistory = ch.history } catch(e) {}
|
||||
renderTerminals(); renderCmdHistory();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ===== Plugins =====
|
||||
@ -1329,10 +1483,11 @@ function flyStarmapTo(nodeId, dur) {
|
||||
}
|
||||
|
||||
function onStarmapResize() {
|
||||
var cont = document.getElementById('starmap-container');
|
||||
if (!cont || !starmapRen || !starmapCam) return;
|
||||
if (!starmapRen || !starmapCam) return;
|
||||
var cont = starmapRen.domElement.parentElement;
|
||||
if (!cont) return;
|
||||
var rect = cont.getBoundingClientRect();
|
||||
var w = rect.width || 800, h = Math.max(rect.height || 500, 100);
|
||||
var w = rect.width || 800, h = Math.max(rect.height || 250, 100);
|
||||
if (w > 0 && h > 0) { starmapCam.aspect = w / h; starmapCam.updateProjectionMatrix(); starmapRen.setSize(w, h) }
|
||||
}
|
||||
|
||||
@ -1343,8 +1498,10 @@ function toggleStarmapAuto() {
|
||||
}
|
||||
|
||||
function resetStarmapCamera() {
|
||||
if (!starmapCam || !starmapCtrl || !starmapNodeMeshes) return;
|
||||
var maxD = 0;
|
||||
starmapNodeMeshes.forEach(function(m) { var d = m.position.length(); if (d > maxD) maxD = d });
|
||||
if (maxD < 1) maxD = 30;
|
||||
var td = Math.min(Math.max(maxD + 20, 30), 150);
|
||||
var sp = starmapCam.position.clone(), ep = new THREE.Vector3(td * 0.9, td * 0.6, td * 0.9);
|
||||
var st = starmapCtrl.target.clone(), t0 = Date.now();
|
||||
@ -1363,6 +1520,31 @@ function starmapAnimate() {
|
||||
if (starmapRen && starmapScene && starmapCam) starmapRen.render(starmapScene, starmapCam);
|
||||
}
|
||||
|
||||
function createNebula() {
|
||||
var nc = 500;
|
||||
var p = new Float32Array(nc * 3), cl = new Float32Array(nc * 3);
|
||||
for (var i = 0; i < nc; i++) {
|
||||
var i3 = i * 3;
|
||||
p[i3] = (Math.random() - 0.5) * 800;
|
||||
p[i3+1] = (Math.random() - 0.5) * 800;
|
||||
p[i3+2] = (Math.random() - 0.5) * 800;
|
||||
var ch = Math.random();
|
||||
if (ch < 0.33) {
|
||||
cl[i3]=0.5+Math.random()*0.3; cl[i3+1]=0.2+Math.random()*0.2; cl[i3+2]=0.7+Math.random()*0.3;
|
||||
} else if (ch < 0.66) {
|
||||
cl[i3]=0.2+Math.random()*0.2; cl[i3+1]=0.3+Math.random()*0.3; cl[i3+2]=0.8+Math.random()*0.2;
|
||||
} else {
|
||||
cl[i3]=0.7+Math.random()*0.3; cl[i3+1]=0.2+Math.random()*0.2; cl[i3+2]=0.5+Math.random()*0.3;
|
||||
}
|
||||
}
|
||||
var g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(p, 3));
|
||||
g.setAttribute('color', new THREE.BufferAttribute(cl, 3));
|
||||
var m = new THREE.PointsMaterial({ size: 8, vertexColors: true, transparent: true, opacity: 0.15, sizeAttenuation: true, blending: THREE.AdditiveBlending });
|
||||
var np = new THREE.Points(g, m);
|
||||
starmapScene.add(np);
|
||||
}
|
||||
|
||||
// ===== Settings =====
|
||||
function pluginDisplayName(p) {
|
||||
if (p === 'core') return __('核心', 'Core');
|
||||
@ -1664,7 +1846,7 @@ async function logout() {
|
||||
|
||||
renderConfigDisabled();
|
||||
connectSSE();
|
||||
(async function() { await loadChatHistory(); renderAll() })();
|
||||
(async function() { await loadChatHistory(); renderAll(); startUptimeTicker() })();
|
||||
setInterval(renderAll, 15000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
@ -131,12 +132,35 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
|
||||
sessions: make(map[string]time.Time),
|
||||
termStates: make(map[string]*termState),
|
||||
}
|
||||
h.loadChatHistory()
|
||||
if evBus != nil {
|
||||
go h.trackToolEvents()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) loadChatHistory() {
|
||||
if h.cfgReg == nil {
|
||||
return
|
||||
}
|
||||
ps := h.cfgReg.PluginConfig("webui")
|
||||
v, err := ps.Get("chathistory")
|
||||
if err != nil || v == nil {
|
||||
return
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return
|
||||
}
|
||||
var msgs []ChatMsg
|
||||
if err := json.Unmarshal([]byte(s), &msgs); err != nil {
|
||||
return
|
||||
}
|
||||
h.chatMu.Lock()
|
||||
h.chatHistory = msgs
|
||||
h.chatMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) trackToolEvents() {
|
||||
h.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) {
|
||||
h.handleToolEvent(ev)
|
||||
@ -408,7 +432,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
agents := h.supervisor.ListAgents()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "running",
|
||||
"uptime": time.Since(h.startTime).String(),
|
||||
"uptime": time.Since(h.startTime).Round(time.Second).String(),
|
||||
"agents": len(agents),
|
||||
"version": meta.Version,
|
||||
"startedAt": h.startTime,
|
||||
@ -843,11 +867,17 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) addChatMsg(msg ChatMsg) {
|
||||
h.chatMu.Lock()
|
||||
defer h.chatMu.Unlock()
|
||||
h.chatHistory = append(h.chatHistory, msg)
|
||||
if len(h.chatHistory) > maxChatHistory {
|
||||
h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:]
|
||||
}
|
||||
// persist to webui config table as compact JSON
|
||||
if h.cfgReg != nil {
|
||||
ps := h.cfgReg.PluginConfig("webui")
|
||||
b, _ := json.Marshal(h.chatHistory)
|
||||
ps.Set("chathistory", string(b))
|
||||
}
|
||||
h.chatMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
@ -944,23 +974,36 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(writeCh)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[SSE] writer panic: %v", r)
|
||||
}
|
||||
}()
|
||||
for line := range writeCh {
|
||||
fmt.Fprintf(w, "%s\n", line)
|
||||
flusher.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call"}
|
||||
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain"}
|
||||
var unsubs []func()
|
||||
for _, t := range subTypes {
|
||||
t2 := t
|
||||
_ = h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) {
|
||||
unsub := h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) {
|
||||
data, _ := json.Marshal(evt)
|
||||
select {
|
||||
case writeCh <- fmt.Sprintf("event: %s\ndata: %s", evt.Type, string(data)):
|
||||
case writeCh <- fmt.Sprintf("event: %s\ndata: %s\n", evt.Type, string(data)):
|
||||
default:
|
||||
log.Printf("[SSE] DROPPED event %s (writeCh full, len=%d)", evt.Type, len(writeCh))
|
||||
}
|
||||
})
|
||||
unsubs = append(unsubs, unsub)
|
||||
}
|
||||
defer func() {
|
||||
for _, unsub := range unsubs {
|
||||
unsub()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
|
||||
@ -155,11 +155,30 @@ func (p *Plugin) ensureAuthBootstrap(s *sdk.PluginSDK) {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"})
|
||||
p.ensureAuthBootstrap(s)
|
||||
|
||||
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
|
||||
p.evBus.Publish(&events.Event{Type: events.EventStage, Payload: map[string]interface{}{"phase": "pre_action", "message": "thinking"}})
|
||||
return nil
|
||||
})
|
||||
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||||
tool := ""
|
||||
if len(ctx.ToolCalls) > 0 {
|
||||
tool = ctx.ToolCalls[0].Name
|
||||
}
|
||||
p.evBus.Publish(&events.Event{Type: events.EventStage, Payload: map[string]interface{}{"phase": "before_toolcall", "tool": tool, "message": "tool:" + tool}})
|
||||
return nil
|
||||
})
|
||||
s.RegisterStage(sdk.StageBeforeOutput, func(ctx *sdk.StageContext) error {
|
||||
p.evBus.Publish(&events.Event{Type: events.EventStage, Payload: map[string]interface{}{"phase": "before_output", "message": "output"}})
|
||||
return nil
|
||||
})
|
||||
|
||||
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider, p.providerMgr, p.baseAPIKey)
|
||||
p.handler = h
|
||||
h.RegisterRoutes(p.mux)
|
||||
|
||||
@ -38,6 +38,7 @@ type IOInjector = pubsdk.IOInjector
|
||||
type ToolRegistrar = pubsdk.ToolRegistrar
|
||||
type StageRegistrar = pubsdk.StageRegistrar
|
||||
type APIRegistrar = pubsdk.APIRegistrar
|
||||
type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
|
||||
|
||||
type PluginSDK struct {
|
||||
*pubsdk.PluginSDK
|
||||
@ -68,23 +69,36 @@ func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI,
|
||||
textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI,
|
||||
sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar,
|
||||
) *PluginSDK {
|
||||
base := pubsdk.New(name, sett, regTool, regStage, regAPI)
|
||||
if iom != nil {
|
||||
base.SetIOInjector(ioAdapter{iom: iom})
|
||||
// SDKConfig holds all dependencies for creating a PluginSDK.
|
||||
type SDKConfig struct {
|
||||
IOManager *agentIO.IOManager
|
||||
EventBus *events.Bus
|
||||
Memory MemoryAPI
|
||||
TextMemory TextMemoryAPI
|
||||
DocMemory DocMemoryAPI
|
||||
Knowledge KnowledgeAPI
|
||||
LLM LLMAPI
|
||||
Settings SettingsAPI
|
||||
RegTool ToolRegistrar
|
||||
RegStage StageRegistrar
|
||||
RegAPI APIRegistrar
|
||||
RegOutput OutputChannelRegistrar
|
||||
}
|
||||
|
||||
func New(name string, cfg SDKConfig) *PluginSDK {
|
||||
base := pubsdk.New(name, cfg.Settings, cfg.RegTool, cfg.RegStage, cfg.RegAPI, cfg.RegOutput)
|
||||
if cfg.IOManager != nil {
|
||||
base.SetIOInjector(ioAdapter{iom: cfg.IOManager})
|
||||
}
|
||||
base.SetMemoryAPI(mem)
|
||||
base.SetTextMemoryAPI(textMem)
|
||||
base.SetDocMemoryAPI(docMem)
|
||||
base.SetKnowledgeAPI(know)
|
||||
base.SetLLMAPI(llm)
|
||||
base.SetMemoryAPI(cfg.Memory)
|
||||
base.SetTextMemoryAPI(cfg.TextMemory)
|
||||
base.SetDocMemoryAPI(cfg.DocMemory)
|
||||
base.SetKnowledgeAPI(cfg.Knowledge)
|
||||
base.SetLLMAPI(cfg.LLM)
|
||||
return &PluginSDK{
|
||||
PluginSDK: base,
|
||||
iom: iom,
|
||||
eventBus: eventBus,
|
||||
iom: cfg.IOManager,
|
||||
eventBus: cfg.EventBus,
|
||||
logger: log.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user