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:
root
2026-07-16 12:11:16 +08:00
parent fa91b24460
commit 7f28b997e6
45 changed files with 3292 additions and 423 deletions

View File

@ -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 "channelcontent 不能为空"
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
}

View File

@ -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))
}
}

View File

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

View 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
}

View File

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

View File

@ -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)
}

View File

@ -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 格式说明
输出通道可多次调用,长消息应当分多次发出而不是一口气发完。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。

View File

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

View File

@ -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
View 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
}

View File

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

View File

@ -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
View 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
}

View File

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

View File

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

View File

@ -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)
}
}
}

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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 密钥)",

View File

@ -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",

View File

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

View File

@ -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",

View File

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

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "最大定时时长",

View File

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

View File

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

View File

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

View File

@ -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(),
}
}