From 82d840966e2c57e0ce3d8f972d87258e4d1179f6 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 21:34:34 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 5 +++ web/static/i18n/en-US.json | 3 +- web/static/i18n/zh-CN.json | 3 +- web/static/js/chat.js | 15 ++++++++ web/static/js/monitor.js | 76 ++++++++++++++++++++++++++++++++------ 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index a507268a..4938163b 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3593,6 +3593,11 @@ header { background: rgba(255, 112, 67, 0.12); } +.timeline-item-user_interrupt_continue { + border-left-color: #d97706; + background: rgba(217, 119, 6, 0.08); +} + .timeline-item-header { display: flex; align-items: center; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 888f196b..8661d3d4 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -288,6 +288,7 @@ "error": "Error", "streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.", "taskCancelled": "Task cancelled", + "userInterruptContinueTitle": "⏸️ User interrupt & continue", "unknownTool": "Unknown tool", "einoAgentReplyTitle": "Sub-agent reply", "einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})", @@ -396,7 +397,7 @@ "stopTask": "Stop task", "interruptModalTitle": "Interrupt current step", "interruptReasonLabel": "Interrupt note", - "interruptModalHint": "Same as MCP monitor \"Stop tool\": ends only the in-flight tool call; the conversation and this run continue. Optional note is merged into the tool result (bilingual USER INTERRUPT NOTE, not raw CLI). Leave empty for a plain stop. If no tool is running yet (model still thinking), wait for a tool call or use \"Stop completely\".", + "interruptModalHint": "When a tool is running: same as MCP monitor \"Stop tool\" — only that call is stopped and the run continues; your note can be merged into the tool result (USER INTERRUPT NOTE). When no tool is running (model thinking/streaming only): \"Interrupt & continue\" still works — current output pauses, your note is merged into context and the run resumes automatically; the progress timeline shows a \"User interrupt & continue\" entry. Use this instead of a full stop when you only want to steer; use \"Stop completely\" to end the whole task.", "interruptReasonPlaceholder": "e.g. Tool is too slow—skip and summarize…", "interruptReasonRequired": "Please enter a short note so the model can continue accordingly.", "interruptSubmitting": "Submitting...", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 508416df..a6a3b4f4 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -277,6 +277,7 @@ "error": "错误", "streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。", "taskCancelled": "任务已取消", + "userInterruptContinueTitle": "⏸️ 用户中断并继续", "unknownTool": "未知工具", "einoAgentReplyTitle": "子代理回复", "einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})", @@ -385,7 +386,7 @@ "stopTask": "停止任务", "interruptModalTitle": "中断当前步骤", "interruptReasonLabel": "中断说明", - "interruptModalHint": "与 MCP 监控页「终止工具」一致:仅结束当前这一次工具调用,整条对话与本轮推理会继续;工具返回中可附带说明(中英 USER INTERRUPT NOTE 块,与命令行原文区分)。留空则等同仅终止工具。若当前没有工具在执行(模型尚在思考),请等待工具开始或改用「彻底停止」。", + "interruptModalHint": "有工具在执行时:与 MCP 监控页「终止工具」一致,仅结束当前这一次工具调用,本轮推理会继续;说明可写入工具返回(USER INTERRUPT NOTE)。无工具在执行时(模型纯思考/流式输出):仍可「中断并继续」——会暂停当前输出,把你的说明合并进上下文并自动续跑;进度详情时间线会出现「用户中断并继续」条目。不需要整轮停止时请优先用本按钮;要结束整条任务请用「彻底停止」。", "interruptReasonPlaceholder": "例如:工具耗时过长,请先跳过并总结当前结果…", "interruptReasonRequired": "请填写中断说明,以便模型根据你的意图继续。", "interruptSubmitting": "提交中...", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 58c0ce4a..a768488f 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -26,6 +26,11 @@ const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟 // 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表) const MAX_CHAT_FILES = 10; const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。'; +/** 与 handler.formatInterruptContinueUserMessage 首段一致;主对话不展示,仅迭代详情(user_interrupt_continue) */ +const CHAT_INTERRUPT_CONTINUE_USER_PREFIX = '【用户补充 / 中断后继续】'; +function isInterruptContinueInjectChatMessage(content) { + return typeof content === 'string' && content.trimStart().startsWith(CHAT_INTERRUPT_CONTINUE_USER_PREFIX); +} /** * 对话附件:选文件后异步 POST /api/chat-uploads,发送时只传 serverPath(绝对路径),请求体不再内联大文件内容。 * @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise|null, uploadError: string|null }[]} @@ -2259,6 +2264,10 @@ function renderProcessDetails(messageId, processDetails) { itemTitle = agPx + '🧑‍⚖️ HITL · ' + hitlMsg; } else if (eventType === 'progress') { itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); + } else if (eventType === 'user_interrupt_continue') { + itemTitle = typeof window.t === 'function' + ? window.t('chat.userInterruptContinueTitle') + : '⏸️ 用户中断并继续'; } addTimelineItem(timeline, eventType, { @@ -2975,6 +2984,9 @@ async function loadConversation(conversationId) { // 渲染单条消息的辅助函数 const renderOneMessage = (msg) => { + if (msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) { + return; + } let displayContent = msg.content; if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) { for (let i = msg.processDetails.length - 1; i >= 0; i--) { @@ -6639,6 +6651,9 @@ function formatConversationAsMarkdown(conversation, options = {}) { } messages.forEach((msg, index) => { + if (msg && msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) { + return; + } const role = getConversationRoleLabel(msg && msg.role); const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt); const content = msg && typeof msg.content === 'string' ? msg.content : ''; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 163118c8..386a3724 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -784,19 +784,33 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut mcpSection.appendChild(buttonsContainer); } - const hasExecBtns = buttonsContainer.querySelector('.mcp-detail-btn:not(.process-detail-btn)'); - if (mcpIds.length > 0 && !hasExecBtns) { - mcpIds.forEach((execId, index) => { + let maxExecIndex = 0; + const existingExecBtns = buttonsContainer.querySelectorAll('.mcp-detail-btn:not(.process-detail-btn)'); + existingExecBtns.forEach(function (btn) { + const n = parseInt(btn.dataset.execIndex, 10); + if (!isNaN(n) && n > maxExecIndex) maxExecIndex = n; + }); + const seenExec = new Set(); + existingExecBtns.forEach(function (btn) { + if (btn.dataset.execId) seenExec.add(String(btn.dataset.execId).trim()); + }); + let appendedAny = false; + if (mcpIds.length > 0) { + mcpIds.forEach(function (execId) { + const id = execId != null ? String(execId).trim() : ''; + if (!id || seenExec.has(id)) return; + seenExec.add(id); + maxExecIndex += 1; + appendedAny = true; const detailBtn = document.createElement('button'); detailBtn.className = 'mcp-detail-btn'; - detailBtn.dataset.execId = execId; - detailBtn.dataset.execIndex = String(index + 1); - detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; - detailBtn.onclick = () => showMCPDetail(execId); + detailBtn.dataset.execId = id; + detailBtn.dataset.execIndex = String(maxExecIndex); + detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: maxExecIndex }) : '调用 #' + maxExecIndex) + ''; + detailBtn.onclick = function () { showMCPDetail(id); }; buttonsContainer.appendChild(detailBtn); }); - // 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求) - if (typeof batchUpdateButtonToolNames === 'function') { + if (appendedAny && typeof batchUpdateButtonToolNames === 'function') { batchUpdateButtonToolNames(buttonsContainer, mcpIds); } } @@ -1079,6 +1093,24 @@ function resolveStreamTimeline(progressId) { return timeline; } +/** 去重合并 MCP execution id(顺序:先 prev 后 next),用于多段 Run / 多次 SSE 同一任务。 */ +function mergeMcpExecutionIDLists(prev, next) { + const seen = new Set(); + const out = []; + const add = function (arr) { + if (!Array.isArray(arr)) return; + for (let i = 0; i < arr.length; i++) { + const s = arr[i] != null ? String(arr[i]).trim() : ''; + if (!s || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + }; + add(prev); + add(next); + return out; +} + // 处理流式事件 function handleStreamEvent(event, progressElement, progressId, getAssistantId, setAssistantId, getMcpIds, setMcpIds) { @@ -1320,6 +1352,19 @@ function handleStreamEvent(event, progressElement, progressId, }); break; + case 'user_interrupt_continue': { + const d = event.data || {}; + const titleBase = typeof window.t === 'function' + ? window.t('chat.userInterruptContinueTitle') + : '⏸️ 用户中断并继续'; + addTimelineItem(timeline, 'user_interrupt_continue', { + title: titleBase, + message: event.message || '', + data: d + }); + break; + } + case 'eino_stream_error': { const d = event.data || {}; const agent = d.einoAgent ? String(d.einoAgent) : ''; @@ -1672,7 +1717,7 @@ function handleStreamEvent(event, progressElement, progressId, const responseData = event.data || {}; const mcpIds = responseData.mcpExecutionIds || []; - setMcpIds(mcpIds); + setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds)); if (responseData.conversationId) { // 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略 @@ -1748,7 +1793,7 @@ function handleStreamEvent(event, progressElement, progressId, // 先更新 mcp ids const responseData = event.data || {}; - const mcpIds = responseData.mcpExecutionIds || []; + const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []); setMcpIds(mcpIds); // 更新对话ID @@ -2272,7 +2317,7 @@ async function attachRunningTaskEventStream(conversationId) { if (line.indexOf('data: ') === 0) { try { const eventData = JSON.parse(line.slice(6)); - handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; }); + handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); }); } catch (e) { console.error('task-events parse', e); } @@ -2485,6 +2530,11 @@ function addTimelineItem(timeline, type, options) { ${escapeHtml(options.message || taskCancelledLabel)} `; + } else if (type === 'user_interrupt_continue' && options.message) { + const streamBody = typeof formatTimelineStreamBody === 'function' + ? formatTimelineStreamBody(options.message, options.data) + : options.message; + content += `
${formatMarkdown(streamBody)}
`; } item.innerHTML = content; @@ -3386,6 +3436,8 @@ function refreshProgressAndTimelineI18n() { titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle'); } else if (type === 'cancelled') { titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled'); + } else if (type === 'user_interrupt_continue') { + titleSpan.textContent = _t('chat.userInterruptContinueTitle'); } else if (type === 'progress' && item.dataset.progressMessage !== undefined) { titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage; }