mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 15:40:38 +02:00
feat(ui): 显示 Agent 任务进度列表 (#251)
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
/* Agent-maintained plan: compact progress chip, expanded on hover/focus. */
|
||||
.agent-plan-progress {
|
||||
--agent-plan-surface: var(--card-bg);
|
||||
--agent-plan-surface-hover: var(--bg-tertiary);
|
||||
--agent-plan-text: var(--text-primary);
|
||||
--agent-plan-text-secondary: var(--text-secondary);
|
||||
--agent-plan-border: var(--border-color);
|
||||
--agent-plan-spinner-track: color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
--agent-plan-trigger-bottom: 14px;
|
||||
--agent-plan-panel-bottom: 70px;
|
||||
position: relative;
|
||||
z-index: 42;
|
||||
flex: 0 0 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-plan-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: var(--agent-plan-trigger-bottom);
|
||||
transform: translateX(50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--agent-plan-border);
|
||||
border-radius: 18px;
|
||||
color: var(--agent-plan-text);
|
||||
background: var(--agent-plan-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background-color 150ms ease, border-color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger:hover,
|
||||
.agent-plan-progress-trigger:focus-visible {
|
||||
color: var(--agent-plan-text);
|
||||
background: var(--agent-plan-surface-hover);
|
||||
border-color: var(--accent-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.agent-plan-progress-panel {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: var(--agent-plan-panel-bottom);
|
||||
width: max-content;
|
||||
min-width: 360px;
|
||||
max-width: min(560px, calc(100vw - 40px));
|
||||
max-height: min(54vh, 440px);
|
||||
padding: 14px 18px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--agent-plan-border);
|
||||
border-radius: 18px;
|
||||
color: var(--agent-plan-text);
|
||||
background: var(--agent-plan-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transform: translate(50%, 8px) scale(0.985);
|
||||
transform-origin: bottom center;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* “回到最新消息”只在用户离开底部时出现。它与任务进度同为居中浮层,
|
||||
* 两者同时可见时让任务进度上移,保留回到底部按钮靠近输入框的位置。
|
||||
*/
|
||||
.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) {
|
||||
--agent-plan-trigger-bottom: 64px;
|
||||
--agent-plan-panel-bottom: 120px;
|
||||
}
|
||||
|
||||
.agent-plan-progress.is-hover-active .agent-plan-progress-panel,
|
||||
.agent-plan-progress:focus-within .agent-plan-progress-panel,
|
||||
.agent-plan-progress.is-open .agent-plan-progress-panel {
|
||||
opacity: 1;
|
||||
transform: translate(50%, 0) scale(1);
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.agent-plan-task {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
padding: 4px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 570;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.agent-plan-task-label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agent-plan-task--completed {
|
||||
color: var(--agent-plan-text-secondary);
|
||||
}
|
||||
|
||||
.agent-plan-task--in_progress {
|
||||
color: var(--agent-plan-text);
|
||||
}
|
||||
|
||||
.agent-plan-task--pending {
|
||||
color: var(--agent-plan-text-secondary);
|
||||
}
|
||||
|
||||
.agent-plan-task-status {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
border: 2px solid var(--agent-plan-text-secondary);
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.agent-plan-task-status--completed {
|
||||
border-color: var(--agent-plan-text-secondary);
|
||||
color: var(--agent-plan-text-secondary);
|
||||
}
|
||||
|
||||
.agent-plan-task-check {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.agent-plan-task-status--in_progress {
|
||||
border-color: var(--agent-plan-spinner-track);
|
||||
border-top-color: var(--accent-color);
|
||||
animation: agent-plan-spin 820ms linear infinite;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger .agent-plan-task-status {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@keyframes agent-plan-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.agent-plan-progress {
|
||||
--agent-plan-trigger-bottom: 10px;
|
||||
--agent-plan-panel-bottom: 66px;
|
||||
}
|
||||
|
||||
.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) {
|
||||
--agent-plan-trigger-bottom: 60px;
|
||||
--agent-plan-panel-bottom: 116px;
|
||||
}
|
||||
|
||||
.agent-plan-progress-panel {
|
||||
min-width: min(360px, calc(100vw - 28px));
|
||||
max-width: calc(100vw - 28px);
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger {
|
||||
min-height: 42px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-plan-progress-trigger,
|
||||
.agent-plan-progress-panel {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.agent-plan-task-status--in_progress {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
}
|
||||
@@ -3489,6 +3489,7 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
|
||||
.chat-container.is-conversation-restoring #chat-messages,
|
||||
.chat-container.is-conversation-restoring #chat-turn-rail,
|
||||
.chat-container.is-conversation-restoring #chat-return-latest,
|
||||
.chat-container.is-conversation-restoring #agent-plan-progress,
|
||||
.chat-container.is-conversation-restoring #chat-input-container {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -3546,7 +3547,6 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
|
||||
.chat-messages:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--text-muted) 80%, transparent);
|
||||
}
|
||||
|
||||
.chat-welcome-empty-state {
|
||||
flex: 1 0 auto;
|
||||
width: 100%;
|
||||
@@ -45316,6 +45316,12 @@ html[data-theme="dark"] .conversation-sidebar .recent-conversations-section {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* The composer is an isolated stacking context. Lift that whole context while the
|
||||
settings popover is open so the turn rail and its preview cannot paint above it. */
|
||||
.chat-input-container.is-session-settings-open {
|
||||
z-index: 121;
|
||||
}
|
||||
|
||||
.chat-composer-context {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -562,6 +562,10 @@
|
||||
"conversationPreviewViewed": "Viewed",
|
||||
"conversationPreviewConversation": "Conversation",
|
||||
"returnToLatest": "Jump to latest message",
|
||||
"taskProgressStep": "Step {{current}} of {{total}}",
|
||||
"taskProgressOpen": "View task progress",
|
||||
"taskProgressDetails": "Task progress details",
|
||||
"taskProgressUnnamed": "Untitled task",
|
||||
"completedUnread": "Completed, not viewed",
|
||||
"newConversationInProject": "Start a new conversation in this project",
|
||||
"newUnassignedConversation": "Start a new conversation without a project",
|
||||
|
||||
@@ -550,6 +550,10 @@
|
||||
"conversationPreviewViewed": "已查看",
|
||||
"conversationPreviewConversation": "对话",
|
||||
"returnToLatest": "回到最新消息",
|
||||
"taskProgressStep": "第 {{current}} / {{total}} 步",
|
||||
"taskProgressOpen": "查看任务进度",
|
||||
"taskProgressDetails": "任务进度详情",
|
||||
"taskProgressUnnamed": "未命名任务",
|
||||
"completedUnread": "已完成,尚未查看",
|
||||
"newConversationInProject": "在此项目中新建对话",
|
||||
"newUnassignedConversation": "新建无项目对话",
|
||||
|
||||
@@ -42,3 +42,10 @@ test('欢迎语随项目和无项目状态更新', () => {
|
||||
assert.equal(zh.chat.welcomeSubtitle, '请输入您的测试需求,系统将自动执行相应的安全测试。');
|
||||
assert.equal(typeof en.chat.projectWelcomeMessage, 'string');
|
||||
});
|
||||
|
||||
test('会话设置打开时提升整个输入区层级并遮住轮次导航', () => {
|
||||
assert.match(chat, /function syncChatSessionSettingsLayerState\(\)/);
|
||||
assert.match(chat, /inputBar\.classList\.toggle\('is-session-settings-open', open\)/);
|
||||
assert.match(styles, /\.chat-input-container\.is-session-settings-open\s*\{[\s\S]*?z-index:\s*121/);
|
||||
assert.match(styles, /\.chat-turn-rail\s*\{[\s\S]*?z-index:\s*20/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
(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);
|
||||
@@ -0,0 +1,74 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const { deriveProgress, applyTaskUpdate } = require('./chat-plan-progress.js');
|
||||
|
||||
test('任务进度优先定位进行中步骤并保留完成项', () => {
|
||||
const progress = deriveProgress([
|
||||
{ id: '1', subject: '梳理需求', status: 'completed' },
|
||||
{ id: '2', subject: '实现组件', activeForm: '正在实现组件', status: 'in_progress' },
|
||||
{ id: '3', subject: '浏览器验证', status: 'pending' }
|
||||
]);
|
||||
assert.equal(progress.activeStep, 2);
|
||||
assert.equal(progress.completed, 1);
|
||||
assert.equal(progress.total, 3);
|
||||
assert.equal(progress.allCompleted, false);
|
||||
});
|
||||
|
||||
test('TaskUpdate 成功后即时勾选,最终步骤显示全部完成', () => {
|
||||
const initial = [
|
||||
{ id: '1', subject: '接口', status: 'completed' },
|
||||
{ id: '2', subject: '界面', status: 'in_progress' }
|
||||
];
|
||||
const updated = applyTaskUpdate(initial, { taskId: '2', status: 'completed' });
|
||||
const progress = deriveProgress(updated);
|
||||
assert.equal(progress.activeStep, 2);
|
||||
assert.equal(progress.completed, 2);
|
||||
assert.equal(progress.allCompleted, true);
|
||||
});
|
||||
|
||||
test('删除任务不会出现在悬浮清单中', () => {
|
||||
const tasks = applyTaskUpdate([
|
||||
{ id: '1', subject: '保留', status: 'pending' },
|
||||
{ id: '2', subject: '删除', status: 'pending' }
|
||||
], { taskId: '2', status: 'deleted' });
|
||||
assert.deepEqual(tasks.map((task) => task.id), ['1']);
|
||||
});
|
||||
|
||||
test('任务进度样式跟随系统主题变量而非固定深色', () => {
|
||||
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
|
||||
assert.match(css, /--agent-plan-surface:\s*var\(--card-bg\)/);
|
||||
assert.match(css, /background:\s*var\(--agent-plan-surface\)/);
|
||||
assert.match(css, /color:\s*var\(--agent-plan-text\)/);
|
||||
assert.doesNotMatch(css, /background:\s*#(?:292929|2b2b2b|303030)/i);
|
||||
});
|
||||
|
||||
test('回到最新按钮与任务进度同时显示时采用上下避让布局', () => {
|
||||
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
|
||||
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
assert.match(css, /\.chat-return-latest:not\(\[hidden\]\)\s*\+\s*\.agent-plan-progress:not\(\[hidden\]\)/);
|
||||
assert.match(css, /--agent-plan-trigger-bottom:\s*64px/);
|
||||
assert.match(css, /--agent-plan-panel-bottom:\s*120px/);
|
||||
assert.match(css, /bottom:\s*var\(--agent-plan-trigger-bottom\)/);
|
||||
assert.match(css, /bottom:\s*var\(--agent-plan-panel-bottom\)/);
|
||||
assert.match(template, /<button[^>]+id="chat-return-latest"[\s\S]*?<\/button>\s*<div id="agent-plan-progress"/);
|
||||
});
|
||||
|
||||
test('计划详情只在真实鼠标移动或主动操作后展开', () => {
|
||||
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
|
||||
const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
|
||||
assert.doesNotMatch(css, /\.agent-plan-progress:hover\s+\.agent-plan-progress-panel/);
|
||||
assert.match(css, /\.agent-plan-progress\.is-hover-active\s+\.agent-plan-progress-panel/);
|
||||
assert.match(source, /host\.addEventListener\('pointermove',\s*armHoverAfterPointerMove\)/);
|
||||
assert.match(source, /returnLatestButton\.addEventListener\('pointerdown',\s*disarmPassiveHover\)/);
|
||||
assert.match(source, /passiveHoverAnchor = \{ x: event\.clientX, y: event\.clientY \}/);
|
||||
assert.match(source, /event\.clientX === passiveHoverAnchor\.x && event\.clientY === passiveHoverAnchor\.y/);
|
||||
assert.match(source, /host\.classList\.remove\('is-hover-active'\)/);
|
||||
});
|
||||
|
||||
test('服务端判定任务停止后立即清空旧任务卡片', () => {
|
||||
const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
|
||||
assert.match(source, /payload && payload\.running === false/);
|
||||
assert.match(source, /state\.tasks = \[\][\s\S]{0,160}state\.expanded = false/);
|
||||
});
|
||||
@@ -342,7 +342,7 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
|
||||
assert.match(html, /router\.js\?v=20260813-2/);
|
||||
assert.match(html, /chat\.js\?v=20260813-3/);
|
||||
assert.match(html, /chat\.js\?v=20260813-4/);
|
||||
});
|
||||
|
||||
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
|
||||
@@ -394,5 +394,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
|
||||
assert.match(html, /style\.css\?v=20260813-5/);
|
||||
assert.match(html, /style\.css\?v=20260813-6/);
|
||||
});
|
||||
|
||||
@@ -314,6 +314,16 @@ function mountChatSessionSettingsPopover() {
|
||||
composerSurface.appendChild(wrap);
|
||||
}
|
||||
wrap.classList.add('chat-session-settings-popover');
|
||||
syncChatSessionSettingsLayerState();
|
||||
}
|
||||
|
||||
function syncChatSessionSettingsLayerState() {
|
||||
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||
const inputBar = document.getElementById('chat-input-container');
|
||||
if (!inputBar) return;
|
||||
const open = !!wrap && wrap.style.display !== 'none' &&
|
||||
!wrap.classList.contains('conversation-reasoning-collapsed');
|
||||
inputBar.classList.toggle('is-session-settings-open', open);
|
||||
}
|
||||
|
||||
function initChatReasoningBarHeightSync() {
|
||||
@@ -1574,6 +1584,7 @@ function openChatSessionSettings(section, event) {
|
||||
if (!wrap || !toggle || wrap.style.display === 'none') return;
|
||||
syncChatReasoningBarHeight();
|
||||
wrap.classList.remove('conversation-reasoning-collapsed');
|
||||
syncChatSessionSettingsLayerState();
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
if (typeof closeAgentModePanel === 'function') closeAgentModePanel();
|
||||
if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel();
|
||||
@@ -1732,6 +1743,7 @@ function closeChatReasoningPanel() {
|
||||
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||
const toggle = document.getElementById('conversation-reasoning-toggle');
|
||||
if (wrap) wrap.classList.add('conversation-reasoning-collapsed');
|
||||
syncChatSessionSettingsLayerState();
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
@@ -1741,6 +1753,7 @@ function toggleConversationReasoningCard() {
|
||||
if (!wrap || !toggle) return;
|
||||
syncChatReasoningBarHeight();
|
||||
wrap.classList.toggle('conversation-reasoning-collapsed');
|
||||
syncChatSessionSettingsLayerState();
|
||||
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
if (!collapsed) {
|
||||
@@ -5434,6 +5447,7 @@ async function startNewConversation(options = {}) {
|
||||
try {
|
||||
window.currentConversationId = '';
|
||||
} catch (e) { /* ignore */ }
|
||||
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: '' } }));
|
||||
updateChatPrimaryActionState();
|
||||
currentConversationGroupId = null; // 新对话不属于任何分组
|
||||
// 顶部“新任务”继承当前文件夹;文件夹内的“+”仍可显式指定(包括无项目)。
|
||||
@@ -5945,6 +5959,7 @@ async function loadConversation(conversationId) {
|
||||
try {
|
||||
window.currentConversationId = conversationId;
|
||||
} catch (e) { /* ignore */ }
|
||||
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: conversationId } }));
|
||||
updateChatPrimaryActionState();
|
||||
if (typeof refreshChatProjectSelector === 'function') {
|
||||
refreshChatProjectSelector({ reloadFolders: false, renderFolders: false });
|
||||
|
||||
@@ -246,8 +246,8 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
|
||||
assert.match(chat, /signal: conversationLoadController\.signal/);
|
||||
assert.match(template, /monitor\.js\?v=20260813-9/);
|
||||
assert.match(template, /chat-scroll\.js\?v=20260813-6/);
|
||||
assert.match(template, /chat\.js\?v=20260813-3/);
|
||||
assert.match(template, /style\.css\?v=20260813-5/);
|
||||
assert.match(template, /chat\.js\?v=20260813-4/);
|
||||
assert.match(template, /style\.css\?v=20260813-6/);
|
||||
});
|
||||
|
||||
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ function setCurrentConversationIdFromStream(cid) {
|
||||
currentConversationId = cid;
|
||||
try {
|
||||
window.currentConversationId = cid;
|
||||
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: cid } }));
|
||||
if (typeof window.syncChatConversationHash === 'function') {
|
||||
window.syncChatConversationHash(cid);
|
||||
}
|
||||
@@ -2591,6 +2592,22 @@ function formatEinoRunRetryTitle(data) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function dispatchAgentPlanTaskEvent(event, fallbackConversationId) {
|
||||
if (!event || (event.type !== 'tool_call' && event.type !== 'tool_result')) return;
|
||||
const data = event.data && typeof event.data === 'object' ? event.data : {};
|
||||
const toolName = String(data.toolName || '').trim().toLowerCase();
|
||||
if (!['taskcreate', 'taskupdate', 'tasklist', 'taskget'].includes(toolName)) return;
|
||||
const conversationId = String(data.conversationId || fallbackConversationId || window.currentConversationId || '').trim();
|
||||
if (!conversationId) return;
|
||||
window.dispatchEvent(new CustomEvent('agent-plan-task-event', {
|
||||
detail: {
|
||||
eventType: event.type,
|
||||
conversationId: conversationId,
|
||||
data: data
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// 处理流式事件
|
||||
function handleStreamEvent(event, progressElement, progressId,
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds, options) {
|
||||
@@ -2619,6 +2636,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchAgentPlanTaskEvent(event, expectedConversationId || eventConversationId);
|
||||
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
|
||||
? window.captureScrollPinState()
|
||||
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||
|
||||
Reference in New Issue
Block a user