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 cbeebc1616
commit e822293c85
4 changed files with 247 additions and 33 deletions

View File

@ -61,14 +61,41 @@ func (s *State) Send(line string) error {
}
func (s *State) SendChat(msg string) (string, error) {
return s.SendChatStream(msg, nil)
}
// SendChatStream 发送一条对话消息并循环读取响应行直至终结帧。
// onEvent 回调在每收到一个过程帧(reasoning/tool_call)时被调用,
// 可为 nil;返回值为最终响应内容或错误。
func (s *State) SendChatStream(msg string, onEvent func(respLine)) (string, error) {
if err := s.Send(msg); err != nil {
return "", err
}
line, err := s.readLine()
if err != nil {
return "", err
for {
line, err := s.readLine()
if err != nil {
return "", err
}
rl := parseRespLineStruct(line)
switch rl.Type {
case "response":
return rl.Content, nil
case "error":
if rl.Error == "" {
rl.Error = line
}
return "", fmt.Errorf("%s", rl.Error)
default:
// 过程帧:reasoning / tool_call / 旧版服务器的普通文本
if rl.Type == "" && onEvent == nil && rl.Content == "" && rl.Error == "" {
// 非JSON旧行且无回调:直接当最终输出(向后兼容旧服务器)
return line, nil
}
if onEvent != nil {
onEvent(rl)
}
}
}
return parseRespLine(line)
}
func (s *State) SendBuiltin(cmd string) (string, error) {
@ -96,13 +123,22 @@ type respLine struct {
Type string `json:"type"`
Content string `json:"content"`
Error string `json:"error"`
Tool string `json:"tool"`
Status string `json:"status"`
Result string `json:"result"`
}
// parseRespLineStruct 解析一行 JSON 响应帧,解析失败时将原文放入 Content。
func parseRespLineStruct(line string) respLine {
var rl respLine
if err := json.Unmarshal([]byte(line), &rl); err != nil {
return respLine{Content: line}
}
return rl
}
func parseRespLine(line string) (string, error) {
var rl respLine
if err := json.Unmarshal([]byte(line), &rl); err != nil {
return line, nil
}
rl := parseRespLineStruct(line)
switch rl.Type {
case "response":
return rl.Content, nil