mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML
两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。
## 症状(实测取证)
1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200
`{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。
2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。
## 根因
停止按钮走的是「空内容中断」,而 interceptLoop 有一行
`if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。
所以停止指令从未到达调度器;接口那个 200 是不诚实的。
另查明两条会放大症状的既有问题(停止后仍在跑):
- `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。
上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。
- `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。
这是给「被更高中断抢占」用的(现场要交出去、稍后继续),
但用户按停止是「不要了」,重跑就是停止没生效。
## 修法(按用户明确的设计)
停止 = ①立即结束当前 LLM 推理(不重试、不恢复);
②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。
- scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/
`consumeCancel`。配额取快照值(停止后新到的输入不受影响),
重复按停止取 max 不累加(两个客户端同时按不该翻倍)。
- `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM;
**纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。
带注释的停止(`/stop 换个话题`)仍走中断路径。
- `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。
- `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。
- `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是
Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退,
否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。
- WebUI handler / CLI `/stop`:空消息时带 `stop:true`。
## 鸿蒙端
- `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名,
避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。
- `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络),
纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容,
Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。
注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。
- `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明,
ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。
## 验证
- 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、
配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。
- `go test ./internal/... ./cmd/...` 全绿。
- 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受
READ_PASTEBOARD 授权限制装不上,与既有记录一致)。
This commit is contained in:
@ -31,9 +31,32 @@ func (a *Agent) interceptLoop() {
|
||||
select {
|
||||
case evt := <-a.io.InputInterruptChan():
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
if text == "" {
|
||||
stop, _ := evt.Payload["stop"].(bool)
|
||||
if text == "" && !stop {
|
||||
// 没有内容也不是停止指令:没有可处理的东西(旧行为)。
|
||||
//
|
||||
// 注意:**不能**把“空内容”一律当成空操作。客户端停止按钮
|
||||
// 本来就不带消息(/chat/interrupt 收 body 空的 {}),
|
||||
// 旧代码在这里 continue 掉,于是停止按钮毫无反应,
|
||||
// 而且接口还回 200 骗调用方——已实测:HTTP 200 但内核零日志、
|
||||
// 生成继续跑到自然结束。
|
||||
continue
|
||||
}
|
||||
if stop {
|
||||
// 停止:①立即结束当前 LLM 推理;②登记短路配额。
|
||||
if n := a.sched.armStop(); n > 0 || a.sched.takeStop() {
|
||||
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
|
||||
evt.Source, evt.OutputChannel, n)
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
if text == "" {
|
||||
// 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条
|
||||
// 中断入队,取消后会以空内容重跑一轮,停下之后又“活着”。
|
||||
continue
|
||||
}
|
||||
// 带注释的停止(/stop 说句话):注释本身仍作为中断处理,
|
||||
// 走下面的正常路径——用户想看模型对被停下话题的回应。
|
||||
}
|
||||
log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80))
|
||||
|
||||
clone := &agentIO.InputEvent{
|
||||
|
||||
@ -135,6 +135,20 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
|
||||
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
|
||||
ch, err := p.ChatStream(ctx, req)
|
||||
if err != nil {
|
||||
// 上下文已取消/超时:**绝不能**回退到非流式 Chat。
|
||||
//
|
||||
// 回退意味着再发一次完整请求,而这时用户已经按了停止(或请求已超时),
|
||||
// 结果是“按了停止又跑了一遍”——停止按钮看起来毫无反应的一个真实成因。
|
||||
//
|
||||
// 判据取 **ctx.Err()** 而不是“错误是不是 context.Canceled”:很多 provider
|
||||
// 不支持流式时也回 Canceled 表示“请走非流式”(仓里大量假 provider 即如此),
|
||||
// 那种情况必须继续回退,否则会把“不支持流式”误当成“已被取消”。
|
||||
if ctx.Err() != nil {
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err)
|
||||
return p.Chat(ctx, req)
|
||||
}
|
||||
@ -161,11 +175,16 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent
|
||||
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
|
||||
}
|
||||
// 取消类错误不能回退(否则等于再跑一遍完整的非流式请求)。
|
||||
// 同样以 ctx.Err() 为准:真取消才拦,provider 探活不算。
|
||||
if ctx.Err() != nil {
|
||||
return resp, accErr
|
||||
}
|
||||
log.Printf("[agent] stream failed before content (%v), falling back to non-stream chat", accErr)
|
||||
return p.Chat(ctx, req)
|
||||
}
|
||||
|
||||
@ -364,6 +364,16 @@ type scheduler struct {
|
||||
// 被挂起 4 帧;L4 之上没有更高级别,链到此为止。超限只可能是内核 bug,
|
||||
// 因此这里只做防御性计数,**不降级、不丢弃帧**。
|
||||
maxInterruptFrames int
|
||||
|
||||
// cancelBudget 是「停止」剩下的短路配额(见 Agent.RequestStop)。
|
||||
//
|
||||
// 语义(用户明确的设计):停止 = ①立即结束当前 LLM 推理;②对**停止那一刻
|
||||
// 已排队**的 x 条消息,后续依次在 pre-action 阶段短路,而不是把它们当
|
||||
// 中断/新输入再跑一遍。配额是快照值:停止之后**新到**的输入不受影响。
|
||||
cancelBudget int
|
||||
// stopArmed 标记“下一条待收尾的任务是因为用户按了停止”。
|
||||
// 取消 LLM 后 stepLLM 默认重跑本步;用户停止时必须改为直接收尾。
|
||||
stopArmed bool
|
||||
}
|
||||
|
||||
// suspendedTask 是一个被抢占任务的现场。
|
||||
@ -573,6 +583,53 @@ func (s *scheduler) setImmediateLocked(t *Task) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// armStop 处理一次「停止」指令:登记短路配额并返回**停止那一刻的排队深度**。
|
||||
//
|
||||
// 语义(用户明确的设计):停止 = ①立即结束当前 LLM 推理;②对停止那一刻
|
||||
// 已排队的 x 条消息,后续依次在 pre-action 阶段短路。配额是**快照值**:
|
||||
// 停止之后新到的输入不受影响(否则停止会变成一个永远生效的“黑洞”)。
|
||||
//
|
||||
// 多次按停止取**较大值**而不是累加:两个客户端同时按下时配额不应翻倍。
|
||||
func (s *scheduler) armStop() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if n := s.pendingQueueLenLocked(); n > s.cancelBudget {
|
||||
s.cancelBudget = n
|
||||
}
|
||||
s.stopArmed = true
|
||||
return s.cancelBudget
|
||||
}
|
||||
|
||||
// pendingQueueLenLocked 统计**尚未执行**的排队输入数量(不含中断队列/挂起)。
|
||||
func (s *scheduler) pendingQueueLenLocked() int {
|
||||
return len(s.queue)
|
||||
}
|
||||
|
||||
// takeStop 消费「当前任务应被立即结束而不是重试」这一次标记。
|
||||
//
|
||||
// 取消 LLM 后 stepLLM 会看到 context.Canceled 并 outcomeContinue 重跑;
|
||||
// 若这是用户按下的停止,重跑就是错的——应该直接收尾。
|
||||
func (s *scheduler) takeStop() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.stopArmed {
|
||||
return false
|
||||
}
|
||||
s.stopArmed = false
|
||||
return true
|
||||
}
|
||||
|
||||
// consumeCancel 消费一格短路配额;true 表示本任务在 pre-action 阶段直接收尾。
|
||||
func (s *scheduler) consumeCancel() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cancelBudget <= 0 {
|
||||
return false
|
||||
}
|
||||
s.cancelBudget--
|
||||
return true
|
||||
}
|
||||
|
||||
func removeTask(list []*Task, target *Task) []*Task {
|
||||
for i, t := range list {
|
||||
if t == target {
|
||||
|
||||
190
internal/agent/core/stop_semantics_test.go
Normal file
190
internal/agent/core/stop_semantics_test.go
Normal file
@ -0,0 +1,190 @@
|
||||
package core
|
||||
|
||||
// 「停止」语义的验收测试(用户明确的设计):
|
||||
//
|
||||
// 停止 = ①立即结束当前 LLM 推理(不重试、不恢复);②对停止那一刻**已排队**的
|
||||
// x 条消息,后续依次在 pre-action 阶段短路,而不是把它们当新输入再跑一遍。
|
||||
//
|
||||
// 为什么单独一组用例:此前的实现把「停止」当成一条普通中断——空内容还会被
|
||||
// interceptLoop 直接丢掉。实测症状是接口回 200、内核零日志、生成继续跑到自然结束。
|
||||
// 这组用例把三条不变量钉住:停止必须终结当前任务、必须按快照配额短路排队消息、
|
||||
// 停止之后新到的输入不受影响。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// stopProvider 是真会走流式路径的 provider:首个 ChatStream 阻塞到 ctx 取消,
|
||||
// 之后(若真的重跑了)计数会在 callCount 上暴露出来。
|
||||
//
|
||||
// 与 preemptProvider 的区别:后者 ChatStream 恒回错误,会让
|
||||
// chatStreamWithFallback 走非流式回退——那条路径恰好是“停止后重跑”的现场,
|
||||
// 但也让测试看不到流式取消本身的行为。两个 double 各测一段。
|
||||
type stopProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
streams int
|
||||
entered chan struct{}
|
||||
enteredOn sync.Once
|
||||
}
|
||||
|
||||
func newStopProvider() *stopProvider {
|
||||
return &stopProvider{entered: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (p *stopProvider) Name() string { return "stop" }
|
||||
func (p *stopProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func (p *stopProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
p.mu.Unlock()
|
||||
return &agentAPI.CompletionResponse{Content: "非流式回退内容"}, nil
|
||||
}
|
||||
|
||||
func (p *stopProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
p.streams++
|
||||
p.mu.Unlock()
|
||||
p.enteredOn.Do(func() { close(p.entered) })
|
||||
ch := make(chan agentAPI.StreamChunk)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
<-ctx.Done()
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *stopProvider) callCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.calls
|
||||
}
|
||||
|
||||
// 停止指令必须终结当前任务,而不是取消后重跑本步。
|
||||
func TestStop_TerminatesCurrentTaskWithoutRetry(t *testing.T) {
|
||||
sp := newStopProvider()
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
evt, _ := textEvent("webui", "写一篇很长的文章")
|
||||
task := newInputTask(evt)
|
||||
if !a.sched.enqueue(task) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
nt, _, _ := a.sched.nextRef()
|
||||
|
||||
done := make(chan struct{})
|
||||
var frame *TaskFrame
|
||||
go func() {
|
||||
frame, _ = a.runInputTask(nt.Event)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider 未被调用")
|
||||
}
|
||||
|
||||
// 与停止按钮完全一致的调用形状:空消息 + stop 标记。
|
||||
stopEvt := &agentIO.InputEvent{
|
||||
Source: "webui",
|
||||
Type: "text",
|
||||
OutputChannel: "webui",
|
||||
Payload: map[string]interface{}{
|
||||
"content": "", "stop": true, "interrupt": true, "priority": "L4",
|
||||
},
|
||||
}
|
||||
a.io.InjectInterrupt(stopEvt.Source, stopEvt.OutputChannel, stopEvt.Payload)
|
||||
// interceptLoop 才是 armStop 的调用点;这里模拟它已消费该指令(只 arm,
|
||||
// 不 takeStop——takeStop 必须由 stepLLM 去消费,那正是被测行为)。
|
||||
a.sched.armStop()
|
||||
a.cancelCurrentLLM()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("停止后任务未结束(仍在重跑或挂起)")
|
||||
}
|
||||
|
||||
if frame == nil {
|
||||
t.Fatal("未拿到帧")
|
||||
}
|
||||
// 不该出现「取消 → 重跑 → 再问一次模型」:provider 只该被调用一次。
|
||||
// 这正是停止按钮失效的核心——实测旧行为:取消后还会(经非流式回退或
|
||||
// stepLLM 重跑)再发一次完整请求。
|
||||
if n := sp.callCount(); n != 1 {
|
||||
t.Fatalf("停止后 LLM 调用次数=%d,期望 1(不得重跑/回退)", n)
|
||||
}
|
||||
if frame.Response == "" {
|
||||
t.Fatal("停止应给用户一条收尾说明")
|
||||
}
|
||||
}
|
||||
|
||||
// 停止时的排队深度是快照:恰好短路 x 条,之后新到的输入不受影响。
|
||||
func TestStop_CancelBudgetIsSnapshot(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
// 停止那一刻队列里有 2 条。
|
||||
a.sched.enqueue(newInputTask(mustTextEvent("qq", "一")))
|
||||
a.sched.enqueue(newInputTask(mustTextEvent("qq", "二")))
|
||||
n := a.sched.armStop()
|
||||
if n != 2 {
|
||||
t.Fatalf("停止时排队深度=%d,期望 2", n)
|
||||
}
|
||||
|
||||
// 停止后才到的新输入:不受配额影响。
|
||||
a.sched.enqueue(newInputTask(mustTextEvent("qq", "三")))
|
||||
|
||||
canceled := 0
|
||||
kept := []string{}
|
||||
for _, name := range []string{"一", "二", "三"} {
|
||||
if a.sched.consumeCancel() {
|
||||
canceled++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, name)
|
||||
}
|
||||
if canceled != 2 {
|
||||
t.Fatalf("被短路条数=%d,期望 2(快照配额)", canceled)
|
||||
}
|
||||
if len(kept) != 1 || kept[0] != "三" {
|
||||
t.Fatalf("停止后新到的输入应正常执行,实际 kept=%v", kept)
|
||||
}
|
||||
|
||||
// 配额用尽后不再短路。
|
||||
if a.sched.consumeCancel() {
|
||||
t.Fatal("配额应已耗尽")
|
||||
}
|
||||
}
|
||||
|
||||
// 多次按停止取较大值,而不是累加(两个客户端同时按下不该翻倍)。
|
||||
func TestStop_ArmTwiceTakesMaxNotSum(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.sched.enqueue(newInputTask(mustTextEvent("qq", "一")))
|
||||
a.sched.enqueue(newInputTask(mustTextEvent("qq", "二")))
|
||||
if n := a.sched.armStop(); n != 2 {
|
||||
t.Fatalf("首次 armStop=%d,期望 2", n)
|
||||
}
|
||||
if n := a.sched.armStop(); n != 2 {
|
||||
t.Fatalf("重复 armStop=%d,期望仍为 2(取 max 不累加)", n)
|
||||
}
|
||||
if !a.sched.consumeCancel() || !a.sched.consumeCancel() {
|
||||
t.Fatal("两次配额都应可消费")
|
||||
}
|
||||
if a.sched.consumeCancel() {
|
||||
t.Fatal("第三次不应再有配额")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTextEvent(source, text string) *agentIO.InputEvent {
|
||||
evt, _ := textEvent(source, text)
|
||||
return evt
|
||||
}
|
||||
@ -536,6 +536,14 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
|
||||
f.Response = *f.StageCtx.Response
|
||||
return outcomeDone
|
||||
}
|
||||
// 停止的第二个半边(用户明确的设计):对**停止那一刻已排队**的消息,
|
||||
// 依次在这里短路——不进 LLM、不执行工具,直接以一条说明收尾。
|
||||
// 配额是停止时的快照,消费完即止;停止之后新到的输入不受影响。
|
||||
if a.sched.consumeCancel() {
|
||||
log.Printf("[agent] cancelled queued task at pre-action (input=%q)", truncateStr(f.Input, 40))
|
||||
f.Response = "已取消(用户停止)。"
|
||||
return outcomeDone
|
||||
}
|
||||
if len(f.StageCtx.ContextMsgs) > 0 {
|
||||
for _, m := range f.StageCtx.ContextMsgs {
|
||||
role, _ := m["role"].(string)
|
||||
@ -586,6 +594,18 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||
f.Err = fmt.Errorf("interrupted by user input")
|
||||
return outcomeFailed
|
||||
}
|
||||
// 用户按下的「停止」:取消必须**终结本任务**,不是重跑。
|
||||
//
|
||||
// 默认行为(下面的 outcomeContinue 重跑本步)是给「被更高中断抢占」用的:
|
||||
// 取消只是把现场交出去,稍后还要继续。而停止是用户明确的“不要了”,
|
||||
// 重跑会让停止按钮看起来没反应(已实测:取消后又发了一次完整请求)。
|
||||
if a.sched.takeStop() {
|
||||
log.Printf("[agent] stop: LLM cancelled, task terminated without retry")
|
||||
if strings.TrimSpace(f.Response) == "" {
|
||||
f.Response = "已停止生成。"
|
||||
}
|
||||
return outcomeDone
|
||||
}
|
||||
f.Turn++
|
||||
return outcomeContinue // 重跑 StepLLM
|
||||
}
|
||||
|
||||
@ -393,10 +393,16 @@ func (p *Plugin) cmdInterrupt(conn net.Conn, parts []string, s *sdk.PluginSDK) {
|
||||
}
|
||||
// PriorityL3(交互):/stop 是人在终端上当场下的指令,属于"需要及时处理",
|
||||
// 不该用默认的 L1(后台)——那样它会被排在其它后台注入后面,停得不及时。
|
||||
s.InjectInterrupt(cliSource, cliChannel, "text", map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"content": msg,
|
||||
"priority": sdk.PriorityL3,
|
||||
})
|
||||
}
|
||||
if msg == "" {
|
||||
// 无附带消息 = 停止:必须带 stop 标记,否则空内容会被 interceptLoop
|
||||
// 当空操作丢掉(旧行为:回了"已发送中断信号"但内核什么都没发生)。
|
||||
payload["stop"] = true
|
||||
}
|
||||
s.InjectInterrupt(cliSource, cliChannel, "text", payload)
|
||||
writeLine(conn, map[string]interface{}{
|
||||
"type": "response",
|
||||
"content": "已发送中断信号",
|
||||
|
||||
@ -296,13 +296,15 @@ func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, name, st.ModTime(), f)
|
||||
}
|
||||
|
||||
// handleChatInterrupt 注入用户中断:取消正在进行的 LLM 生成并/或发送打断消息。
|
||||
// 核心拦截语义(interceptLoop):
|
||||
// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以
|
||||
// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入;
|
||||
// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。
|
||||
// handleChatInterrupt 注入用户中断或「停止」。
|
||||
//
|
||||
// message 可选:空则纯取消(仍会注入空内容中断触发取消)。
|
||||
// 两种语义分开(用户明确的设计):
|
||||
// - **停止**(message 为空,即停止按钮):①立即结束当前 LLM 推理(不重试);
|
||||
// ②对停止那一刻已排队的 x 条消息,后续依次在 pre-action 阶段短路。
|
||||
// 只传 stop=true,不入中断队列——旧实现把空停止当普通中断入队,取消后
|
||||
// 还会以空内容重跑一轮,表现为“停了又活”。
|
||||
// - **中断/补充指令**(message 非空):取消当前流式请求 + 中断入队,
|
||||
// 模型看到被打断的上下文与用户新输入。
|
||||
func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@ -324,12 +326,18 @@ func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
|
||||
if body.DeviceID != "" {
|
||||
source = "webui/" + body.DeviceID
|
||||
}
|
||||
// PriorityL4:终止按钮必须能立即打断当前任务(内核级插件才有的能力)。
|
||||
// PriorityL4:终止/停止必须能立即打断当前任务(内核级插件才有的能力)。
|
||||
// agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效;
|
||||
// 但 LLM 流式段会被立刻取消。
|
||||
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"content": body.Message,
|
||||
"priority": sdk.PriorityL4,
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(body.Message) == "" {
|
||||
// 空消息 = 停止。必须带 stop 标记,否则空内容会被 interceptLoop
|
||||
// 当成空操作丢掉(旧行为:接口回 200,实际什么都没发生)。
|
||||
payload["stop"] = true
|
||||
}
|
||||
h.sdk.InjectInterrupt(source, "webui", "text", payload)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user