Files
CyberStrikeAI/web/static/js/chat-plan-progress.js
T

317 lines
13 KiB
JavaScript

(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);