(function (root) { 'use strict'; const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget']); const POLL_INTERVAL_MS = 1500; const FINAL_STATE_HOLD_MS = 2400; function normalizeTask(raw, index) { const task = raw && typeof raw === 'object' ? raw : {}; const status = String(task.status || 'pending').trim().toLowerCase(); return { id: String(task.id || (index + 1)), subject: String(task.subject || '').trim(), description: String(task.description || '').trim(), activeForm: String(task.activeForm || '').trim(), status: ['pending', 'in_progress', 'completed', 'deleted'].includes(status) ? status : 'pending' }; } function deriveProgress(rawTasks) { const tasks = (Array.isArray(rawTasks) ? rawTasks : []) .map(normalizeTask) .filter((task) => task.status !== 'deleted'); let activeIndex = tasks.findIndex((task) => task.status === 'in_progress'); if (activeIndex < 0) activeIndex = tasks.findIndex((task) => task.status !== 'completed'); if (activeIndex < 0 && tasks.length) activeIndex = tasks.length - 1; return { tasks, total: tasks.length, completed: tasks.filter((task) => task.status === 'completed').length, activeStep: activeIndex >= 0 ? activeIndex + 1 : 0, allCompleted: tasks.length > 0 && tasks.every((task) => task.status === 'completed') }; } function applyTaskUpdate(rawTasks, args) { const update = args && typeof args === 'object' ? args : {}; const taskID = String(update.taskId || update.taskID || '').trim(); if (!taskID) return deriveProgress(rawTasks).tasks; return deriveProgress(rawTasks).tasks .filter((task) => !(String(update.status || '').toLowerCase() === 'deleted' && task.id === taskID)) .map((task) => { if (task.id !== taskID) return task; return Object.assign({}, task, { subject: String(update.subject || task.subject).trim(), description: String(update.description || task.description).trim(), activeForm: String(update.activeForm || task.activeForm).trim(), status: String(update.status || task.status).trim().toLowerCase() }); }); } if (typeof module !== 'undefined' && module.exports) { module.exports = { normalizeTask, deriveProgress, applyTaskUpdate }; return; } const state = { conversationId: '', tasks: [], signature: '', expanded: false, requestSequence: 0, abortController: null, pollTimer: null, refreshTimer: null, finalHoldUntil: 0, taskCalls: new Map() }; const host = root.document && root.document.getElementById('agent-plan-progress'); if (!host) return; let passiveHoverAnchor = null; function clearPassiveHoverVisual() { host.classList.remove('is-hover-active'); } function resetPassiveHover() { clearPassiveHoverVisual(); passiveHoverAnchor = null; } function disarmPassiveHover(event) { clearPassiveHoverVisual(); if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return; passiveHoverAnchor = { x: event.clientX, y: event.clientY }; } function armHoverAfterPointerMove(event) { if (event && event.pointerType && event.pointerType !== 'mouse') return; if (passiveHoverAnchor) { if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return; // Smooth scrolling and layout shifts may emit pointermove without the // physical pointer moving. Keep the panel locked until coordinates change. if (event.clientX === passiveHoverAnchor.x && event.clientY === passiveHoverAnchor.y) return; passiveHoverAnchor = null; } host.classList.add('is-hover-active'); } host.addEventListener('pointermove', armHoverAfterPointerMove); host.addEventListener('pointerleave', clearPassiveHoverVisual); const returnLatestButton = root.document.getElementById('chat-return-latest'); if (returnLatestButton) { // Clicking the return-to-latest control moves this plan chip downward. // Clear the hover gate before that layout shift so a stationary pointer // cannot accidentally reveal the plan panel underneath it. returnLatestButton.addEventListener('pointerdown', disarmPassiveHover); returnLatestButton.addEventListener('click', disarmPassiveHover); } function translate(key, fallback, params) { if (typeof root.t === 'function') { const value = root.t(key, params || {}); if (value && value !== key) return value; } let value = fallback; Object.entries(params || {}).forEach(([name, replacement]) => { value = value.replaceAll('{{' + name + '}}', String(replacement)); }); return value; } function createSVG(className, pathData) { const namespace = 'http://www.w3.org/2000/svg'; const svg = root.document.createElementNS(namespace, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.classList.add(className); const path = root.document.createElementNS(namespace, 'path'); path.setAttribute('d', pathData); svg.appendChild(path); return svg; } function statusIcon(status) { const icon = root.document.createElement('span'); icon.className = 'agent-plan-task-status agent-plan-task-status--' + status; icon.setAttribute('aria-hidden', 'true'); if (status === 'completed') { icon.appendChild(createSVG('agent-plan-task-check', 'M7.5 12.5 10.5 15.5 16.8 8.8')); } return icon; } function taskLabel(task) { if (task.status === 'in_progress' && task.activeForm) return task.activeForm; return task.subject || translate('chat.taskProgressUnnamed', '未命名任务'); } function render(force) { const progress = deriveProgress(state.tasks); const signature = JSON.stringify(progress.tasks) + '|' + state.expanded; if (!force && signature === state.signature) return; state.signature = signature; host.replaceChildren(); if (!progress.total) { host.hidden = true; resetPassiveHover(); return; } host.hidden = false; host.classList.toggle('is-open', state.expanded); const panel = root.document.createElement('div'); panel.className = 'agent-plan-progress-panel'; panel.id = 'agent-plan-progress-panel'; panel.setAttribute('role', 'status'); panel.setAttribute('aria-label', translate('chat.taskProgressDetails', '任务进度详情')); progress.tasks.forEach((task) => { const row = root.document.createElement('div'); row.className = 'agent-plan-task agent-plan-task--' + task.status; row.appendChild(statusIcon(task.status)); const label = root.document.createElement('span'); label.className = 'agent-plan-task-label'; label.textContent = taskLabel(task); if (task.description) label.title = task.description; row.appendChild(label); panel.appendChild(row); }); const trigger = root.document.createElement('button'); trigger.type = 'button'; trigger.className = 'agent-plan-progress-trigger'; trigger.setAttribute('aria-controls', panel.id); trigger.setAttribute('aria-expanded', state.expanded ? 'true' : 'false'); trigger.setAttribute('aria-label', translate('chat.taskProgressOpen', '查看任务进度')); trigger.appendChild(statusIcon(progress.allCompleted ? 'completed' : 'in_progress')); const count = root.document.createElement('span'); count.className = 'agent-plan-progress-count'; count.textContent = translate('chat.taskProgressStep', '第 {{current}} / {{total}} 步', { current: progress.activeStep, total: progress.total }); trigger.appendChild(count); trigger.addEventListener('click', () => { state.expanded = !state.expanded; render(true); if (state.expanded) host.querySelector('.agent-plan-progress-trigger')?.focus(); }); host.append(panel, trigger); } function currentConversationId() { return String(root.currentConversationId || '').trim(); } function setConversation(conversationId) { const next = String(conversationId || '').trim(); if (next === state.conversationId) return false; state.conversationId = next; state.tasks = []; state.signature = ''; state.expanded = false; state.finalHoldUntil = 0; state.taskCalls.clear(); resetPassiveHover(); state.requestSequence += 1; if (state.abortController) state.abortController.abort(); state.abortController = null; render(true); return true; } async function fetchPlanTasks() { const conversationId = currentConversationId(); setConversation(conversationId); if (!conversationId || root.document.hidden) return; const sequence = ++state.requestSequence; if (state.abortController) state.abortController.abort(); const controller = new AbortController(); state.abortController = controller; try { const fetcher = typeof root.apiFetch === 'function' ? root.apiFetch : root.fetch.bind(root); const response = await fetcher('/api/conversations/' + encodeURIComponent(conversationId) + '/plan-tasks', { signal: controller.signal }); if (!response.ok) throw new Error('HTTP ' + response.status); const payload = await response.json(); if (sequence !== state.requestSequence || conversationId !== state.conversationId) return; if (payload && payload.running === false) { state.tasks = []; state.expanded = false; state.finalHoldUntil = 0; render(false); return; } const tasks = deriveProgress(payload && payload.tasks).tasks; if (!tasks.length && state.tasks.length && Date.now() < state.finalHoldUntil) return; state.tasks = tasks; render(false); } catch (error) { if (error && error.name === 'AbortError') return; // A task list is supplemental UI; a transient poll failure must not // interfere with the chat or erase the last known task state. } finally { if (sequence === state.requestSequence) state.abortController = null; } } function scheduleRefresh(delay) { root.clearTimeout(state.refreshTimer); state.refreshTimer = root.setTimeout(fetchPlanTasks, Number(delay) || 0); } function handlePlanToolEvent(event) { const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {}; const conversationId = String(detail.conversationId || '').trim(); if (!conversationId || conversationId !== currentConversationId()) return; const data = detail.data && typeof detail.data === 'object' ? detail.data : {}; const toolName = String(data.toolName || '').trim().toLowerCase(); if (!PLAN_TOOL_NAMES.has(toolName)) return; const callId = String(data.toolCallId || '').trim(); if (detail.eventType === 'tool_call') { if (callId) state.taskCalls.set(callId, { toolName, args: data.argumentsObj || {} }); scheduleRefresh(60); return; } if (detail.eventType !== 'tool_result') return; const call = callId ? state.taskCalls.get(callId) : null; if (callId) state.taskCalls.delete(callId); if (data.success !== false && call && call.toolName === 'taskupdate') { state.tasks = applyTaskUpdate(state.tasks, call.args); const progress = deriveProgress(state.tasks); if (progress.allCompleted) state.finalHoldUntil = Date.now() + FINAL_STATE_HOLD_MS; render(false); } scheduleRefresh(100); } root.addEventListener('agent-plan-task-event', handlePlanToolEvent); root.addEventListener('conversation-changed', (event) => { setConversation(event && event.detail ? event.detail.conversationId : currentConversationId()); scheduleRefresh(0); }); root.document.addEventListener('visibilitychange', () => { if (!root.document.hidden) scheduleRefresh(0); }); root.document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !state.expanded) return; state.expanded = false; render(true); }); root.document.addEventListener('pointerdown', (event) => { if (!state.expanded || host.contains(event.target)) return; state.expanded = false; render(true); }); setConversation(currentConversationId()); fetchPlanTasks(); state.pollTimer = root.setInterval(fetchPlanTasks, POLL_INTERVAL_MS); })(typeof window !== 'undefined' ? window : globalThis);