mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -315,11 +315,28 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent
|
||||
}
|
||||
|
||||
resp, accErr := accumulateStream(ctx, ch, a)
|
||||
|
||||
// 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致:
|
||||
// 被 cancel 时丢弃已收内容返回 err),让 process() 的 continue 分支
|
||||
// 重启轮次并以 [中断消息] 注入打断内容。绝不能把部分内容当成功返回,
|
||||
// 否则用户打断会被无视、继续执行工具/输出。
|
||||
if errors.Is(accErr, context.Canceled) || errors.Is(accErr, context.DeadlineExceeded) {
|
||||
// 通知客户端:本轮流式作废,清空 delta 累积并定格已显示内容
|
||||
if a != nil {
|
||||
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
||||
"content": "",
|
||||
"channel": a.currentOutputChannel,
|
||||
"reset": true,
|
||||
})
|
||||
}
|
||||
return resp, accErr
|
||||
}
|
||||
|
||||
if accErr == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 流中途错误:若已累积到内容则返回部分结果,否则回退非流式
|
||||
// 其他错误(网络中断等):已累积到实质内容则返回部分结果,否则回退非流式
|
||||
if resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
|
||||
log.Printf("[agent] stream interrupted mid-way (%v), returning partial result", accErr)
|
||||
return resp, nil
|
||||
|
||||
69
internal/agent/core/stream_accumulate_test.go
Normal file
69
internal/agent/core/stream_accumulate_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// 验证流式 tool call 分片累积:模拟 llmsproxy/big-pickle 的分片序列
|
||||
func TestAccumulateStreamToolCalls(t *testing.T) {
|
||||
ch := make(chan agentAPI.StreamChunk, 10)
|
||||
go func() {
|
||||
// 分片1: name + id + arguments 开头
|
||||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||||
{ID: "call_1", Name: "cmd_run", RawArguments: "{\""},
|
||||
}}
|
||||
// 分片2-3: 只有 arguments 分片
|
||||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||||
{RawArguments: "command\""},
|
||||
}}
|
||||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||||
{RawArguments: ":\"date\"}"},
|
||||
}}
|
||||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "tool_calls"}
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
resp, err := accumulateStream(context.Background(), ch, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("accumulateStream: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.Name != "cmd_run" || tc.ID != "call_1" {
|
||||
t.Fatalf("bad name/id: %s/%s", tc.ID, tc.Name)
|
||||
}
|
||||
cmd, _ := tc.Arguments["command"].(string)
|
||||
if cmd != "date" {
|
||||
t.Fatalf("arguments not merged, got: %v", tc.Arguments)
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Fatalf("finish reason: %q", resp.FinishReason)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 content/reasoning 增量累积
|
||||
func TestAccumulateStreamContent(t *testing.T) {
|
||||
ch := make(chan agentAPI.StreamChunk, 5)
|
||||
go func() {
|
||||
ch <- agentAPI.StreamChunk{ReasoningContent: "think "}
|
||||
ch <- agentAPI.StreamChunk{Content: "你"}
|
||||
ch <- agentAPI.StreamChunk{Content: "好"}
|
||||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
|
||||
close(ch)
|
||||
}()
|
||||
resp, err := accumulateStream(context.Background(), ch, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("accumulateStream: %v", err)
|
||||
}
|
||||
if resp.Content != "你好" {
|
||||
t.Fatalf("content: %q", resp.Content)
|
||||
}
|
||||
if resp.ReasoningContent != "think " {
|
||||
t.Fatalf("reasoning: %q", resp.ReasoningContent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user