feat(streaming): token-level delta events + interrupt for CLI/WebUI/GUI

Expose the LLM token-level streaming deltas (EventReasoningDelta /
EventContentDelta) to every client channel and add user-initiated
interrupt (cancel generation / send interrupt message) to all three
frontends, preserving the existing interrupt-injection semantics.

SDK/events:
  - EventReasoningDelta, EventContentDelta constants exported in the
    public/internal SDK event alias tables.

CLI plugin:
  - handleChat subscribes to both delta events and forwards
    reasoning_delta / content_delta JSON frames (channel-filtered);
    aggregated reasoning/tool_call/response frames still fire as before.
  - New /stop (alias /interrupt) builtin injects an interrupt via
    InjectInterrupt(cliSource, cliChannel) - matches interceptLoop
    semantics: cancels an active stream and re-injects the message as
    a [中断消息] for a restarted turn; with no active LLM it behaves
    as a plain input.

Waiter client (line mode + TUI):
  - streamRender accumulates delta chunks and redraws the current line;
    a reset frame (stream abandoned, e.g. user interrupt) flushes the
    partial buffer so the next turn does not concatenate onto stale
    content. Aggregated frames terminate the delta line and render the
    final text (old servers without deltas behave exactly as before).
  - TUI merges content_delta into the in-flight agent message and seals
    it (final flag) on response/tool_call/error so subsequent deltas
    never append to a finished message.

WebUI:
  - SSE handler subscribes to the two delta events but does NOT record
    them into the replay ring - reconnection replays only aggregated
    events (the final truth), avoiding duplicate delta accumulation.
  - POST /api/v1/chat/interrupt calls InjectInterrupt(webui, webui)
    with optional message; fronted by a Stop button shown only while
    a generation is in flight.

dashboard.html / GUI app.js:
  - Stop button next to Send (hidden until chatLoading); interruptChat
    POSTs /chat/interrupt. Delta listeners append incrementally;
    agent_output (aggregated) now replaces (not appends) the in-flight
    content and marks _final; reset frames finalize the partial message.

process.go:
  - chatStreamWithFallback preserves the context.Canceled/
    DeadlineExceeded contract: a user interrupt returns the canceled
    error (never a partial-content success) so the existing continue
    branch restarts the turn with the [中断消息]. A reset
    EventContentDelta is published so connected clients drop stale
    partial renderings before the new turn begins.

Verified: /stop 'msg' via waiter triggers 'interrupt from cli/cli' in
interceptLoop; unit TestChatStreamCancelPreservesInterrupt confirms the
canceled error propagates instead of being swallowed.
This commit is contained in:
JianFeeeee
2026-08-25 10:50:37 +08:00
parent 28a6d3f09c
commit 061d2ae320
11 changed files with 423 additions and 7 deletions

View File

@ -211,8 +211,33 @@ func (p *Plugin) handleChat(w *connWriter, line string, s *sdk.PluginSDK) {
"result": truncateOneLine(result, 160),
})
})
// token 级流式增量帧:客户端可选订做逐 token 渲染。
// 旧客户端收到未知 type 会忽略;聚合 reasoning/response 帧仍照常发送,
// 保证旧/新客户端最终都能看到完整文本。
unsubReasoningDelta := s.Subscribe(sdk.EventReasoningDelta, 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_delta", "content": content})
})
unsubContentDelta := s.Subscribe(sdk.EventContentDelta, 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": "content_delta", "content": content})
})
defer unsubReasoning()
defer unsubToolCall()
defer unsubReasoningDelta()
defer unsubContentDelta()
resp := s.InjectTextSync(cliSource, cliChannel, line)
if resp != nil {
@ -260,6 +285,8 @@ func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) boo
switch parts[0] {
case "/help":
p.cmdHelp(conn)
case "/stop", "/interrupt":
p.cmdInterrupt(conn, parts, s)
case "/status":
p.cmdStatus(conn, s)
case "/kernel":
@ -285,6 +312,7 @@ func (p *Plugin) cmdHelp(conn net.Conn) {
"type": "response",
"content": `内置命令(直接对话内核,不依赖网络):
/help 显示此帮助
/stop [消息] 停止当前生成/发送中断消息(别名 /interrupt
/status 系统运行状态
/kernel 内核状态插件、工具、LLM、记忆
/settings 列出所有配置
@ -304,6 +332,36 @@ func (p *Plugin) cmdHelp(conn net.Conn) {
})
}
// ======== /stop ========
// cmdInterrupt 注入用户中断。核心拦截语义interceptLoop
// - 有 LLM 在跑cancelLLM 取消当前流式请求中断入队process() 以
// [中断消息] 重启轮次(模型看到被打断的上下文 + 用户新输入);
// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。
//
// 可选附带消息:/stop 换个话题(空参数 = 纯取消)。
func (p *Plugin) cmdInterrupt(conn net.Conn, parts []string, s *sdk.PluginSDK) {
msg := strings.TrimSpace(strings.TrimPrefix(line2(parts), "/stop"))
if alias := strings.TrimSpace(strings.TrimPrefix(line2(parts), "/interrupt")); alias != "" {
msg = alias
}
s.InjectInterrupt(cliSource, cliChannel, "text", map[string]interface{}{
"content": msg,
})
writeLine(conn, map[string]interface{}{
"type": "response",
"content": "已发送中断信号",
})
}
// line2 将命令行参数重组为原始字符串(保留词间空格,去掉首 token
func line2(parts []string) string {
if len(parts) < 2 {
return ""
}
return strings.Join(parts[1:], " ")
}
// ======== /status ========
func (p *Plugin) cmdStatus(conn net.Conn, s *sdk.PluginSDK) {