From acaf0887c7050118b7987a382156fb46959ee765 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 31 Oct 2026 10:03:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E8=81=8A=E5=A4=A9=E5=9B=BE?= =?UTF-8?q?=E7=89=87"=E5=8A=A0=E8=BD=BD=E6=88=90=E5=8A=9F=E5=8D=B4?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5"?= =?UTF-8?q?=E2=80=94=E2=80=94HTML=20=E5=B1=9E=E6=80=A7=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=8F=8D=E6=96=9C=E6=9D=A0=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷 生产截图:图片加载成功了(img 元素在、src 正确),界面却多显示一行 蓝字「图片加载失败"/>」。 根因是内联事件属性里用了反斜杠转义: onerror="this.parentElement.innerHTML='图片加载失败'" **HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在 `图片加载失败'"/>` 变成**文本节点**直接显示。 症状极具误导性:看起来像"图片坏了",实际图片是好的;而那个 onerror 因为语法错误**永不执行** —— 真失败时反而什么都不显示。 ## 修法 不用内联属性(那要求以后每次拼 HTML 都记得转义规则),改成 **捕获阶段的事件委托**: - error 事件**不冒泡**,只能在捕获阶段拿到(注释里写明了) - 用 createElement + textContent 构造占位,不再拼 innerHTML 字符串 - 只处理 .chat-attachment-img,不影响正文里的 markdown 图片 ## 顺带:两个类没有 CSS `chat-attachment-img` 与 `att-err` 只出现在 JS 里,CSS 里一条都没有。 图片靠内联 style 撑着还能看,失败占位则完全没有样式。已补。 ## 判据(2 条,变异验证) - `TestChatAttachmentImgHasNoBrokenInlineHandler`:不许内联 onerror、 必须用 DOM API、必须有 error 监听 - `TestDashboardNoEscapedQuoteInHTMLAttributes`:通用扫描, 任何 `name="...\"...` 形态都会红(下次别人写内联事件也拦得住) 变异:把内联 onerror 放回去 ⇒ 两条同时红。 ★ 写判据时踩到的坑(都已修):判据被**自己的注释**打红(注释里必须写出 错误写法来解释),需先用已有的 stripJSComments 剥离;另外我先自己写了一个 stripJSComments,与 dashboard_js_scope_test.go 的重名 —— 应复用已有的。 ## 顺带修一条脆弱判据 `TestRTChild_RapidInputCollapsesToLatest` 全量跑时偶发红 ("连发 50 条却跑了 5 轮")。 探针查实:连发 50 条只花 ~80µs,而测试 provider 的 ChatStream **同步瞬间 返回**,两者同量级 ⇒ 消费方能在连发过程中抢到几次执行。探针显示 dropped=48,collapse 明明生效了。 所以 `Rounds > 3` 是在测调度抖动,不是测契约。改为断言**覆盖主导** (dropped >= burst/2)。真实通话每轮几十毫秒,连发微秒级,生产里 "轮次≈1" 自然成立。 变异验证:让 dropped 不计数 ⇒ 判据红(dropped=0(连发 50 条))。 go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE --- internal/agent/core/rtchild_r11_test.go | 24 ++++- .../plugins/webui/chat_attachment_img_test.go | 93 +++++++++++++++++++ internal/plugins/webui/dashboard.css | 21 +++++ internal/plugins/webui/dashboard.js | 40 +++++++- 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 internal/plugins/webui/chat_attachment_img_test.go 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" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。