diff --git a/internal/agent/core/rtchild_r11_test.go b/internal/agent/core/rtchild_r11_test.go
index dec7b8fb..433cab7b 100644
--- a/internal/agent/core/rtchild_r11_test.go
+++ b/internal/agent/core/rtchild_r11_test.go
@@ -188,10 +188,32 @@ func TestRTChild_RapidInputCollapsesToLatest(t *testing.T) {
if !ok {
t.Fatal("实例不存在")
}
- if st.Rounds > 3 {
+ // ★ 判据是「**绝大多数输入被覆盖**」,不是「轮次必须 ≤3」。
+ //
+ // ## 为什么不能只看 Rounds(实测:本判据曾偶发红)
+ //
+ // rtCountingProvider 的 ChatStream **同步瞬间返回**(缓冲 2 的 channel
+ // 里塞好就 close),于是消费循环的每一轮是微秒级;而连发 50 条本身
+ // 也只花 ~80µs(探针实测)。两者同量级 ⇒ 消费方有机会在连发过程中
+ // 抢到几次执行,轮次可能到 5。
+ //
+ // 这不是缺陷:探针实测 dropped=48、rounds=2,collapse 明明生效了。
+ // 真实通话里每轮要几十毫秒(LLM 往返),而连发是微秒级,
+ // 所以"轮次≈1"在生产里自然成立 —— 用微秒级 provider 去要求它,
+ // 是在测调度抖动,不是在测契约。
+ //
+ // ## 真正该钉的是什么
+ //
+ // 契约 1/3 的核心是「**不排队**」:绝大多数输入必须被**覆盖丢弃**,
+ // 而不是逐条被处理。所以断言 dropped 占绝对多数。
+ if st.Rounds > burst/4 {
t.Fatalf("连发 %d 条却跑了 %d 轮 —— rtSlot 退化成了队列(契约 1/3:最新者胜、不排队)",
burst, st.Rounds)
}
+ if st.Dropped < burst/2 {
+ t.Fatalf("dropped=%d(连发 %d 条)—— 绝大多数输入应被覆盖丢弃,"+
+ "而不是逐条处理(契约 3:不吃队列、不补偿)", st.Dropped, burst)
+ }
if st.Dropped == 0 {
t.Fatal("连发 50 条但 dropped=0 —— 覆盖没有发生,或计数器没接线")
}
diff --git a/internal/plugins/webui/chat_attachment_img_test.go b/internal/plugins/webui/chat_attachment_img_test.go
new file mode 100644
index 00000000..dbc30aa9
--- /dev/null
+++ b/internal/plugins/webui/chat_attachment_img_test.go
@@ -0,0 +1,93 @@
+package webui
+
+// 聊天图片附件的 HTML 必须**能被浏览器正确切分**(2026-10-30)。
+//
+// ## 缺陷现场
+//
+// 生产截图:图片明明加载成功了(img 元素在、src 正确),界面上却多出一行
+// 蓝色文字「图片加载失败"/>」。
+//
+// 根因是内联事件属性里用了反斜杠转义:
+//
+// onerror="this.parentElement.innerHTML='图片加载失败'"
+//
+// **HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
+// `图片加载失败'"/>` 变成**文本节点**直接显示。
+// 而那个 onerror 因为语法错误永远不会执行 —— 真失败时反而什么都不显示。
+//
+// ## 为什么这条判据值得写
+//
+// 这种缺陷**不会让任何构建或测试失败**:JSON 合法、JS 语法合法、
+// 字符串拼接合法。它只在浏览器里错,而且症状("加载成功却显示加载失败")
+// 指向的方向与真因(引号归属)完全不同。
+//
+// 判据做法:按正则把 HTML 里所有 `name="value"` 属性切出来,
+// 断言每个值内部**不含裸引号**,并断言页面里没有残留的 `"/>` 文本片段。
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// attrRe 匹配 HTML 属性。用它模拟浏览器"走到下一个引号就闭合"的行为。
+var attrRe = regexp.MustCompile(`([a-zA-Z_:][-a-zA-Z0-9_:.]*)="([^"]*)"`)
+
+// TestChatAttachmentImgHasNoBrokenInlineHandler 钉住附件图片的 HTML 形态。
+func TestChatAttachmentImgHasNoBrokenInlineHandler(t *testing.T) {
+ js := stripJSComments(dashboardJS(t))
+ if !strings.Contains(js, "chat-attachment-img") {
+ t.Fatal("dashboardJS 里找不到 chat-attachment-img —— 附件图片渲染被改名或删了?")
+ }
+
+ // ① 内联 onerror 不能再出现:它是本缺陷的载体。
+ //
+ // 真正的失败处理已改为**捕获阶段的事件委托**(error 事件不冒泡,
+ // 只能在捕获阶段拿到)。所以这里断言"没有内联 onerror"是合理的收紧,
+ // 而不是"不许处理错误"。
+ if strings.Contains(js, `onerror="this.parentElement`) {
+ t.Error("★ 附件图片仍在用内联 onerror —— " +
+ "HTML 属性不支持反斜杠转义,属性会被提前闭合、剩余部分变成可见文本。" +
+ "请用捕获阶段的 error 事件委托(见 renderChat 里的 addEventListener(\"error\", ..., true))")
+ }
+
+ // ② 失败占位必须走 DOM API,不能拼 innerHTML 字符串。
+ //
+ // innerHTML 拼接是这条缺陷的来源(要在字符串里同时处理 HTML 引号、
+ // JS 引号、CSS 选择器)。用 createElement + textContent 只需处理数据。
+ if !strings.Contains(js, `span.className = "att-err"`) {
+ t.Error("找不到 .att-err 的 DOM 构造 —— 失败占位应改用 createElement/textContent")
+ }
+
+ // ③ 必须有捕获阶段的 error 委托(缺了它,图片失败时用户看到破图无提示)。
+ //
+ // 注意:error 事件不冒泡,`addEventListener("error", fn)` 不带 true
+ // 是**收不到** img 加载失败的 —— 这条注释是为了让改的人别再踩。
+ if !regexp.MustCompile(`addEventListener\(\s*"error"`).MatchString(js) {
+ t.Error("缺少 error 事件监听 —— 图片加载失败时不会显示占位")
+ }
+}
+
+// TestDashboardNoEscapedQuoteInHTMLAttributes 是**通用**的同类缺陷扫描。
+//
+// 它不针对某一行,而是扫整个 dashboard.js:凡是形如 `="...\"..."` 的
+// 拼接,都会在浏览器里被提前闭合。
+//
+// 这条判据的价值在于:下次别人再写内联事件(onclick/onerror/onload…)
+// 并试图用 \" 转义时,这里会红,而不是等到生产上出现一行诡异的可见文本。
+func TestDashboardNoEscapedQuoteInHTMLAttributes(t *testing.T) {
+ js := stripJSComments(dashboardJS(t))
+ // 找 `="` 之后到下一个 `"` 之间出现反斜杠转义引号的片段。
+ // 注意要排除 JS 字符串里的普通转义(如 "a\"b" 作为纯数据),
+ // 所以只匹配**紧跟在 HTML 属性名后面**的形态:空白 + 名字 + = "
+ bad := regexp.MustCompile(`[a-zA-Z-]+="[^"]*\\"`)
+ if m := bad.FindString(js); m != "" {
+ t.Errorf("★ 发现 HTML 属性内用反斜杠转义引号(浏览器会提前闭合属性):\n %s\n"+
+ "HTML 属性值里没有转义序列 —— 需要嵌套引号时改用单引号包属性值,"+
+ "或改用 DOM API / 事件委托。", m)
+ }
+}
diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css
index 12710a16..9db27eb1 100644
--- a/internal/plugins/webui/dashboard.css
+++ b/internal/plugins/webui/dashboard.css
@@ -1590,6 +1590,27 @@
border-radius: 6px;
}
+ /* ===== 聊天附件(图片/音频)=====
+ 这两个类此前**只有类名没有样式**:JS 里写着 class="chat-attachment-img"
+ 和 class="att-err",CSS 里一条都没有。图片靠内联 style 撑着还能看,
+ 而失败占位 .att-err 完全没有样式 —— 即使事件接上了也只是一行裸文字。 */
+ .chat-attachment-img {
+ max-width: 320px;
+ max-height: 240px;
+ border-radius: 10px;
+ display: block;
+ cursor: zoom-in;
+ }
+ .att-err {
+ display: inline-block;
+ padding: 8px 12px;
+ border: 1px dashed var(--kv-border);
+ border-radius: 8px;
+ color: var(--text-muted, #888);
+ font-size: 12px;
+ line-height: 1.4;
+ }
+
/* ===== 思考卡片(PiDeck 风格)===== */
.reasoning-card {
margin: 6px 0;
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js
index b64a1636..98120576 100644
--- a/internal/plugins/webui/dashboard.js
+++ b/internal/plugins/webui/dashboard.js
@@ -2370,6 +2370,44 @@ function renderChat() {
},
true,
);
+ // 图片加载失败:**必须用捕获阶段**的委托,不能写成内联 onerror。
+ //
+ // ## 原来坏在哪(2026-10-30 实测)
+ //
+ // 原代码是:
+ // onerror="this.parentElement.innerHTML='图片加载失败'"
+ //
+ // HTML **属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,
+ // 于是 onerror 属性在 图片加载失败'"/>` 变成**文本节点**直接显示。
+ //
+ // 症状因此极具误导性:图片明明加载成功了(img 元素在、src 正确),
+ // 界面上却多出一行「图片加载失败"/>」;而那个 onerror 本身因为语法
+ // 错误永远不会执行 —— 真失败时反而什么都不显示。
+ //
+ // ## 为什么用 error 捕获而不是重写内联
+ //
+ // error 事件**不冒泡**,只有在捕获阶段才能委托到容器上。
+ // 改内联也能修(用单引号包属性值 + 不嵌套引号),但那仍然要求
+ // 以后每次拼 HTML 都记得转义规则;委托把这条规则从"每次都要做对"
+ // 变成"只在一处写对"。
+ msgsEl.addEventListener(
+ "error",
+ (e) => {
+ var t = e.target;
+ if (!t || t.tagName !== "IMG" || !t.classList.contains("chat-attachment-img")) {
+ return;
+ }
+ var box = t.parentElement;
+ if (!box) return;
+ var span = document.createElement("span");
+ span.className = "att-err";
+ span.textContent = __("图片加载失败", "Image failed to load");
+ box.replaceChildren(span);
+ },
+ true,
+ );
}
var msgs = state.messages;
var sig =
@@ -2470,7 +2508,7 @@ function renderChat() {
escHtml(att.url) +
'" ' +
'alt="image" loading="lazy" style="max-width:320px;max-height:240px;border-radius:10px;display:block;cursor:zoom-in" ' +
- 'onerror="this.parentElement.innerHTML=\'图片加载失败\'"/>';
+ '/>';
} else if (att.type === "audio") {
// 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。
// preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。