mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
feat(cli): streaming process output with npm-style spinner
CLI 对话现在像 npm 安装一样先显示 braille 加载动画,然后逐步吐出
推理内容和工具调用状态,最后输出最终响应。
协议扩展(JSON 行,向后兼容):
- {"type":"reasoning","content":...} 推理过程帧
- {"type":"tool_call","tool":...,"status":...,"result":...} 工具调用帧
- response / error 仍为终结帧,语义不变
服务端(internal/plugins/cli):
- handleChat: 通过 SDK 订阅 EventReasoning/EventToolCall(按 channel=="cli"
过滤),InjectTextSync 阻塞期间实时转发事件到 socket;connWriter 互斥
保护并发写。纯插件层实现,不触碰内核。
- 不订阅 EventAgentOutput:内核先写 ResponseCh 再 publish 该事件,
订阅会导致响应重复。
客户端(cmd/waiter):
- startSpinner: npm 风格 braille 转圈(80ms),幂等 stop(),非 TTY 自动禁用
- SendChatStream: 循环读帧直至终结帧,onEvent 回调渲染过程帧
- printServerOutput: reasoning 灰色 · 前缀;tool_call ✔/✘ 状态行 + 结果预览
- 交互模式发送后自动起 spinner,首帧到达即停;oneshot 同理
- 向后兼容旧服务器(无类型行直接作为最终输出)
端到端验证:本地 homed 测试实例 + llmsproxy,oneshot 与交互模式均正确
渲染 推理→工具调用→最终响应 完整链路。
另外修正 dashboard.html renderReasoningCard 流式态使用 preview 结构
(与 GUI 渲染器一致,配合此前 renderChatStreamChunk 增量更新)。
This commit is contained in:
@ -19,6 +19,11 @@ import (
|
||||
// DefaultSocket 由 main.go 在 Load() 前设置,覆盖默认 socket 路径。
|
||||
var DefaultSocket string
|
||||
|
||||
const (
|
||||
cliSource = "cli"
|
||||
cliChannel = "cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugin.RegisterPluginMeta("cli", "CLI", "CLI")
|
||||
plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -155,19 +160,72 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
|
||||
}
|
||||
}
|
||||
|
||||
resp := s.InjectTextSync("cli", "cli", line)
|
||||
if resp != nil {
|
||||
content, _ := resp.Payload["content"].(string)
|
||||
writeLine(conn, map[string]interface{}{
|
||||
"type": "response",
|
||||
"content": content,
|
||||
})
|
||||
} else {
|
||||
writeLine(conn, map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": "agent is not available",
|
||||
})
|
||||
p.handleChat(&connWriter{conn: conn}, line, s)
|
||||
}
|
||||
}
|
||||
|
||||
// connWriter 为单条连接提供互斥保护的 JSON 行写入。
|
||||
// 对话过程中事件订阅回调运行在事件总线的发布 goroutine 上,
|
||||
// 与主循环写最终响应并发,因此写入必须串行化。
|
||||
type connWriter struct {
|
||||
conn net.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (w *connWriter) writeLine(v interface{}) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data = append(data, '\n')
|
||||
w.mu.Lock()
|
||||
w.conn.Write(data)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// handleChat 处理一条对话消息:订阅内核的推理/工具调用事件并实时
|
||||
// 转发给客户端(流式过程输出),InjectTextSync 返回后写出最终响应。
|
||||
// 仅插件层改动:通过 SDK 订阅事件,不触碰内核。
|
||||
func (p *Plugin) handleChat(w *connWriter, line string, s *sdk.PluginSDK) {
|
||||
unsubReasoning := s.Subscribe(sdk.EventReasoning, func(evt *sdk.Event) {
|
||||
if ch, _ := evt.Payload["channel"].(string); ch != cliChannel {
|
||||
return
|
||||
}
|
||||
content, _ := evt.Payload["content"].(string)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
w.writeLine(map[string]interface{}{"type": "reasoning", "content": content})
|
||||
})
|
||||
unsubToolCall := s.Subscribe(sdk.EventToolCall, func(evt *sdk.Event) {
|
||||
if ch, _ := evt.Payload["channel"].(string); ch != cliChannel {
|
||||
return
|
||||
}
|
||||
tool, _ := evt.Payload["tool"].(string)
|
||||
status, _ := evt.Payload["status"].(string)
|
||||
result, _ := evt.Payload["result"].(string)
|
||||
w.writeLine(map[string]interface{}{
|
||||
"type": "tool_call",
|
||||
"tool": tool,
|
||||
"status": status,
|
||||
"result": truncateOneLine(result, 160),
|
||||
})
|
||||
})
|
||||
defer unsubReasoning()
|
||||
defer unsubToolCall()
|
||||
|
||||
resp := s.InjectTextSync(cliSource, cliChannel, line)
|
||||
if resp != nil {
|
||||
content, _ := resp.Payload["content"].(string)
|
||||
w.writeLine(map[string]interface{}{
|
||||
"type": "response",
|
||||
"content": content,
|
||||
})
|
||||
} else {
|
||||
w.writeLine(map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": "agent is not available",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -560,6 +618,16 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) {
|
||||
|
||||
// ======== helpers ========
|
||||
|
||||
// truncateOneLine 将多行文本压成单行并按 rune 截断,用于事件结果预览。
|
||||
func truncateOneLine(s string, max int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
r := []rune(s)
|
||||
if len(r) > max {
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func writeLine(conn net.Conn, v interface{}) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user