From 699b9181e6e9580dcaeafc89c58cea1b4c78a44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Thu, 7 May 2026 16:57:17 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 28 ++++++ web/static/i18n/en-US.json | 26 ++++++ web/static/i18n/zh-CN.json | 26 ++++++ web/static/js/auth.js | 9 +- web/static/js/chat.js | 114 ++++++++++++++++++++++++- web/static/js/monitor.js | 170 +++++++++++++++++++++++++++++++------ web/templates/index.html | 51 +++++++++++ 7 files changed, 395 insertions(+), 29 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 937bb1c5..32dccec7 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3196,6 +3196,12 @@ header { border-color: rgba(220, 53, 69, 0.3); } +.status-chip.status-cancelled { + background: rgba(108, 117, 125, 0.12); + color: var(--text-secondary, #6c757d); + border-color: rgba(108, 117, 125, 0.35); +} + .status-chip.status-pending, .status-chip.status-unknown { background: rgba(255, 193, 7, 0.12); @@ -3203,6 +3209,18 @@ header { border-color: rgba(255, 193, 7, 0.3); } +.detail-abort-hint { + font-size: 0.875rem; + opacity: 0.88; + margin: 0 0 10px; + line-height: 1.45; +} + +.detail-abort-section .btn-monitor-abort { + border-color: rgba(253, 126, 20, 0.55); + color: #fd7e14; +} + .detail-code-card { background: var(--bg-secondary); border: 1px dashed rgba(0, 0, 0, 0.06); @@ -5517,6 +5535,16 @@ header { color: var(--error-color); } +.monitor-status-chip.cancelled { + background: rgba(108, 117, 125, 0.15); + color: var(--text-muted, #6c757d); +} + +.monitor-execution-actions .btn-monitor-abort { + border-color: rgba(253, 126, 20, 0.55); + color: #fd7e14; +} + .monitor-execution-actions { display: flex; align-items: center; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 3ab8a6dc..0fc59747 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -394,6 +394,16 @@ "tasks": { "title": "Task Management", "stopTask": "Stop task", + "interruptModalTitle": "Interrupt current step", + "interruptReasonLabel": "Interrupt note", + "interruptModalHint": "Your note is saved as a user message and the agent continues in the same stream. Use \"Stop completely\" to end the 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...", + "interruptConfirmContinue": "Interrupt & continue", + "interruptHardStop": "Stop completely", + "interruptModalClose": "Close", + "userInterruptTimelineTitle": "User interrupt note (continuing)", "collapseDetail": "Collapse details", "newTask": "New task", "autoRefresh": "Auto refresh", @@ -1260,6 +1270,8 @@ "statusCompleted": "Completed", "statusRunning": "Running", "statusFailed": "Failed", + "statusCancelled": "Cancelled", + "terminateExecution": "Stop", "loading": "Loading...", "noStatsData": "No statistical data", "noExecutions": "No execution records", @@ -1727,8 +1739,22 @@ "statusRunning": "Running", "statusCompleted": "Completed", "statusFailed": "Failed", + "statusCancelled": "Cancelled", "unknown": "Unknown", "getDetailFailed": "Failed to get details", + "runningNoResponseYet": "No output yet; the tool may still be running. If it hangs, use \"Stop tool\" below to end this call only.", + "abortTitle": "Execution control", + "abortHint": "Stops only this tool call. The conversation / multi-step task continues (unlike stopping the whole task).", + "abortBtn": "Stop tool", + "abortConfirm": "Stop this tool call? The overall conversation or iterative task will not be cancelled.", + "abortSuccess": "Cancellation requested; status will update when the tool returns.", + "abortFailed": "Failed to stop tool", + "abortNoteModalTitle": "Stop tool with a note", + "abortNoteModalHint": "Optional: why you stopped or how the model should continue. The model sees any tool output first, then a labeled block (USER INTERRUPT NOTE — not raw tool output), then your text. Leave empty for a plain stop.", + "abortNoteLabel": "Note (optional)", + "abortNotePlaceholder": "e.g. Output is enough—skip waiting and continue…", + "abortNoteSubmit": "Stop tool", + "abortNoteClose": "Cancel", "execSuccessNoContent": "Execution succeeded with no displayable content.", "time": "Time", "executionId": "Execution ID", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index adcbce99..2edc969d 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -383,6 +383,16 @@ "tasks": { "title": "任务管理", "stopTask": "停止任务", + "interruptModalTitle": "中断当前步骤", + "interruptReasonLabel": "中断说明", + "interruptModalHint": "填写说明后将作为一条用户消息写入对话,智能体在同一会话内继续迭代。若只需完全停止任务,请点「彻底停止」。", + "interruptReasonPlaceholder": "例如:工具耗时过长,请先跳过并总结当前结果…", + "interruptReasonRequired": "请填写中断说明,以便模型根据你的意图继续。", + "interruptSubmitting": "提交中...", + "interruptConfirmContinue": "中断并继续", + "interruptHardStop": "彻底停止", + "interruptModalClose": "关闭", + "userInterruptTimelineTitle": "用户中断说明(继续迭代)", "collapseDetail": "收起详情", "newTask": "新建任务", "autoRefresh": "自动刷新", @@ -1249,6 +1259,8 @@ "statusCompleted": "已完成", "statusRunning": "执行中", "statusFailed": "失败", + "statusCancelled": "已终止", + "terminateExecution": "终止", "loading": "加载中...", "noStatsData": "暂无统计数据", "noExecutions": "暂无执行记录", @@ -1716,8 +1728,22 @@ "statusRunning": "执行中", "statusCompleted": "已完成", "statusFailed": "失败", + "statusCancelled": "已终止", "unknown": "未知", "getDetailFailed": "获取详情失败", + "runningNoResponseYet": "尚无返回,工具可能仍在执行。若长时间无响应,可使用下方「终止工具」结束本次调用。", + "abortTitle": "运行控制", + "abortHint": "仅中断当前这一次工具调用;对话与多步迭代任务会继续,不会等同于「停止任务」。", + "abortBtn": "终止工具", + "abortConfirm": "确定终止此次工具调用?整条对话或迭代任务不会因此停止。", + "abortSuccess": "已发送终止请求,工具返回后状态将更新。", + "abortFailed": "终止失败", + "abortNoteModalTitle": "终止工具并补充说明", + "abortNoteModalHint": "可选:说明为何终止或希望模型如何继续。提交后模型会先看到工具已输出内容(若有),再看到带「用户终止说明」标题的独立区块(中英标注,与命令行原文区分),最后是您的文字。留空则与原先仅终止一致。", + "abortNoteLabel": "终止说明(可选)", + "abortNotePlaceholder": "例如:输出已够判断,请停止等待并继续下一步…", + "abortNoteSubmit": "提交终止", + "abortNoteClose": "取消", "execSuccessNoContent": "执行成功,未返回可展示的文本内容。", "time": "时间", "executionId": "执行 ID", diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 3b9c3579..f1a59adb 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -306,12 +306,13 @@ async function bootstrapApp() { // 通用工具函数 function getStatusText(status) { + const s = (status && String(status).toLowerCase()) || ''; if (typeof window.t !== 'function') { - const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' }; - return fallback[status] || status; + const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败', cancelled: '已终止' }; + return fallback[s] || status; } - const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' }; - const key = keyMap[status]; + const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled' }; + const key = keyMap[s]; return key ? window.t(key) : status; } diff --git a/web/static/js/chat.js b/web/static/js/chat.js index ec1b04f3..94e78095 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2446,7 +2446,24 @@ async function showMCPDetail(executionId) { } } } else { - responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据'; + if (normalizedStatus === 'running') { + responseElement.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.runningNoResponseYet') : '尚无返回,工具可能仍在执行。若长时间无响应,可在下方终止本次调用。'; + } else { + responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据'; + } + } + + const abortSection = document.getElementById('detail-abort-section'); + const abortBtn = document.getElementById('detail-abort-btn'); + if (abortSection && abortBtn) { + if (normalizedStatus === 'running') { + abortSection.style.display = 'block'; + abortBtn.dataset.execId = exec.id || ''; + abortBtn.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.abortBtn') : '终止工具'; + } else { + abortSection.style.display = 'none'; + delete abortBtn.dataset.execId; + } } // 显示模态框 @@ -2464,6 +2481,101 @@ function closeMCPDetail() { document.getElementById('mcp-detail-modal').style.display = 'none'; } +/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */ +async function abortMCPToolExecutionFromDetail() { + const btn = document.getElementById('detail-abort-btn'); + const id = btn && btn.dataset.execId; + if (!id) { + return; + } + await cancelMCPToolExecution(id, { refreshDetail: true }); +} + +/** + * 打开 MCP 工具终止弹窗(说明会经服务端加上「用户终止说明」标题块后与工具输出合并给模型) + * @param {string} executionId + * @param {{ refreshDetail?: boolean }} [options] + */ +function openMcpToolAbortModal(executionId, options = {}) { + window.__mcpToolAbortContext = { executionId: executionId, options: options || {} }; + const ta = document.getElementById('mcp-tool-abort-note'); + if (ta) { + ta.value = ''; + } + const m = document.getElementById('mcp-tool-abort-modal'); + if (m) { + m.style.display = 'block'; + } +} + +function closeMcpToolAbortModal() { + window.__mcpToolAbortContext = null; + const m = document.getElementById('mcp-tool-abort-modal'); + if (m) { + m.style.display = 'none'; + } +} + +async function submitMcpToolAbortModal() { + const ctx = window.__mcpToolAbortContext; + if (!ctx || !ctx.executionId) { + closeMcpToolAbortModal(); + return; + } + const note = (document.getElementById('mcp-tool-abort-note') && document.getElementById('mcp-tool-abort-note').value || '').trim(); + const executionId = ctx.executionId; + const options = ctx.options || {}; + closeMcpToolAbortModal(); + await cancelMCPToolExecutionSubmit(executionId, note, options); +} + +/** + * 提交终止请求(body: { note }) + * @param {string} executionId + * @param {string} userNote + * @param {{ refreshDetail?: boolean }} [options] + */ +async function cancelMCPToolExecutionSubmit(executionId, userNote, options = {}) { + if (!executionId) { + return; + } + try { + const res = await apiFetch(`/api/monitor/execution/${encodeURIComponent(executionId)}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: userNote || '' }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(body.error || body.message || res.statusText); + } + const okMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortSuccess') : '已发送终止请求'; + alert(okMsg); + if (options.refreshDetail && typeof showMCPDetail === 'function') { + await showMCPDetail(executionId); + } + if (typeof refreshMonitorPanel === 'function') { + const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page) ? monitorState.pagination.page : 1; + await refreshMonitorPanel(page); + } + } catch (e) { + const failMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortFailed') : '终止失败'; + alert(failMsg + ': ' + (e && e.message ? e.message : String(e))); + } +} + +/** + * 取消单次 MCP 工具执行(监控页「终止」)。弹出说明框后提交;仅取消该次 tools/call,不停止整条对话/迭代任务。 + * @param {string} executionId + * @param {{ refreshDetail?: boolean }} [options] + */ +async function cancelMCPToolExecution(executionId, options = {}) { + if (!executionId) { + return; + } + openMcpToolAbortModal(executionId, options); +} + // 复制详情面板中的内容 function copyDetailBlock(elementId, triggerBtn = null) { const target = document.getElementById(elementId); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index e955fdad..a05ce992 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -1,4 +1,6 @@ const progressTaskState = new Map(); +/** @type {{ progressId: string, conversationId: string } | null} */ +let userInterruptModalPending = null; let activeTaskInterval = null; const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次 const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']); @@ -410,6 +412,128 @@ async function requestCancel(conversationId) { return result; } +/** 用户填写说明后中断当前步骤,由后端写入对话并继续同一条流式迭代 */ +async function requestCancelWithContinue(conversationId, reason) { + const response = await apiFetch('/api/agent-loop/cancel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + conversationId, + reason: reason || '', + continueAfter: true, + }), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败')); + } + return result; +} + +function openUserInterruptModal(progressId, conversationId) { + userInterruptModalPending = { progressId, conversationId }; + const ta = document.getElementById('user-interrupt-reason'); + if (ta) { + ta.value = ''; + } + const m = document.getElementById('user-interrupt-modal'); + if (m) { + m.style.display = 'block'; + } +} + +function closeUserInterruptModal() { + userInterruptModalPending = null; + const m = document.getElementById('user-interrupt-modal'); + if (m) { + m.style.display = 'none'; + } +} + +async function submitUserInterruptContinue() { + if (!userInterruptModalPending) { + return; + } + const reason = (document.getElementById('user-interrupt-reason') && document.getElementById('user-interrupt-reason').value || '').trim(); + if (!reason) { + alert(typeof window.t === 'function' ? window.t('tasks.interruptReasonRequired') : '请填写中断说明'); + return; + } + const { progressId, conversationId } = userInterruptModalPending; + closeUserInterruptModal(); + const stopBtn = document.getElementById(`${progressId}-stop-btn`); + try { + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...'; + } + await requestCancelWithContinue(conversationId, reason); + loadActiveTasks(); + } catch (error) { + console.error('中断并继续失败:', error); + alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '操作失败') + ': ' + error.message); + } finally { + if (stopBtn) { + stopBtn.disabled = false; + stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务'; + } + } +} + +async function submitUserInterruptHardCancel() { + if (!userInterruptModalPending) { + return; + } + const { progressId } = userInterruptModalPending; + closeUserInterruptModal(); + await performHardCancelProgressTask(progressId); +} + +/** 彻底停止任务(原「停止任务」行为) */ +async function performHardCancelProgressTask(progressId) { + const state = progressTaskState.get(progressId); + const stopBtn = document.getElementById(`${progressId}-stop-btn`); + + if (!state || !state.conversationId) { + if (stopBtn) { + stopBtn.disabled = true; + setTimeout(() => { + stopBtn.disabled = false; + }, 1500); + } + alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。'); + return; + } + + if (state.cancelling) { + return; + } + + markProgressCancelling(progressId); + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...'; + } + + try { + await requestCancel(state.conversationId); + loadActiveTasks(); + } catch (error) { + console.error('取消任务失败:', error); + alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message); + if (stopBtn) { + stopBtn.disabled = false; + stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务'; + } + const currentState = progressTaskState.get(progressId); + if (currentState) { + currentState.cancelling = false; + } + } +} + function addProgressMessage() { const messagesDiv = document.getElementById('chat-messages'); const messageDiv = document.createElement('div'); @@ -737,7 +861,7 @@ function toggleProcessDetails(progressId, assistantMessageId) { } } -// 停止当前进度对应的任务 +// 停止当前进度:弹出「中断并说明 / 彻底停止」 async function cancelProgressTask(progressId) { const state = progressTaskState.get(progressId); const stopBtn = document.getElementById(`${progressId}-stop-btn`); @@ -757,27 +881,7 @@ async function cancelProgressTask(progressId) { return; } - markProgressCancelling(progressId); - if (stopBtn) { - stopBtn.disabled = true; - stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...'; - } - - try { - await requestCancel(state.conversationId); - loadActiveTasks(); - } catch (error) { - console.error('取消任务失败:', error); - alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message); - if (stopBtn) { - stopBtn.disabled = false; - stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务'; - } - const currentState = progressTaskState.get(progressId); - if (currentState) { - currentState.cancelling = false; - } - } + openUserInterruptModal(progressId, state.conversationId); } // 将进度消息转换为可折叠的详情组件 @@ -1414,6 +1518,18 @@ function handleStreamEvent(event, progressElement, progressId, break; } + case 'user_interrupt_continue': { + const d = event.data || {}; + const reason = (d.reason != null && String(d.reason).trim() !== '') ? String(d.reason).trim() : (event.message || ''); + const timelineTitle = typeof window.t === 'function' ? window.t('tasks.userInterruptTimelineTitle') : '用户中断说明(继续迭代)'; + addTimelineItem(timeline, 'user_interrupt', { + title: '✋ ' + timelineTitle, + message: reason, + data: d, + }); + break; + } + case 'progress': const progressTitle = document.querySelector(`#${progressId} .progress-title`); if (progressTitle) { @@ -2777,7 +2893,8 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情'; const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除'; const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录'; - const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' }; + const terminateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.terminateExecution') : '终止'; + const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed', cancelled: 'statusCancelled' }; const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined; const rows = executions .map(exec => { @@ -2788,7 +2905,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel; const duration = formatExecutionDuration(exec.startTime, exec.endTime); const toolName = escapeHtml(exec.toolName || unknownToolLabel); - const executionId = escapeHtml(exec.id || ''); + const rawExecId = exec.id || ''; + const executionId = escapeHtml(rawExecId); + const terminateBtn = status === 'running' + ? `` + : ''; return ` @@ -2801,6 +2922,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
+ ${terminateBtn}
diff --git a/web/templates/index.html b/web/templates/index.html index 40151d9c..216b2d52 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1053,6 +1053,7 @@ + @@ -2449,6 +2450,13 @@ +

请求参数

@@ -2489,6 +2497,49 @@
+ + + + + +