mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 23:20:25 +02:00
317 lines
13 KiB
JavaScript
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);
|