diff --git a/web/static/css/style.css b/web/static/css/style.css index 7d5d0c12..a8138eef 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -11153,6 +11153,124 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { padding: 0 8px; } +.section-header-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.conversation-sort-dropdown { + position: relative; +} + +.conversation-sort-btn { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.conversation-sort-btn:hover, +.conversation-sort-btn[aria-expanded="true"] { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.conversation-sort-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 200; + min-width: 156px; + padding: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.conversation-sort-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 8px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 0.8125rem; + cursor: pointer; + border-radius: 6px; + text-align: left; + transition: background 0.15s ease, color 0.15s ease; +} + +.conversation-sort-option:hover { + background: var(--bg-tertiary); +} + +.conversation-sort-option.is-selected { + background: rgba(0, 102, 255, 0.08); + color: var(--accent-color); + font-weight: 500; +} + +.conversation-sort-option.is-selected:hover { + background: rgba(0, 102, 255, 0.12); +} + +.conversation-sort-option-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--bg-tertiary); + color: var(--text-muted); + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.conversation-sort-option:hover .conversation-sort-option-icon { + color: var(--text-secondary); +} + +.conversation-sort-option.is-selected .conversation-sort-option-icon { + background: rgba(0, 102, 255, 0.12); + color: var(--accent-color); + box-shadow: inset 0 0 0 1px rgba(0, 102, 255, 0.16); +} + +.conversation-sort-option-label { + flex: 1; + min-width: 0; + line-height: 1.2; +} + +.conversation-sort-option-check { + flex-shrink: 0; + width: 14px; + font-size: 0.75rem; + font-weight: 700; + color: var(--accent-color); + opacity: 0; + text-align: center; +} + +.conversation-sort-option.is-selected .conversation-sort-option-check { + opacity: 1; +} + .section-title { font-size: 0.8125rem; font-weight: 600; @@ -13818,10 +13936,23 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { } .batch-task-header .batch-task-edit-btn { - /* 仅把“编辑”(首个操作按钮)推到最右,避免多个按钮都 margin-left:auto 导致挤压/错位 */ + /* 仅把「编辑」(首个操作按钮)推到最右,避免多个按钮都 margin-left:auto 导致挤压/错位 */ margin-left: auto; } +.batch-task-header .batch-task-edit-btn--push { + margin-left: auto; +} + +.batch-task-header .batch-task-run-btn { + flex-shrink: 0; +} + +.batch-task-header .batch-task-run-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .batch-task-header .batch-task-delete-btn { margin-left: 8px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index c38ee450..927d68e8 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -436,6 +436,9 @@ "conversationGroups": "Conversation groups", "addGroup": "New group", "recentConversations": "Recent conversations", + "sortConversations": "Sort", + "sortByCreatedAt": "Created time", + "sortByUpdatedAt": "Updated time", "batchManage": "Batch manage", "paginationShow": "Show {{start}}-{{end}} of {{total}}", "paginationRange": "{{start}}-{{end}}/{{total}}", @@ -676,7 +679,12 @@ "viewConversation": "View conversation", "viewVulnerabilities": "View vulnerabilities", "viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue", - "retryTask": "Retry", + "runSingleTask": "Run task", + "confirmRunSingleTask": "Run this task only? The queue will pause when it finishes and will not continue other pending items.", + "runSingleTaskFailed": "Failed to run task", + "runSingleTaskUnavailable": "Unavailable while the queue or a task is running", + "runSingleTaskUnavailableSelf": "This task is running", + "runSingleTaskUnavailableQueue": "Queue is running; pause it before running another task individually", "conversationIdLabel": "Conversation ID", "statusPending": "Pending", "statusPaused": "Paused", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 1802d63e..9290be23 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -424,6 +424,9 @@ "conversationGroups": "对话分组", "addGroup": "新建分组", "recentConversations": "最近对话", + "sortConversations": "排序", + "sortByCreatedAt": "创建时间", + "sortByUpdatedAt": "更新时间", "batchManage": "批量管理", "paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}", "paginationRange": "{{start}}-{{end}}/{{total}}", @@ -664,7 +667,12 @@ "viewConversation": "查看对话", "viewVulnerabilities": "查看漏洞", "viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列", - "retryTask": "重试", + "runSingleTask": "单条执行", + "confirmRunSingleTask": "确定执行该任务?仅运行这一条,完成后队列会自动暂停,不会继续执行其他待执行项。", + "runSingleTaskFailed": "单条执行失败", + "runSingleTaskUnavailable": "队列或任务执行中,暂无法单条执行", + "runSingleTaskUnavailableSelf": "该任务正在执行中", + "runSingleTaskUnavailableQueue": "队列批量执行中,请暂停后再单条执行其它任务", "conversationIdLabel": "对话ID", "statusPending": "待执行", "statusPaused": "已暂停", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 65a44994..b9206553 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -5763,6 +5763,95 @@ let conversationGroupMappingCache = {}; let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况) let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染 const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size'; +const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by'; + +function getConversationSortBy() { + try { + const saved = localStorage.getItem(CONVERSATIONS_SORT_KEY); + if (saved === 'created_at' || saved === 'updated_at') return saved; + } catch (e) { /* ignore */ } + return 'updated_at'; +} + +let conversationSortBy = getConversationSortBy(); + +function getConversationSortTime(conv) { + const field = conversationSortBy === 'created_at' ? 'createdAt' : 'updatedAt'; + const raw = conv && conv[field]; + if (!raw) return new Date(0); + const date = new Date(raw); + return isNaN(date.getTime()) ? new Date(0) : date; +} + +function updateConversationSortMenuUI() { + const menu = document.getElementById('conversation-sort-menu'); + const btn = document.getElementById('conversation-sort-btn'); + if (!menu) return; + menu.querySelectorAll('.conversation-sort-option').forEach((option) => { + const selected = option.dataset.sort === conversationSortBy; + option.classList.toggle('is-selected', selected); + option.setAttribute('aria-checked', selected ? 'true' : 'false'); + }); + if (btn) { + btn.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true'); + } +} + +function closeConversationSortMenu() { + const menu = document.getElementById('conversation-sort-menu'); + const btn = document.getElementById('conversation-sort-btn'); + if (menu) menu.hidden = true; + if (btn) btn.setAttribute('aria-expanded', 'false'); +} + +function toggleConversationSortMenu(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const menu = document.getElementById('conversation-sort-menu'); + const btn = document.getElementById('conversation-sort-btn'); + if (!menu || !btn) return; + const willOpen = menu.hidden; + closeConversationSortMenu(); + if (willOpen) { + menu.hidden = false; + btn.setAttribute('aria-expanded', 'true'); + updateConversationSortMenuUI(); + } +} + +function setConversationSortBy(sortBy) { + const next = sortBy === 'created_at' ? 'created_at' : 'updated_at'; + if (next === conversationSortBy) { + closeConversationSortMenu(); + return; + } + conversationSortBy = next; + try { + localStorage.setItem(CONVERSATIONS_SORT_KEY, next); + } catch (e) { /* ignore */ } + updateConversationSortMenuUI(); + closeConversationSortMenu(); + conversationsPagination.page = 1; + loadConversationsWithGroups(conversationsSearchQuery); +} + +if (!window.__conversationSortMenuBound) { + window.__conversationSortMenuBound = true; + document.addEventListener('click', (event) => { + const dropdown = document.getElementById('conversation-sort-dropdown'); + if (!dropdown || dropdown.contains(event.target)) return; + closeConversationSortMenu(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeConversationSortMenu(); + }); +} + +window.toggleConversationSortMenu = toggleConversationSortMenu; +window.setConversationSortBy = setConversationSortBy; +window.closeConversationSortMenu = closeConversationSortMenu; function getConversationsPageSize() { try { @@ -6025,6 +6114,9 @@ async function loadConversationsWithGroups(searchQuery = '') { const pageSize = conversationsPagination.pageSize; const offset = (conversationsPagination.page - 1) * pageSize; const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (conversationSortBy === 'created_at') { + convParams.set('sort_by', 'created_at'); + } if (searchQuery && searchQuery.trim()) { convParams.set('search', searchQuery.trim()); } else { @@ -6114,11 +6206,7 @@ async function loadConversationsWithGroups(searchQuery = '') { }); // 按时间排序 - const sortByTime = (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt) : new Date(0); - const timeB = b.updatedAt ? new Date(b.updatedAt) : new Date(0); - return timeB - timeA; - }; + const sortByTime = (a, b) => getConversationSortTime(b) - getConversationSortTime(a); pinnedConvs.sort(sortByTime); normalConvs.sort(sortByTime); @@ -6146,8 +6234,8 @@ async function loadConversationsWithGroups(searchQuery = '') { }; normalConvs.forEach(conv => { - const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); - const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj; + const dateObj = getConversationSortTime(conv); + const validDate = dateObj.getTime() === 0 ? new Date() : dateObj; const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart); groups[groupKey].push({ ...conv, @@ -6159,8 +6247,8 @@ async function loadConversationsWithGroups(searchQuery = '') { if (pinnedConvs.length > 0) { pinnedConvs.forEach(conv => { - const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); - const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj; + const dateObj = getConversationSortTime(conv); + const validDate = dateObj.getTime() === 0 ? new Date() : dateObj; fragment.appendChild(createConversationListItemWithMenu({ ...conv, _timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart), @@ -8508,6 +8596,7 @@ function clearGroupSearch() { // 初始化时加载分组 document.addEventListener('DOMContentLoaded', async () => { + updateConversationSortMenuUI(); await loadGroups(); await loadConversationsWithGroups(); diff --git a/web/static/js/tasks.js b/web/static/js/tasks.js index eb8b97a3..a1389a6f 100644 --- a/web/static/js/tasks.js +++ b/web/static/js/tasks.js @@ -83,6 +83,21 @@ function batchQueueAllowsSubtaskMutation(queue) { return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled'; } +/** 是否允许对指定子任务发起单条执行(与后端 queueAllowsSingleTaskRunLocked 对齐) */ +function batchQueueCanRunSingleTask(queue, task) { + if (!queue || !task) return false; + if (task.status === 'running') return false; + if (queue.status === 'running') return false; + return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled'; +} + +function batchQueueRunSingleTaskDisabledReason(queue, task) { + if (!queue || !task) return _t('tasks.runSingleTaskUnavailable'); + if (task.status === 'running') return _t('tasks.runSingleTaskUnavailableSelf'); + if (queue.status === 'running') return _t('tasks.runSingleTaskUnavailableQueue'); + return _t('tasks.runSingleTaskUnavailable'); +} + // HTML转义函数(如果未定义) if (typeof escapeHtml === 'undefined') { function escapeHtml(text) { @@ -1497,6 +1512,8 @@ async function showBatchQueueDetail(queueId) { ${queue.tasks.map((task, index) => { const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' }; const canEdit = allowSubtaskMutation && task.status !== 'running'; + const canRunSingle = batchQueueCanRunSingleTask(queue, task); + const runSingleUnavailableTitle = escapeHtml(batchQueueRunSingleTaskDisabledReason(queue, task)); const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n"); return `
@@ -1504,10 +1521,10 @@ async function showBatchQueueDetail(queueId) { #${index + 1} ${taskStatus.text} ${escapeHtml(task.message)} + + ${task.conversationId ? `` : ''} ${canEdit ? `` : ''} ${canEdit ? `` : ''} - ${allowSubtaskMutation && task.status === 'failed' ? `` : ''} - ${task.conversationId ? `` : ''}
${task.startedAt ? `
` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}
` : ''} ${task.completedAt ? `
` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}
` : ''} @@ -2270,38 +2287,25 @@ async function saveInlineAgentMode() { } } -// --- 重试失败任务 --- -async function retryBatchTask(queueId, taskId) { +// --- 单条执行 --- +async function runSingleBatchTask(queueId, taskId) { if (!queueId || !taskId) return; + if (!confirm(_t('tasks.confirmRunSingleTask'))) return; try { - // 获取任务消息 - const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`); - if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed')); - const detail = await detailResp.json(); - const task = detail.queue.tasks.find(t => t.id === taskId); - if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found'); - const message = task.message; - - // 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失 - const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, { + const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}/run`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }), }); - if (!addResp.ok) { - const r = await addResp.json().catch(() => ({})); - throw new Error(r.error || _t('tasks.addTaskFailed')); + const result = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(result.error || _t('tasks.runSingleTaskFailed')); } - // 新任务添加成功后才删除旧任务 - const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' }); - if (!delResp.ok) { - // 删除失败不阻塞(新任务已添加,旧任务保留也不影响) - console.warn('删除旧任务失败,但新任务已添加'); + if (result.autoStarted === false && result.message) { + alert(result.message); } showBatchQueueDetail(queueId); refreshBatchQueues(); } catch (e) { - console.error('重试任务失败:', e); + console.error('单条执行失败:', e); alert(e.message); } } @@ -2437,7 +2441,7 @@ window.startInlineEditRole = startInlineEditRole; window.saveInlineRole = saveInlineRole; window.startInlineEditAgentMode = startInlineEditAgentMode; window.saveInlineAgentMode = saveInlineAgentMode; -window.retryBatchTask = retryBatchTask; +window.runSingleBatchTask = runSingleBatchTask; window.startInlineEditSchedule = startInlineEditSchedule; window.toggleInlineScheduleCron = toggleInlineScheduleCron; window.saveInlineSchedule = saveInlineSchedule; diff --git a/web/templates/index.html b/web/templates/index.html index 594f788c..ae307b47 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -808,16 +808,49 @@
最近对话 - +
+
+ + +
+ +