diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js
index 357cf1d..c4dc67d 100644
--- a/cmd/gui/renderer/app.js
+++ b/cmd/gui/renderer/app.js
@@ -1166,6 +1166,9 @@ function buildChatLayout() {
'' +
+ '" +
'" +
@@ -2036,6 +2039,7 @@ function buildChatStarmapGraph() {
async function sendChat() {
var inp = document.getElementById("chat-input");
var btn = document.getElementById("chat-send-btn");
+ var stopBtn = document.getElementById("chat-stop-btn");
var text = inp.value.trim();
if (!text || state.chatLoading) return;
if (state.currentConn && state.currentConn.type === "cli") {
@@ -2057,6 +2061,7 @@ async function sendChat() {
state.chatStage = __("等待AI回复...", "Waiting for AI...");
btn.disabled = true;
btn.textContent = "";
+ if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止
rerenderChat();
try {
// webui 连接:同步 POST 等完整回复(服务端 X-Trigger-Only 也返回 response;SSE 公网不稳时靠同步兜底)
@@ -2124,10 +2129,26 @@ async function sendChat() {
state.chatStage = "";
btn.disabled = false;
btn.textContent = __("发送", "Send");
+ var sb2 = document.getElementById("chat-stop-btn");
+ if (sb2) sb2.style.display = "none"; // 回复完成/失败,隐藏停止按钮
rerenderChat();
}
}
+// 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前请求并以
+// [中断消息] 重启轮次;无则在跑则作为普通消息处理。
+async function interruptChat() {
+ try {
+ await api("/chat/interrupt", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ toast(__("已发送中断信号", "Interrupt signal sent"));
+ } catch (e) {
+ toast(__("中断失败: ", "Interrupt failed: ") + e.message, true);
+ }
+}
+
async function queryMemoryChat() {
var q = document.getElementById("mem-query")?.value;
var r = document.getElementById("mem-result-chat");
diff --git a/cmd/waiter/builtin.go b/cmd/waiter/builtin.go
index 9272d2e..7a9de6c 100644
--- a/cmd/waiter/builtin.go
+++ b/cmd/waiter/builtin.go
@@ -12,6 +12,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
case cmd == "/help":
fmt.Fprintln(out, `Built-in commands:
/help show this help
+ /stop [msg] stop generation / send interrupt (alias /interrupt)
/exit, /quit exit waiter
/clear clear screen
/reconnect force reconnection
diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go
index bb86801..c430687 100644
--- a/cmd/waiter/main.go
+++ b/cmd/waiter/main.go
@@ -202,10 +202,15 @@ func runInteractive(state *State, cfg *Config) {
}
func oneshot(state *State, msg string) {
+ var sr streamRender
stop := startSpinner("thinking...")
resp, err := state.SendChatStream(msg, func(rl respLine) {
// 第一个过程帧到达即停转,后续帧直接渲染
stop()
+ if sr.handleDelta(rl) {
+ return // delta 已增量渲染
+ }
+ sr.reset() // 聚合帧/工具帧:结束 delta 流,换行输出
printServerEvent(rl)
})
stop()
@@ -347,6 +352,56 @@ loop:
}
}
+// streamRender 累积 token 级 delta 帧并增量重绘当前行。
+// 聚合帧(reasoning/tool_call/response)到达时清空累积状态(该轮已结束)。
+// 旧服务器不发 delta,此结构始终为空,行为与原来完全一致。
+type streamRender struct {
+ reasoning strings.Builder
+ content strings.Builder
+}
+
+// handleDelta 处理 delta 帧;返回是否消费了该帧。
+// reset=true 的空帧表示服务端轮次作废(用户中断):清空累积并定格已显示内容。
+func (sr *streamRender) handleDelta(rl respLine) bool {
+ switch rl.Type {
+ case "reasoning_delta":
+ if rl.Reset {
+ sr.reasoning.Reset()
+ fmt.Print(clearLine)
+ return true
+ }
+ sr.reasoning.WriteString(rl.Content)
+ if colors {
+ fmt.Printf("%s%s· %s%s", clearLine, colorDim, sr.reasoning.String(), colorReset)
+ } else {
+ fmt.Printf("%s[思考] %s", clearLine, sr.reasoning.String())
+ }
+ return true
+ case "content_delta":
+ if rl.Reset {
+ sr.content.Reset()
+ fmt.Print(clearLine + "\n") // 定格已显示的部分内容,换行
+ return true
+ }
+ sr.content.WriteString(rl.Content)
+ if colors {
+ fmt.Printf("%s%s%s%s", clearLine, colorGreen, sr.content.String(), colorReset)
+ } else {
+ fmt.Printf("%s%s", clearLine, sr.content.String())
+ }
+ return true
+ }
+ return false
+}
+
+// reset 在收到聚合帧/工具帧时调用:delta 流被打断或结束,
+// 下一行输出不再覆盖 delta 内容。
+func (sr *streamRender) reset() {
+ sr.reasoning.Reset()
+ sr.content.Reset()
+ fmt.Print(clearLine + "\n")
+}
+
// printServerOutput 渲染一行服务器输出(JSON 帧)。
func printServerOutput(content string) {
rl := parseRespLineStruct(content)
@@ -369,8 +424,10 @@ func printServerEvent(rl respLine) {
// renderPlain 无色模式下的纯文本渲染。
func renderPlain(rl respLine, raw string) string {
switch rl.Type {
- case "reasoning":
+ case "reasoning", "reasoning_delta":
return "[思考] " + rl.Content
+ case "content_delta":
+ return rl.Content
case "tool_call":
return fmt.Sprintf("[工具] %s (%s) %s", rl.Tool, rl.Status, rl.Result)
case "response":
diff --git a/cmd/waiter/state.go b/cmd/waiter/state.go
index 7bf67cd..03579cf 100644
--- a/cmd/waiter/state.go
+++ b/cmd/waiter/state.go
@@ -126,6 +126,7 @@ type respLine struct {
Tool string `json:"tool"`
Status string `json:"status"`
Result string `json:"result"`
+ Reset bool `json:"reset,omitempty"` // delta 帧:服务端轮次作废,清空累积
}
// parseRespLineStruct 解析一行 JSON 响应帧,解析失败时将原文放入 Content。
diff --git a/cmd/waiter/tui.go b/cmd/waiter/tui.go
index 7ce7e7e..051b2ba 100644
--- a/cmd/waiter/tui.go
+++ b/cmd/waiter/tui.go
@@ -69,6 +69,7 @@ type chatMsg struct {
tool string // msgTool: 工具名
status string // msgTool: ok/denied/interrupted/error/running
result string // msgTool: 结果预览
+ final bool // msgAgent: 流式消息已完成(后续 delta 不再追加)
}
// ---- tea.Msg ----
@@ -375,18 +376,61 @@ func (m *tuiModel) handleServerLine(line string) {
} else {
m.append(cm)
}
+ case "reasoning_delta":
+ // token 级增量:与 reasoning 同样合并到最后一条 reasoning 消息
+ if rl.Reset {
+ m.messages = []chatMsg{}
+ break
+ }
+ if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgReasoning {
+ m.messages[n-1].text += rl.Content
+ } else if rl.Content != "" {
+ m.append(chatMsg{kind: msgReasoning, text: rl.Content})
+ }
+ case "content_delta":
+ // token 级增量:追加到最后一条 agent 消息(流式生成中的回复)
+ if rl.Reset {
+ m.sealLastAgent()
+ break
+ }
+ if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgAgent && !m.messages[n-1].final {
+ m.messages[n-1].text += rl.Content
+ } else if rl.Content != "" {
+ m.append(chatMsg{kind: msgAgent, text: rl.Content})
+ }
case "tool_call":
+ // 工具调用打断内容流:置 final 防止后续 delta 误追加到旧消息
+ m.sealLastAgent()
m.append(chatMsg{kind: msgTool, tool: rl.Tool, status: rl.Status, result: rl.Result})
case "response":
+ // 聚合最终响应:覆盖/替换 delta 累积的最后一条 agent 消息(内容相同),
+ // 或在无 delta 时新建。置 final 标记本轮完成。
m.busy = false
- m.append(chatMsg{kind: msgAgent, text: rl.Content})
+ if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgAgent && !m.messages[n-1].final {
+ if rl.Content != "" {
+ m.messages[n-1].text = rl.Content // 以聚合为准(含 stage 插件改写后的最终文本)
+ }
+ m.messages[n-1].final = true
+ } else {
+ cm := chatMsg{kind: msgAgent, text: rl.Content, final: true}
+ m.append(cm)
+ }
case "error":
m.busy = false
+ m.sealLastAgent()
m.append(chatMsg{kind: msgError, text: rl.Error})
default:
// 非 JSON 旧行(旧服务器):当最终输出
m.busy = false
- m.append(chatMsg{kind: msgAgent, text: line})
+ m.append(chatMsg{kind: msgAgent, text: line, final: true})
+ }
+ m.refreshViewport()
+}
+
+// sealLastAgent 将最后一条未完成的 agent 流式消息标记为完成。
+func (m *tuiModel) sealLastAgent() {
+ if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgAgent {
+ m.messages[n-1].final = true
}
}
diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go
index c43a5d5..e746681 100644
--- a/internal/agent/core/process.go
+++ b/internal/agent/core/process.go
@@ -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
diff --git a/internal/agent/core/stream_accumulate_test.go b/internal/agent/core/stream_accumulate_test.go
new file mode 100644
index 0000000..ba6838c
--- /dev/null
+++ b/internal/agent/core/stream_accumulate_test.go
@@ -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)
+ }
+}
diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go
index 0f705bc..5e1a5f9 100644
--- a/internal/plugins/cli/plugin.go
+++ b/internal/plugins/cli/plugin.go
@@ -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) {
diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html
index 7c671e4..632eea9 100644
--- a/internal/plugins/webui/dashboard.html
+++ b/internal/plugins/webui/dashboard.html
@@ -2774,6 +2774,9 @@ background:
'' +
+ '" +
'" +
@@ -3633,6 +3636,7 @@ background:
async function sendChat() {
var inp = document.getElementById("chat-input");
var btn = document.getElementById("chat-send-btn");
+ var stopBtn = document.getElementById("chat-stop-btn");
var text = inp.value.trim();
if (!text || state.chatLoading) return;
state.chatStick = true;
@@ -3644,6 +3648,7 @@ background:
state.chatStage = __("等待AI回复...", "Waiting for AI...");
btn.disabled = true;
btn.textContent = "";
+ if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止
rerenderChat(true);
// 触发式 POST:短超时仅确认受理;回复靠 SSE 流式渲染(对齐 GUI 行为)。
try {
@@ -3715,10 +3720,26 @@ background:
state.chatStage = "";
btn.disabled = false;
btn.textContent = __("发送", "Send");
+ var sb2 = document.getElementById("chat-stop-btn");
+ if (sb2) sb2.style.display = "none"; // 回复完成/失败,隐藏停止按钮
rerenderChat(true);
}
}
+ // 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前
+ // 请求并以 [中断消息] 重启轮次;无则在跑则作为普通消息处理。
+ async function interruptChat() {
+ try {
+ await api("/chat/interrupt", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ toast(__("已发送中断信号", "Interrupt signal sent"));
+ } catch (e) {
+ toast(__("中断失败: ", "Interrupt failed: ") + e.message, true);
+ }
+ }
+
async function queryMemoryChat() {
var q = document.getElementById("mem-query")?.value;
var r = document.getElementById("mem-result-chat");
@@ -4119,8 +4140,11 @@ background:
lastM2.role === "assistant" &&
!lastM2._final
) {
+ // 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准,
+ // 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。
lastM2._grow = true;
- lastM2.content += p.content;
+ lastM2.content = p.content;
+ lastM2._final = true;
rerenderChat();
return;
}
@@ -4137,12 +4161,50 @@ background:
content: p.content,
_streaming: true,
_grow: true,
+ _final: true,
});
rerenderChat();
} catch (ex) {
console.error("[SSE] agent_output error", ex);
}
});
+ // token 级流式增量:逐块追加到当前回复内容(流式生成中);
+ // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。
+ es.addEventListener("content_delta", function (e) {
+ try {
+ var ev = JSON.parse(e.data);
+ var p = ev.payload || {};
+ if (p.channel === "_consolidation_") return;
+ if (p.reset) {
+ var lm = state.messages.length
+ ? state.messages[state.messages.length - 1]
+ : null;
+ if (lm && lm.role === "assistant" && !lm._final) {
+ lm._final = true;
+ rerenderChat();
+ }
+ return;
+ }
+ if (!p.content) return;
+ state.chatStage = __("AI 回复中...", "AI replying...");
+ var last =
+ state.messages.length > 0
+ ? state.messages[state.messages.length - 1]
+ : null;
+ if (!last || last.role !== "assistant" || last._final) {
+ state.messages.push({
+ role: "assistant",
+ content: "",
+ tool_calls: [],
+ _streaming: true,
+ _grow: true,
+ });
+ last = state.messages[state.messages.length - 1];
+ }
+ last.content += p.content;
+ rerenderChat();
+ } catch (ex) {}
+ });
es.addEventListener("terminal_output", function (e) {
try {
var ev = JSON.parse(e.data);
@@ -4170,6 +4232,7 @@ background:
try {
var ev = JSON.parse(e.data);
var p = ev.payload || {};
+ if (p.channel === "_consolidation_") return;
if (p.content) {
state.chatStage = __("AI 思考中...", "AI thinking...");
var last =
@@ -4186,12 +4249,39 @@ background:
});
last = state.messages[state.messages.length - 1];
}
- last.reasoning_content =
- (last.reasoning_content || "") + p.content;
+ // 聚合 reasoning 帧携带全文:直接覆盖(若已有 delta 累积则等价)
+ last.reasoning_content = p.content;
rerenderChat();
}
} catch (ex) {}
});
+ // token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废
+ es.addEventListener("reasoning_delta", function (e) {
+ try {
+ var ev = JSON.parse(e.data);
+ var p = ev.payload || {};
+ if (p.channel === "_consolidation_") return;
+ if (p.reset) return; // 轮次作废(用户中断):清空累积中的思考
+ if (!p.content) return;
+ state.chatStage = __("AI 思考中...", "AI thinking...");
+ var last =
+ state.messages.length > 0
+ ? state.messages[state.messages.length - 1]
+ : null;
+ if (!last || last.role !== "assistant" || last._final) {
+ state.messages.push({
+ role: "assistant",
+ content: "",
+ reasoning_content: "",
+ tool_calls: [],
+ _streaming: true,
+ });
+ last = state.messages[state.messages.length - 1];
+ }
+ last.reasoning_content = (last.reasoning_content || "") + p.content;
+ rerenderChat();
+ } catch (ex) {}
+ });
es.addEventListener("tool_call", function (e) {
try {
var ev = JSON.parse(e.data);
diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go
index 33a9a01..c7ab3ed 100644
--- a/internal/plugins/webui/handler.go
+++ b/internal/plugins/webui/handler.go
@@ -694,6 +694,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tracker/", h.requireAPI(h.handleTracker))
mux.HandleFunc("/api/v1/chat", h.requireAPI(h.handleChat))
mux.HandleFunc("/api/v1/chat/history", h.requireAPI(h.handleChatHistory))
+ mux.HandleFunc("/api/v1/chat/interrupt", h.requireAPI(h.handleChatInterrupt))
mux.HandleFunc("/api/v1/chat/events", h.requireAPI(h.handleChatEvents))
mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals))
mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory))
@@ -1221,6 +1222,39 @@ func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"messages": result})
}
+// handleChatInterrupt 注入用户中断:取消正在进行的 LLM 生成并/或发送打断消息。
+// 核心拦截语义(interceptLoop):
+// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以
+// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入;
+// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。
+// message 可选:空则纯取消(仍会注入空内容中断触发取消)。
+func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if h.sdk == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
+ return
+ }
+ var body struct {
+ Message string `json:"message"`
+ DeviceID string `json:"device_id"`
+ }
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body) // body 可选
+ }
+
+ source := "webui"
+ if body.DeviceID != "" {
+ source = "webui/" + body.DeviceID
+ }
+ h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
+ "content": body.Message,
+ })
+ writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
+}
+
func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) {
h.termMu.Lock()
terms := make([]*termState, 0, len(h.termStates))
@@ -1413,8 +1447,27 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
}
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output"}
+ // token 级流式增量事件:实时转发给浏览器做逐 token 渲染。
+ // 不进 sseEventRing —— 断线重连只重放聚合事件(最终真相),
+ // 避免重放 delta 与聚合内容重复追加。
var unsubs []func()
var seq int64
+ appendDeltaSub := func(evtType sdk.EventType) {
+ unsub := h.sdk.Subscribe(evtType, func(evt *sdk.Event) {
+ data, _ := json.Marshal(evt)
+ seq++
+ id := fmt.Sprintf("%d-%d", evt.Timestamp, seq)
+ select {
+ case writeCh <- fmt.Sprintf("id: %s\nevent: %s\ndata: %s\n", id, evt.Type, string(data)):
+ default:
+ log.Printf("[SSE] DROPPED %s (writeCh full, len=%d)", evt.Type, len(writeCh))
+ }
+ })
+ unsubs = append(unsubs, unsub)
+ }
+ appendDeltaSub(sdk.EventReasoningDelta)
+ appendDeltaSub(sdk.EventContentDelta)
+
for _, t := range subTypes {
t2 := t
unsub := h.sdk.Subscribe(sdk.EventType(t2), func(evt *sdk.Event) {
diff --git a/internal/sdk/events.go b/internal/sdk/events.go
index f1b9eb2..0283a3c 100644
--- a/internal/sdk/events.go
+++ b/internal/sdk/events.go
@@ -18,4 +18,9 @@ const (
EventSystem = events.EventSystem
EventTerminalOutput = events.EventTerminalOutput
EventAll = events.EventAll
+
+ // 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。
+ // 客户端可选订做真逐 token 渲染;聚合事件仍照常发布,旧订阅者不受影响。
+ EventReasoningDelta = events.EventReasoningDelta
+ EventContentDelta = events.EventContentDelta
)