Files
CyberStrikeAI/web/static/js/chat-plan-progress.js
T
Sycun 35508eb614 feat(pi): add the Pi agent runtime with native workflow and retrieval engines
Introduce an isolated Node worker that hosts the Pi SDK while Go keeps
business state, permissions, MCP execution, evidence and delivery checks.
The worker is published per source fingerprint, streams events back to Go
and supports tool callbacks, cancellation, session resume and per-model
transport settings.

Agent dispatch, tool search, plan/task tools and migration controls are
wired on top of it: large tool catalogs load through Pi tool_search, the
conversation task board keeps its existing tools, and legacy mode/route
values still resolve. Workflow gains a native DAG with versioned JSON
checkpoints, and knowledge retrieval moves to native documents, splitter,
HTTP embeddings, vector search and reranking.
2026-11-12 17:55:33 +08:00

357 lines
15 KiB
JavaScript

(function (root) {
'use strict';
const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget', 'write_todos']);
const ACTIVE_POLL_INTERVAL_MS = 1500;
const ERROR_RETRY_INTERVAL_MS = 4000;
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,
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 isCurrentConversationRunning() {
const conversationId = currentConversationId();
if (!conversationId) return false;
try {
const checker = typeof root.isConversationTaskRunning === 'function'
? root.isConversationTaskRunning
: (typeof isConversationTaskRunning === 'function' ? isConversationTaskRunning : null);
return !!(checker && checker(conversationId));
} catch (e) {
return false;
}
}
function shouldContinuePolling(payload) {
if (!currentConversationId() || root.document.hidden) return false;
if (payload && payload.running === false) return false;
if (payload && payload.running === true) {
return state.tasks.length > 0 || Date.now() < state.finalHoldUntil;
}
return isCurrentConversationRunning() && state.tasks.length > 0;
}
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;
let payload = null;
let retryAfterError = false;
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);
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;
retryAfterError = true;
// 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;
if (shouldContinuePolling(payload)) {
scheduleRefresh(retryAfterError ? ERROR_RETRY_INTERVAL_MS : ACTIVE_POLL_INTERVAL_MS);
}
}
}
}
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);
}
function handleConversationTaskStateChanged(event) {
const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {};
const conversationId = String(detail.conversationId || '').trim();
const currentId = currentConversationId();
if (conversationId && conversationId !== currentId) return;
if (isCurrentConversationRunning() || state.tasks.length) {
scheduleRefresh(0);
}
}
root.addEventListener('agent-plan-task-event', handlePlanToolEvent);
root.addEventListener('conversation-task-state-changed', handleConversationTaskStateChanged);
root.addEventListener('conversation-changed', (event) => {
setConversation(event && event.detail ? event.detail.conversationId : currentConversationId());
scheduleRefresh(0);
});
root.document.addEventListener('visibilitychange', () => {
if (!root.document.hidden && (isCurrentConversationRunning() || state.tasks.length)) 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();
})(typeof window !== 'undefined' ? window : globalThis);