From c62ff3bde9c160c16cca3a77ca3f90c9515ed5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Sun, 10 May 2026 20:29:34 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 10 ++++ web/static/js/chat.js | 28 +++++++++-- web/static/js/monitor.js | 103 +++++++++++++++++++++++++++++--------- web/static/js/webshell.js | 26 ++++++++-- 4 files changed, 137 insertions(+), 30 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 32dccec7..a507268a 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3623,6 +3623,12 @@ header { line-height: 1.6; } +/* 流式增量阶段纯文本展示(避免半段 Markdown 反复解析) */ +.timeline-item-content.timeline-stream-plain { + white-space: pre-wrap; + word-break: break-word; +} + .tool-details { display: flex; flex-direction: column; @@ -18300,6 +18306,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) { transform: translateX(-50%) translateY(0); } +.chat-files-toast.chat-toast--error { + background: #b91c1c; +} + /* 对话附件读取 / 文件管理上传 进度条 */ /* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */ .chat-upload-progress-row[hidden] { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 94e78095..58c0ce4a 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -51,6 +51,28 @@ const HITL_MODE_REVIEW_EDIT = 'review_edit'; const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT]; let hitlApplyFeedbackTimer = null; +/** 非阻塞提示(与 chat-files-toast 样式共用) */ +function showChatToast(message, type) { + const text = message == null ? '' : String(message); + if (!text) return; + const el = document.createElement('div'); + el.className = 'chat-files-toast' + (type === 'error' ? ' chat-toast--error' : ''); + el.setAttribute('role', 'status'); + el.textContent = text; + document.body.appendChild(el); + requestAnimationFrame(function () { + el.classList.add('chat-files-toast-visible'); + }); + const hideMs = type === 'error' ? 4500 : 2600; + setTimeout(function () { + el.classList.remove('chat-files-toast-visible'); + setTimeout(function () { el.remove(); }, 300); + }, hideMs); +} +if (typeof window !== 'undefined') { + window.showChatToast = showChatToast; +} + function normalizeOrchestrationClient(s) { const v = String(s || '').trim().toLowerCase().replace(/-/g, '_'); if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute'; @@ -293,7 +315,7 @@ function showHitlApplyFeedback(text, isError, partial) { } if (!el) { if (text && isError) { - alert(text); + showChatToast(text, 'error'); } return; } @@ -2853,7 +2875,7 @@ async function loadConversation(conversationId) { const conversation = await response.json(); if (!response.ok) { - alert('加载对话失败: ' + (conversation.error || '未知错误')); + showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error'); return; } if (seq !== loadConversationRequestSeq) { @@ -3061,7 +3083,7 @@ async function loadConversation(conversationId) { } } catch (error) { console.error('加载对话失败:', error); - alert('加载对话失败: ' + error.message); + showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error'); } } diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index a0f4ff8c..163118c8 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -273,6 +273,47 @@ function escapeHtmlLocal(text) { return div.innerHTML; } +/** + * 与 internal/openai.normalizeStreamingDelta 一致:兼容网关/模型返回「累计全文」或整包重发, + * 避免前端 buffer += chunk 与后端已归一化的增量叠加导致逐段重复(如「响应中显示了响应中显示了」)。 + * @returns {[string, string]} [nextBuffer, effectiveDelta] + */ +function normalizeStreamingDeltaJs(current, incoming) { + const cur = current == null ? '' : String(current); + const inc = incoming == null ? '' : String(incoming); + if (inc === '') { + return [cur, '']; + } + if (cur === '') { + return [inc, inc]; + } + if (inc.startsWith(cur) && inc.length > cur.length) { + return [inc, inc.slice(cur.length)]; + } + const runeCount = Array.from(cur).length; + if (inc === cur && runeCount > 1) { + return [cur, '']; + } + return [cur + inc, inc]; +} +if (typeof window !== 'undefined') { + window.normalizeStreamingDeltaJs = normalizeStreamingDeltaJs; +} + +/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */ +function setTimelineItemContentStreamPlain(contentEl, text) { + if (!contentEl) return; + contentEl.classList.add('timeline-stream-plain'); + contentEl.textContent = text == null ? '' : String(text); +} + +/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */ +function setTimelineItemContentStreamRich(contentEl, html) { + if (!contentEl) return; + contentEl.classList.remove('timeline-stream-plain'); + contentEl.innerHTML = html; +} + function formatAssistantMarkdownContent(text) { const raw = text == null ? '' : String(text); if (typeof marked !== 'undefined') { @@ -1160,7 +1201,19 @@ function handleStreamEvent(event, progressElement, progressId, state = new Map(); thinkingStreamStateByProgressId.set(progressId, state); } - // 若已存在,重置 buffer + // 同一 streamId 重复 start:复用已有条目,避免孤儿卡片 + 新条目重复收 delta + if (state.has(streamId)) { + const ex = state.get(streamId); + ex.buffer = ''; + const existingItem = document.getElementById(ex.itemId); + if (existingItem) { + const contentEl = existingItem.querySelector('.timeline-item-content'); + if (contentEl) { + setTimelineItemContentStreamPlain(contentEl, ''); + } + } + break; + } const thinkBase = typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'; const title = timelineAgentBracketPrefix(d) + '🤔 ' + thinkBase; const itemId = addTimelineItem(timeline, 'thinking', { @@ -1182,17 +1235,14 @@ function handleStreamEvent(event, progressElement, progressId, const s = state.get(streamId); const delta = event.message || ''; - s.buffer += delta; + const merged = normalizeStreamingDeltaJs(s.buffer, delta); + s.buffer = merged[0]; const item = document.getElementById(s.itemId); if (item) { const contentEl = item.querySelector('.timeline-item-content'); if (contentEl) { - if (typeof formatMarkdown === 'function') { - contentEl.innerHTML = formatMarkdown(s.buffer); - } else { - contentEl.textContent = s.buffer; - } + setTimelineItemContentStreamPlain(contentEl, s.buffer); } } break; @@ -1210,11 +1260,10 @@ function handleStreamEvent(event, progressElement, progressId, if (item) { const contentEl = item.querySelector('.timeline-item-content'); if (contentEl) { - // contentEl.innerHTML 用于兼容 Markdown 展示 if (typeof formatMarkdown === 'function') { - contentEl.innerHTML = formatMarkdown(s.buffer); + setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer)); } else { - contentEl.textContent = s.buffer; + setTimelineItemContentStreamPlain(contentEl, s.buffer); } } } @@ -1456,6 +1505,18 @@ function handleStreamEvent(event, progressElement, progressId, stateMap = new Map(); einoAgentReplyStreamStateByProgressId.set(progressId, stateMap); } + if (stateMap.has(streamId)) { + const ex = stateMap.get(streamId); + ex.buffer = ''; + const existingItem = document.getElementById(ex.itemId); + if (existingItem) { + let contentEl = existingItem.querySelector('.timeline-item-content'); + if (contentEl) { + setTimelineItemContentStreamPlain(contentEl, ''); + } + } + break; + } const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...'; const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; const itemId = addTimelineItem(timeline, 'eino_agent_reply', { @@ -1477,7 +1538,8 @@ function handleStreamEvent(event, progressElement, progressId, const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId); if (!stateMap || !stateMap.has(streamId)) break; const s = stateMap.get(streamId); - s.buffer += delta; + const merged = normalizeStreamingDeltaJs(s.buffer, delta); + s.buffer = merged[0]; const item = document.getElementById(s.itemId); if (item) { let contentEl = item.querySelector('.timeline-item-content'); @@ -1490,11 +1552,7 @@ function handleStreamEvent(event, progressElement, progressId, } } if (contentEl) { - if (typeof formatMarkdown === 'function') { - contentEl.innerHTML = formatMarkdown(s.buffer); - } else { - contentEl.textContent = s.buffer; - } + setTimelineItemContentStreamPlain(contentEl, s.buffer); } } break; @@ -1522,9 +1580,9 @@ function handleStreamEvent(event, progressElement, progressId, item.appendChild(contentEl); } if (typeof formatMarkdown === 'function') { - contentEl.innerHTML = formatMarkdown(full); + setTimelineItemContentStreamRich(contentEl, formatMarkdown(full)); } else { - contentEl.textContent = full; + setTimelineItemContentStreamPlain(contentEl, full); } if (d.einoAgent != null && String(d.einoAgent).trim() !== '') { item.dataset.einoAgent = String(d.einoAgent).trim(); @@ -1665,7 +1723,8 @@ function handleStreamEvent(event, progressElement, progressId, } const deltaContent = event.message || ''; - state.buffer += deltaContent; + const mergedResp = normalizeStreamingDeltaJs(state.buffer, deltaContent); + state.buffer = mergedResp[0]; // 更新时间线条目内容 if (state.itemId) { @@ -1675,11 +1734,7 @@ function handleStreamEvent(event, progressElement, progressId, if (contentEl) { const meta = state.streamMeta || responseData; const body = formatTimelineStreamBody(state.buffer, meta); - if (typeof formatMarkdown === 'function') { - contentEl.innerHTML = formatMarkdown(body); - } else { - contentEl.textContent = body; - } + setTimelineItemContentStreamPlain(contentEl, body); } } } diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index d38cb8dc..52fbb69f 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -2898,7 +2898,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { } else if (_et === 'response_delta') { var deltaText = (_em != null && _em !== '') ? String(_em) : ''; if (deltaText) { - streamingTarget += deltaText; + var normR = (typeof window.normalizeStreamingDeltaJs === 'function') + ? window.normalizeStreamingDeltaJs(streamingTarget, deltaText) + : [streamingTarget + deltaText, deltaText]; + streamingTarget = normR[0]; webshellStreamingTypingId += 1; streamingTypingId = webshellStreamingTypingId; runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer); @@ -2952,6 +2955,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { // ─── Thinking (non-stream + stream) ─── } else if (_et === 'thinking_stream_start' && _ed.streamId) { + if (wsThinkingStreams.has(_ed.streamId)) { + var tsExist = wsThinkingStreams.get(_ed.streamId); + tsExist.buf = ''; + if (tsExist.body) tsExist.body.textContent = ''; + } else { var thinkSLabel = wsTOr('chat.aiThinking', 'AI 思考'); var thinkSItem = document.createElement('div'); thinkSItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-thinking'; @@ -2962,11 +2970,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { timelineContainer.appendChild(thinkSItem); timelineContainer.classList.add('has-items'); wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' }); + } if (!streamingTarget) assistantDiv.textContent = '…'; } else if (_et === 'thinking_stream_delta' && _ed.streamId) { var tsD = wsThinkingStreams.get(_ed.streamId); if (tsD) { - tsD.buf += (_em || ''); + var normT = (typeof window.normalizeStreamingDeltaJs === 'function') + ? window.normalizeStreamingDeltaJs(tsD.buf, _em || '') : [tsD.buf + (_em || ''), _em || '']; + tsD.buf = normT[0]; if (typeof formatMarkdown === 'function') { tsD.body.innerHTML = formatMarkdown(tsD.buf); } else { @@ -3076,6 +3087,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { // ─── Eino sub-agent reply streaming ─── } else if (_et === 'eino_agent_reply_stream_start' && _ed.streamId) { + if (einoSubReplyStreams.has(_ed.streamId)) { + var stExist = einoSubReplyStreams.get(_ed.streamId); + stExist.buf = ''; + var preExist = stExist.el && stExist.el.querySelector('.webshell-eino-reply-stream-body'); + if (preExist) preExist.textContent = ''; + } else { var repTS = wsTOr('chat.einoAgentReplyTitle', '子代理回复'); var runTS = wsTOr('timeline.running', '执行中...'); var itemS = document.createElement('div'); @@ -3084,11 +3101,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { timelineContainer.appendChild(itemS); timelineContainer.classList.add('has-items'); einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' }); + } if (!streamingTarget) assistantDiv.textContent = '…'; } else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) { var stD = einoSubReplyStreams.get(_ed.streamId); if (stD) { - stD.buf += (_em || ''); + var normS = (typeof window.normalizeStreamingDeltaJs === 'function') + ? window.normalizeStreamingDeltaJs(stD.buf, _em || '') : [stD.buf + (_em || ''), _em || '']; + stD.buf = normS[0]; var preD = stD.el.querySelector('.webshell-eino-reply-stream-body'); if (!preD) { preD = document.createElement('pre');