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:
JianFeeeee
2026-08-24 23:34:48 +08:00
parent 121a2b9ace
commit ece06b0375
4 changed files with 247 additions and 33 deletions

View File

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

View File

@ -3246,20 +3246,20 @@ background:
}
function renderReasoningCard(text, isStreaming) {
var body = renderMd(text);
var preview =
typeof marked !== "undefined"
? text.replace(/[\s\n]+/g, " ").slice(0, 60)
: escHtml(text).replace(/<[^>]+>/g, " ").slice(0, 60);
return (
'<div class="reasoning-card">' +
'<div class="reasoning-card' + (isStreaming ? " rc-streaming" : "") + '">' +
'<div class="reasoning-head" onclick="toggleReasoning(this)">' +
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rc-ico"><path d="M9 3a2 2 0 0 0-2 2v2a2 2 0 0 1-2 2H3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a2 2 0 0 1-2-2V3a2 2 0 0 0-2-2H9zM12 8v4m0 4h.01"/></svg>' +
'<span class="rc-title">' + (isStreaming ? __("思考中...", "Thinking...") : __("思考", "Thinking")) + '</span>' +
'<span class="rc-chev">▾</span></div>' +
'<div class="reasoning-body" style="display:' + (isStreaming ? "block" : "none") + '">' +
'<div class="reasoning-content">' + body + '</div>' +
(isStreaming ? '<div class="reasoning-sweep"></div>' : "") +
(isStreaming
? '<div class="reasoning-preview">' + escHtml(preview) + '</div><div class="reasoning-sweep"></div>'
: '<div class="reasoning-content">' + renderMd(text) + '</div>') +
'</div></div>'
);
}