mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58: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:
@ -452,3 +452,72 @@ export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
p.content = rest;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 agent 下发的 screensue 内容是不是 HTML 片段。
|
||||
*
|
||||
* 服务端协议两侧都允许 HTML(localuse 的 local_screensue 在 Linux 用 browsh/w3m
|
||||
* 渲染 HTML;remotedevice 的工具说明写的就是「显示内容/HTML」)。此前鸿蒙端一律
|
||||
* 塞进 Text(),于是 HTML 被当成普通字符串原样显示成标签。
|
||||
*
|
||||
* 判据取「首个非空字符是 '<'」并且「存在配对的 '>'」且含字母/斜杠,
|
||||
* 避免把 "<3" 这类纯文本误判成标签;再排除纯文本里常见的比较式(如 "a < b")。
|
||||
*/
|
||||
export function looksLikeHtml(content: string): boolean {
|
||||
const trimmed: string = content.trim();
|
||||
if (trimmed.length === 0 || trimmed.charAt(0) !== '<') {
|
||||
return false;
|
||||
}
|
||||
const close: number = trimmed.indexOf('>');
|
||||
if (close < 0) {
|
||||
return false;
|
||||
}
|
||||
// 标签名必须是字母开头(</div>、<div>、<br/>),"< 3" 这类不是标签。
|
||||
const inner: string = trimmed.substring(1, close).trim();
|
||||
if (inner.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const head: string = inner.charAt(0) === '/' ? inner.substring(1) : inner;
|
||||
const tagName: RegExp = new RegExp('^[a-zA-Z][a-zA-Z0-9]*');
|
||||
return tagName.test(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一段内容包成可交给 RichText 渲染的最小 HTML 文档。
|
||||
*
|
||||
* 为什么用 RichText 而不是 Web:screensue 的内容是 agent 下发的第三方文本,
|
||||
* Web 组件默认带 JS 与网络能力(javaScriptAccess/fileAccess),等于让远端内容
|
||||
* 在客户端进程里执行脚本。RichText 只解析 HTML 子集、无脚本、无网络,
|
||||
* 对「给用户看一段内容」这个用途正好够用且更安全。
|
||||
*
|
||||
* RichText 用系统默认前景色,深色主题下会变成黑字看不见 —— 这里显式注入
|
||||
* 颜色/字号,让两种主题下都可读。
|
||||
*/
|
||||
export function screensueHtml(content: string, dark: boolean): string {
|
||||
const fg: string = dark ? '#E8ECF4' : '#1B2430';
|
||||
const trimmed: string = content.trim();
|
||||
// 已是完整文档就不要重复包 <html>(RichText 需要单一根节点才稳定)。
|
||||
const body: string = looksLikeHtml(trimmed) ? trimmed : escapeHtmlText(trimmed);
|
||||
return '<div style="color:' + fg + ';font-size:16px;line-height:1.6;'
|
||||
+ 'word-break:break-word;padding:2px">' + body + '</div>';
|
||||
}
|
||||
|
||||
/** HTML 转义(纯文本走 RichText 时用,避免内容里的 < > 被当成标签)。 */
|
||||
export function escapeHtmlText(s: string): string {
|
||||
let out: string = '';
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c: string = s.charAt(i);
|
||||
if (c === '&') {
|
||||
out += '&';
|
||||
} else if (c === '<') {
|
||||
out += '<';
|
||||
} else if (c === '>') {
|
||||
out += '>';
|
||||
} else if (c === '"') {
|
||||
out += '"';
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -21,6 +21,14 @@ interface SendChatBody {
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
/** POST /chat/interrupt 的请求体。 */
|
||||
interface InterruptBody {
|
||||
/** true = 停止(立即结束当前推理 + 短路已排队消息);false/省略 = 普通中断。 */
|
||||
stop: boolean;
|
||||
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本发送:POST /chat。
|
||||
* 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。
|
||||
@ -167,10 +175,32 @@ export async function sendChatFile(text: string, path: string, name: string,
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前生成(停止按钮)。
|
||||
*
|
||||
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
|
||||
* - stop:true(无 message)= ①立即结束当前 LLM 推理(不重试);
|
||||
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
|
||||
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
|
||||
*
|
||||
* 为什么必须带 stop:此前这里 POST 的是 null(空 body),后端把空内容当成
|
||||
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
|
||||
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
|
||||
*/
|
||||
export async function interruptChat(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/chat/interrupt', null);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body: InterruptBody = { stop: true };
|
||||
await apiClient.post('/chat/interrupt', body);
|
||||
} catch (e) {
|
||||
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
|
||||
// 否则按钮会一直停在“停止”态,用户以为没生效。
|
||||
}
|
||||
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
|
||||
// 若后端稍后真的推来终态,SSE 处理器会再刷一次(幂等)。
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('已停止');
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
|
||||
@ -2,12 +2,17 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { looksLikeHtml, screensueHtml } from '../common/BridgeCaps';
|
||||
|
||||
/**
|
||||
* agent 主动推送的前台内容页。
|
||||
*
|
||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||
*
|
||||
* 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 BridgeCaps 的
|
||||
* screensueHtml)。HTML 用 RichText 渲染:它只解析 HTML 子集、无脚本、无网络,
|
||||
* 对 agent 下发的远端内容比 Web 组件安全(Web 默认带 JS 与网络能力)。
|
||||
*/
|
||||
@Component
|
||||
export struct ScreensuePage {
|
||||
@ -17,6 +22,11 @@ export struct ScreensuePage {
|
||||
onClose: () => void = () => {
|
||||
};
|
||||
|
||||
/** 本轮内容是不是 HTML(决定走 RichText 还是 Text)。 */
|
||||
private htmlMode(): boolean {
|
||||
return looksLikeHtml(this.pushedText);
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
GradientBackground()
|
||||
@ -44,13 +54,19 @@ export struct ScreensuePage {
|
||||
.width('100%')
|
||||
|
||||
Column() {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
if (this.htmlMode()) {
|
||||
// HTML:RichText 自己排版,外层用带主题背景的卡片承载。
|
||||
RichText(screensueHtml(this.pushedText, this.isDark))
|
||||
.width('100%')
|
||||
} else {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
|
||||
Reference in New Issue
Block a user