'use strict'; const BUSINESS_SOURCE = 'LIANSYN_PLATFORM'; const EXTENSION_SOURCE = 'LTJT_ORDER_ASSISTANT_EXTENSION'; const CURRENT_PAGE = window.location.pathname.replace(/\/+$/, '') || '/'; const IS_HISTORY_PAGE = CURRENT_PAGE === '/history'; const IS_CHANNELS_PAGE = CURRENT_PAGE === '/channels'; const HOME_TASK_LIMIT = 10; const HISTORY_TASK_PAGE_SIZE = 24; const DEFAULT_API_TIMEOUT_MS = 15_000; const AUTH_API_TIMEOUT_MS = 10_000; const RESULT_API_TIMEOUT_MS = 20_000; const LOCAL_TASK_OVERLAY_TTL_MS = 10 * 60_000; const pending = new Map(); let currentTaskId = sessionStorage.getItem('liansyn_platform_current_task_id') || ''; let pollTimer = null; let eventStream = null; let bridgeConnected = false; let erpAutomationEnabled = true; let erpHostAccessReady = true; let erpSessionReady = true; let extensionCompatible = false; let latestAiStatus = null; let latestBridgeStatus = null; let activeStatusDetails = ''; let remoteSyncInProgress = false; let syncRequested = false; let csrfToken = ''; let authUser = null; let organizationAutomationEnabled = false; let automationSettingsLoaded = false; let automationSettingsBusy = false; let automationSettingsError = ''; let channelList = []; let channelSettingsBusy = false; let autoHandoffInProgress = false; let taskStore = []; const runtimeTaskStore = new Map(); const remoteTaskStore = new Map(); const taskDetailStore = new Map(); const taskDetailRequests = new Map(); const localTaskOverlayStore = new Map(); let pollInProgress = false; let backgroundRefreshInProgress = false; let taskListMeta = { total: 0, offset: 0, limit: HOME_TASK_LIMIT, has_more: false }; let historySearch = ''; let historyStatus = ''; let historyOffset = 0; const lifecycleScrollPositions = new Map(); const taskDeleteStates = new Map(); const taskReconciliationStates = new Map(); const taskReplyStates = new Map(); const taskReplyDrafts = new Map(); const autoHandoffRetryAt = new Map(); const AUTO_HANDOFF_RETRY_MS = 30_000; const BROWSER_CONNECTION_ID = `administrator-browser:${location.origin}`; const REQUIRED_EXTENSION_VERSION = '0.5.140'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; const TERMINAL_TASK_STATUSES = new Set([ 'completed', 'blocked', 'failed', 'cancelled', 'dry_run', 'parse_failed' ]); const MANUAL_RECONCILIATION_STATUSES = new Set([ 'reconciliation_pending', 'saved_unverified', 'execution_uncertain', 'uncertain' ]); function requestTimeoutForPath(path) { if (/^\/api\/auth\//.test(path)) return AUTH_API_TIMEOUT_MS; if (/\/result$/.test(path)) return RESULT_API_TIMEOUT_MS; return DEFAULT_API_TIMEOUT_MS; } async function fetchWithTimeout(resource, options = {}, timeoutMs = DEFAULT_API_TIMEOUT_MS) { const controller = new AbortController(); const externalSignal = options.signal; let timeoutId = null; let onExternalAbort = null; if (externalSignal) { onExternalAbort = () => controller.abort(externalSignal.reason); if (externalSignal.aborted) onExternalAbort(); else externalSignal.addEventListener('abort', onExternalAbort, { once: true }); } timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(resource, { ...options, signal: controller.signal }); } catch (error) { if (controller.signal.aborted && !externalSignal?.aborted) { const timeoutError = new Error(`请求超时(${Math.round(timeoutMs / 1000)} 秒):${String(resource)}`); timeoutError.code = 'request_timeout'; throw timeoutError; } throw error; } finally { if (timeoutId !== null) window.clearTimeout(timeoutId); if (externalSignal && onExternalAbort) externalSignal.removeEventListener('abort', onExternalAbort); } } function $(selector) { return document.querySelector(selector); } async function apiRequest(path, options = {}) { const method = String(options.method || 'GET').toUpperCase(); const headers = new Headers(options.headers || {}); const timeoutMs = Number(options.timeoutMs) || requestTimeoutForPath(path); const requestOptions = { ...options }; delete requestOptions.timeoutMs; if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && csrfToken) { headers.set('X-CSRF-Token', csrfToken); } const response = await fetchWithTimeout(path, { ...requestOptions, method, headers, cache: options.cache || 'no-store', credentials: 'same-origin', body: options.body !== undefined && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }, timeoutMs); let payload = null; try { payload = await response.json(); } catch (error) { payload = null; } if (response.status === 401 && path !== '/api/auth/login') { showLoginPanel('登录已过期,请重新登录。'); } if (!response.ok) { throw new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`); } return payload || {}; } function showLoginPanel(message = '') { authUser = null; csrfToken = ''; stopPolling(); automationSettingsLoaded = false; automationSettingsError = ''; organizationAutomationEnabled = false; if (eventStream) { eventStream.close(); eventStream = null; } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; const error = $('#loginError'); if (error) error.textContent = message; } function showAuthChecking() { authUser = null; csrfToken = ''; automationSettingsLoaded = false; automationSettingsError = ''; organizationAutomationEnabled = false; if (eventStream) { eventStream.close(); eventStream = null; } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; const error = $('#loginError'); if (error) error.textContent = '正在恢复登录状态(最多等待 10 秒)…'; } function showAuthenticatedApp(user) { authUser = user; const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); if (panel) panel.hidden = true; if (workbench) workbench.hidden = IS_CHANNELS_PAGE; if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; if (authState) { authState.hidden = false; authState.textContent = `${user.username} · 管理员`; } if (logoutButton) logoutButton.hidden = false; if (automationButton) { automationButton.hidden = IS_CHANNELS_PAGE; renderAutomationToggle(); } const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; } function renderAutomationToggle() { const button = $('#automationToggleButton'); if (!button) return; button.hidden = !authUser || IS_CHANNELS_PAGE; button.disabled = !authUser || automationSettingsBusy || !automationSettingsLoaded || Boolean(automationSettingsError); button.setAttribute('aria-pressed', organizationAutomationEnabled ? 'true' : 'false'); button.classList.toggle('is-enabled', organizationAutomationEnabled); button.classList.toggle('is-error', Boolean(automationSettingsError)); button.textContent = automationSettingsError ? '全自动化:读取失败' : !automationSettingsLoaded ? '全自动化:读取中…' : organizationAutomationEnabled ? '全自动化:已开启' : '全自动化:已关闭'; button.title = automationSettingsError || (organizationAutomationEnabled ? '已开启:AgentBus 新任务解析通过后将自动进入 ERP' : '已关闭:AgentBus 新任务需要人工确认后才进入 ERP'); } async function syncAutomationSettings() { if (!authUser) return null; automationSettingsBusy = true; automationSettingsError = ''; renderAutomationToggle(); try { const result = await apiRequest('/api/settings/automation'); organizationAutomationEnabled = result.settings?.enabled === true; automationSettingsLoaded = true; return result.settings || { enabled: organizationAutomationEnabled }; } catch (error) { automationSettingsLoaded = false; automationSettingsError = error.message || String(error); throw error; } finally { automationSettingsBusy = false; renderAutomationToggle(); } } async function toggleAutomationSetting() { if (!authUser || !automationSettingsLoaded || automationSettingsBusy) return; const nextEnabled = !organizationAutomationEnabled; automationSettingsBusy = true; automationSettingsError = ''; renderAutomationToggle(); try { const result = await apiRequest('/api/settings/automation', { method: 'PUT', body: { enabled: nextEnabled } }); organizationAutomationEnabled = result.settings?.enabled === true; automationSettingsLoaded = true; renderAutomationToggle(); await autoDispatchReadyTasks(); } catch (error) { automationSettingsError = error.message || String(error); renderAutomationToggle(); throw error; } finally { automationSettingsBusy = false; renderAutomationToggle(); } } function channelStatusLabel(status) { return { connected: '已连接', connecting: '连接中', error: '连接错误', disabled: '已停用' }[status] || '未知状态'; } function renderChannelList() { const container = $('#channelList'); const message = $('#channelMessage'); if (!container) return; container.innerHTML = ''; if (!channelList.length) { container.append(el('p', 'muted channel-empty', '还没有用户渠道。添加后,每个渠道会使用独立的 AgentBus key。')); return; } for (const channel of channelList) { const row = el('article', 'channel-row'); const main = el('div', 'channel-row-main'); const heading = el('div', 'channel-row-heading'); heading.append(el('strong', '', channel.display_name || '未命名渠道')); heading.append(el('span', `state ${channel.status === 'connected' ? 'state-ok' : channel.status === 'error' ? 'state-bad' : 'state-warn'}`, channelStatusLabel(channel.status))); main.append(heading); main.append(el('p', 'muted', channel.external_user_ref ? `外部用户:${channel.external_user_ref}` : '未设置外部用户标识')); main.append(el('p', 'channel-key-state', 'AgentBus key:已配置(平台不会回显)')); if (channel.last_error) main.append(el('p', 'channel-error', channel.last_error)); row.append(main); const actions = el('div', 'channel-row-actions'); const toggle = el('button', 'secondary-button', channel.enabled ? '停用' : '启用'); toggle.type = 'button'; toggle.dataset.channelAction = 'toggle'; toggle.dataset.channelId = channel.id; actions.append(toggle); const rotate = el('button', 'secondary-button', '轮换 key'); rotate.type = 'button'; rotate.dataset.channelAction = 'rotate'; rotate.dataset.channelId = channel.id; actions.append(rotate); const rename = el('button', 'secondary-button', '维护名称'); rename.type = 'button'; rename.dataset.channelAction = 'rename'; rename.dataset.channelId = channel.id; actions.append(rename); row.append(actions); container.append(row); } if (message && !channelSettingsBusy) message.textContent = ''; } async function syncChannels() { if (!authUser || IS_HISTORY_PAGE) return null; const result = await apiRequest('/api/channels'); channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); return channelList; } async function createChannelFromForm() { if (!authUser || channelSettingsBusy) return; const message = $('#channelMessage'); channelSettingsBusy = true; if (message) message.textContent = '正在保存渠道…'; try { const result = await apiRequest('/api/channels', { method: 'POST', body: { display_name: normalizeText($('#channelDisplayName')?.value).slice(0, 120), external_user_ref: normalizeText($('#channelExternalUserRef')?.value).slice(0, 200), agentbus_key: String($('#channelAgentbusKey')?.value || '').trim(), bot_address: normalizeText($('#channelBotAddress')?.value).slice(0, 200), enabled: true } }); if (result.channel) channelList = [...channelList.filter((item) => item.id !== result.channel.id), result.channel]; $('#channelAgentbusKey').value = ''; $('#channelDisplayName').value = ''; $('#channelExternalUserRef').value = ''; $('#channelBotAddress').value = ''; renderChannelList(); if (message) message.textContent = '渠道已保存,连接状态会在服务端异步更新。'; } finally { channelSettingsBusy = false; } } async function updateChannelEnabled(channelId, enabled) { if (!authUser || channelSettingsBusy) return; channelSettingsBusy = true; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { method: 'PATCH', body: { enabled } }); await syncChannels(); } finally { channelSettingsBusy = false; } } async function renameChannel(channelId) { if (!authUser || channelSettingsBusy) return; const channel = channelList.find((item) => item.id === channelId); const currentName = channel?.display_name || ''; const nextName = window.prompt('请输入新的渠道名称:', currentName); if (nextName === null || !nextName.trim() || nextName.trim() === currentName) return; channelSettingsBusy = true; const message = $('#channelMessage'); if (message) message.textContent = '正在更新渠道名称…'; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { method: 'PATCH', body: { display_name: nextName.trim().slice(0, 120) } }); await syncChannels(); if (message) message.textContent = '渠道名称已更新。'; } finally { channelSettingsBusy = false; } } async function rotateChannelKey(channelId) { if (!authUser || channelSettingsBusy) return; const key = window.prompt('请输入新的 AgentBus key。保存后旧 key 将立即失效:', ''); if (!key?.trim()) return; channelSettingsBusy = true; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}/rotate-key`, { method: 'POST', body: { agentbus_key: key.trim() } }); await syncChannels(); } finally { channelSettingsBusy = false; } } async function refreshCsrfToken() { const result = await apiRequest('/api/auth/csrf'); csrfToken = result.csrf_token || ''; if (!csrfToken) throw new Error('服务端未返回 CSRF 会话令牌。'); } async function syncRemoteTasks() { if (!authUser) return; if (remoteSyncInProgress) { syncRequested = true; return; } remoteSyncInProgress = true; try { do { syncRequested = false; const params = new URLSearchParams({ limit: String(IS_HISTORY_PAGE ? HISTORY_TASK_PAGE_SIZE : HOME_TASK_LIMIT), offset: String(IS_HISTORY_PAGE ? historyOffset : 0) }); if (IS_HISTORY_PAGE && historySearch) params.set('search', historySearch); if (IS_HISTORY_PAGE && historyStatus) params.set('status', historyStatus); const result = await apiRequest(`/api/tasks?${params.toString()}`); // Keep the server snapshot authoritative, but reject older event/timestamp // versions and preserve a short-lived local overlay until the result is // durably visible on the server. taskStore = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) .filter((task) => task?.status !== 'cancelled' && task?.result?.status !== 'cancelled'); const pagination = result.pagination || {}; taskListMeta = { total: Number.isFinite(Number(pagination.total)) ? Number(pagination.total) : taskStore.length, offset: Number.isFinite(Number(pagination.offset)) ? Number(pagination.offset) : (IS_HISTORY_PAGE ? historyOffset : 0), limit: Number.isFinite(Number(pagination.limit)) ? Number(pagination.limit) : (IS_HISTORY_PAGE ? HISTORY_TASK_PAGE_SIZE : HOME_TASK_LIMIT), has_more: pagination.has_more === true }; if (IS_HISTORY_PAGE && !taskStore.length && historyOffset > 0) { historyOffset = Math.max(0, historyOffset - HISTORY_TASK_PAGE_SIZE); syncRequested = true; continue; } for (const task of taskStore) cacheRuntimeTask(task); if (!taskStore.some((task) => task.task_id === currentTaskId) && !(currentTaskId && runtimeTaskStore.has(currentTaskId) && !IS_HISTORY_PAGE)) { currentTaskId = taskStore[0]?.task_id || ''; } if (currentTaskId) sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); else sessionStorage.removeItem('liansyn_platform_current_task_id'); renderTaskCards(); } while (syncRequested && authUser); await syncRuntimeTasks(); await autoDispatchReadyTasks(); } finally { remoteSyncInProgress = false; if (syncRequested && authUser) { syncRequested = false; queueMicrotask(() => syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); })); } } } function startRemoteEventStream() { if (eventStream) eventStream.close(); const lastEventId = runtimeTasks().reduce((highest, task) => ( Math.max(highest, Number(task?.last_event_id || 0)) ), 0); eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}`); eventStream.addEventListener('task', () => { syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); }); }); eventStream.onerror = () => { // EventSource reconnects automatically; polling remains the fallback authority refresh. }; } function cacheRuntimeTask(task) { if (!task?.task_id) return; if (isTaskPollable(task) || (isAutomaticTask(task) && canStartConfirmedTask(task))) { runtimeTaskStore.set(task.task_id, task); } else { runtimeTaskStore.delete(task.task_id); } } function runtimeTasks() { const merged = new Map(); for (const task of taskStore) { if (task?.task_id) merged.set(task.task_id, task); } for (const task of runtimeTaskStore.values()) { if (!task?.task_id) continue; const existing = merged.get(task.task_id); if (!existing || compareTaskVersion(task, existing) >= 0) merged.set(task.task_id, task); } for (const [taskId, overlay] of localTaskOverlayStore.entries()) { if (Date.now() - overlay.at > LOCAL_TASK_OVERLAY_TTL_MS) { localTaskOverlayStore.delete(taskId); continue; } const existing = merged.get(taskId); merged.set(taskId, existing ? { ...existing, ...overlay.task, __server_status: existing.__server_status || taskServerStatus(existing) } : overlay.task); } return [...merged.values()]; } function hasPollableRuntimeTasks() { return runtimeTasks().some(isTaskPollable); } async function syncRuntimeTasks() { if (!authUser) return; const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false'); const activeTasks = (Array.isArray(result.tasks) ? result.tasks : []).map(mergeRemoteTask); const activeIds = new Set(activeTasks.map((task) => task?.task_id).filter(Boolean)); for (const [taskId, task] of runtimeTaskStore.entries()) { if (isTaskPollable(task) && !activeIds.has(taskId)) runtimeTaskStore.delete(taskId); } for (const task of activeTasks) cacheRuntimeTask(task); } function normalizeText(text) { return String(text || '').replace(/\r/g, '').trim(); } function loadTaskStore() { return []; } function saveTaskStore() { // The backend is authoritative. The in-memory array is a render cache only. } function taskSummary(operation) { const data = operation?.data || {}; const counts = data.passenger_counts || {}; const action = operation?.action || operation?.operation || ''; const labels = { team_order_create: '团队-单个下单', team_order_batch_create: '团队-批量下单', shared_plan_create: '散拼-创建母团计划', shared_child_order_create: '散拼-录入子单', order_update: '已有订单更新', passenger_list_import: '旅客名单导入', confirmation_export: '导出团队文件', create_order: 'ERP 创建订单', update_order: 'ERP 更新订单', export_confirmation: 'ERP 导出恢复' }; return { action, operation_label: labels[action] || action || '输入解析任务', route: '', identifier: data.existing_refs?.identifier || data.existing_refs?.order_no || '', customer: data.customer?.name || '', product: data.product?.name || '', leader: data.leader?.name || '', departure_date: data.departure_dates?.[0] || '', passenger_total: ['adult', 'child_bed', 'child_no_bed', 'infant', 'leader'].reduce((sum, key) => sum + toNumber(counts[key]), 0), op_user: data.op_user?.name || '', sales_user: data.sales_user?.name || '' }; } function taskServerStatus(task) { return String(task?.__server_status || task?.status || task?.result?.status || 'created'); } function compareTaskVersion(left, right) { const leftEvent = Number(left?.last_event_id || 0); const rightEvent = Number(right?.last_event_id || 0); if (leftEvent !== rightEvent) return leftEvent - rightEvent; const leftTime = Date.parse(left?.updated_at || '') || 0; const rightTime = Date.parse(right?.updated_at || '') || 0; return leftTime - rightTime; } function isDetailedTask(task) { return Boolean(task && Array.isArray(task.events) && Object.prototype.hasOwnProperty.call(task, 'operation')); } function findTaskById(taskId) { return taskStore.find((item) => item.task_id === taskId) || localTaskOverlayStore.get(taskId)?.task || runtimeTaskStore.get(taskId) || null; } function rememberRemoteTask(task) { if (!task?.task_id) return task; const previous = remoteTaskStore.get(task.task_id); if (previous && compareTaskVersion(previous, task) > 0) return previous; const normalized = { ...task, __server_status: task.status }; remoteTaskStore.set(task.task_id, normalized); return normalized; } function mergeRemoteTask(task) { if (!task?.task_id) return task; const remoteTask = rememberRemoteTask(task); const detail = taskDetailStore.get(task.task_id); let merged = remoteTask; if (detail && compareTaskVersion(detail.task, remoteTask) === 0) { merged = { ...remoteTask, ...detail.task, __server_status: remoteTask.status }; } else if (detail && compareTaskVersion(detail.task, remoteTask) < 0) { taskDetailStore.delete(task.task_id); } const overlay = localTaskOverlayStore.get(task.task_id); if (!overlay || Date.now() - overlay.at > LOCAL_TASK_OVERLAY_TTL_MS) { if (overlay) localTaskOverlayStore.delete(task.task_id); return merged; } if (TERMINAL_TASK_STATUSES.has(String(remoteTask.status || ''))) { localTaskOverlayStore.delete(task.task_id); return merged; } return { ...merged, ...overlay.task, __server_status: remoteTask.status }; } function cacheTaskDetails(task) { if (!isDetailedTask(task)) return; taskDetailStore.set(task.task_id, { task }); } async function ensureTaskDetails(taskId, { force = false } = {}) { const current = findTaskById(taskId); const cached = taskDetailStore.get(taskId); if (!force && cached && (!current || compareTaskVersion(cached.task, current) >= 0)) return cached.task; const existingRequest = taskDetailRequests.get(taskId); if (existingRequest) return existingRequest; const request = apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { timeoutMs: RESULT_API_TIMEOUT_MS }) .then((result) => { if (!result.task) throw new Error('服务端未返回任务详情。'); const task = { ...result.task, __server_status: result.task.status }; cacheTaskDetails(task); upsertLocalTask(task, { forceVisible: false, source: 'server' }); return task; }) .finally(() => taskDetailRequests.delete(taskId)); taskDetailRequests.set(taskId, request); return request; } function loadSelectedTaskDetails(task) { if (!authUser || !task || isDetailedTask(task)) return; const lifecycle = $('#taskLifecycle'); if (lifecycle) lifecycle.textContent = '正在加载任务详情…'; void ensureTaskDetails(task.task_id).catch((error) => { if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`; }); } function upsertLocalTask(task, { forceVisible = true, source = 'server' } = {}) { if (!task?.task_id) return; const normalizedTask = source === 'server' ? rememberRemoteTask(task) : task; if (source === 'server') { cacheTaskDetails(normalizedTask); if (TERMINAL_TASK_STATUSES.has(String(normalizedTask.status || ''))) { localTaskOverlayStore.delete(normalizedTask.task_id); } } else { localTaskOverlayStore.set(normalizedTask.task_id, { task: { ...(localTaskOverlayStore.get(normalizedTask.task_id)?.task || {}), ...normalizedTask }, at: Date.now() }); } const index = taskStore.findIndex((item) => item.task_id === normalizedTask.task_id); let nextTask; if (index >= 0) { const existing = taskStore[index]; nextTask = { ...existing, ...normalizedTask }; taskStore[index] = nextTask; } else if (forceVisible && !runtimeTaskStore.has(normalizedTask.task_id)) { nextTask = { ...normalizedTask }; taskStore.unshift(nextTask); } else { nextTask = { ...(runtimeTaskStore.get(normalizedTask.task_id) || {}), ...normalizedTask }; } cacheRuntimeTask(nextTask); saveTaskStore(); renderTaskCards(); } function updateLocalTask(taskId, patch) { const visibleIndex = taskStore.findIndex((item) => item.task_id === taskId); const existing = visibleIndex >= 0 ? taskStore[visibleIndex] : findTaskById(taskId); if (!existing) return; const updated = { ...existing, ...patch }; localTaskOverlayStore.set(taskId, { task: updated, at: Date.now() }); if (visibleIndex >= 0) taskStore[visibleIndex] = updated; cacheRuntimeTask(updated); saveTaskStore(); renderTaskCards(); } function selectedTask() { return findTaskById(currentTaskId) || (!IS_HISTORY_PAGE && runtimeTaskStore.get(currentTaskId)) || taskStore[0] || null; } function taskFailureInfo(task) { if (task?.failure && typeof task.failure === 'object') return task.failure; const parseResponse = task?.parse_response; if (parseResponse?.failure && typeof parseResponse.failure === 'object') return parseResponse.failure; if (parseResponse?.failure_stage || parseResponse?.failure_message || parseResponse?.failure_source) { return parseResponse; } return null; } function failureStageLabel(stage) { const labels = { configuration: '服务配置', external_session_creation: 'Agent 会话创建', external_parse_request: 'Agent 请求', external_session_recovery: 'Agent 会话恢复', agent_response_parsing: 'Agent 返回解析', agent_result_validation: 'Agent 结果校验', operation_contract_validation: 'operation 契约校验', parse_worker_lease: '解析 Worker 租约', plugin_handoff: '插件交接', handoff: '插件交接', preflight: 'ERP预检', live_submit: 'ERP保存', browser_execution: '插件浏览器执行', erp_execution: 'ERP 写入', receipt_reconciliation: 'ERP 回执回查', erp_resolution: 'ERP 对象检索' }; return labels[String(stage || '')] || String(stage || '任务处理'); } function taskFailureText(task) { const failure = taskFailureInfo(task); if (!failure) return ''; const stage = failureStageLabel(failure.failure_stage || failure.stage); const message = failure.failure_message || failure.message || failure.error_code || '任务失败。'; return `失败:${stage} · ${message}`; } function taskStatusText(task) { if (!task) return '未知'; const status = task.status || task.result?.status || 'created'; if (requiresManualConfirmation(task)) return 'Agent已返回,待确认并提交 ERP 插件'; const failureText = taskFailureText(task); if (failureText && isFailureStatus(status)) return failureText; const stage = task.result?.stage || task.stage || ''; if (status === 'running' && stage === 'parse') return '发送中 / 解析中'; if (status === 'parse_queued') return '排队解析'; if (status === 'parse_running') return '解析中'; if (status === 'awaiting_user_input' || status === 'agent_parse_needs_input') return '等待用户补充'; if (['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status)) return '外部解析失败'; if (status === 'agent_parse_passed') return '外部解析完成'; if (status === 'parse') return '解析中'; if (status === 'completed') return '已完成'; if (status === 'running') return '执行中'; if (status === 'accepted') return '已接单'; if (status === 'confirmed' && task.confirmation_mode === 'automatic') return '全自动化已接管,等待插件执行'; if (status === 'confirmed') return '已确认,待提交到 ERP 插件'; if (status === 'queued') return '排队中'; if (status === 'paused') return '已暂停'; if (status === 'awaiting_confirmation') return '等待确认并提交 ERP 插件'; if (status === 'waiting_extension') return '等待插件连接'; if (status === 'parse_failed') return '拆解失败'; if (status === 'parse_blocked') return '拆解失败'; if (status === 'dry_run') return '已规划,未写入 ERP'; if (status === 'operation_blocked') return '业务未接入'; if (status === 'execution_uncertain') return '保存状态待回查'; if (status === 'reconciliation_pending') return '待回查'; if (status === 'cancelled') return '已取消'; if (status === 'post_save_recovery_required') return '需要恢复导出'; if (status === 'blocked') return '已阻断'; if (status === 'saved_unverified') return '已提交待回查'; return stage || status; } function taskStatusLabel(task) { if (!task) return '未知'; const status = taskCanonicalStatus(task); const stage = task.result?.stage || task.stage || ''; if (requiresManualConfirmation(task)) return '待确认'; if (['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status)) return '失败'; if (isFailureStatus(status)) return '失败'; if (status === 'running' && stage === 'parse') return '解析中'; if (status === 'confirmed' && task.confirmation_mode === 'automatic') return '自动执行中'; const labels = { created: '待开始', parse_queued: '排队解析', parse_running: '解析中', awaiting_user_input: '等待补充', agent_parse_needs_input: '等待补充', agent_parse_passed: '解析完成', parse: '解析中', completed: '已完成', running: '执行中', accepted: '已接单', confirmed: '已确认', queued: '排队中', paused: '已暂停', awaiting_confirmation: '待确认', waiting_extension: '等待插件', dry_run: '已规划', operation_blocked: '已阻断', execution_uncertain: '待回查', reconciliation_pending: '待回查', saved_unverified: '待回查', cancelled: '已取消', post_save_recovery_required: '待恢复' }; return labels[status] || stage || status; } function statusClassName(status) { const normalized = String(status || 'created').replace(/[^a-z0-9_-]/gi, '_'); return isFailureStatus(normalized) ? `${normalized} is-failure` : normalized; } function isFailureStatus(status) { const normalized = String(status || '').toLowerCase(); return /(?:failed|blocked|error|uncertain)/.test(normalized) || normalized === 'saved_unverified'; } function requiresManualConfirmation(task) { if (!task?.task_id || task.confirmed_at || task.handoff_status === 'accepted') return false; const status = task.status || task.result?.status || 'created'; return ['awaiting_confirmation', 'agent_parse_passed'].includes(status); } function taskCanonicalStatus(task) { return String(task?.status || task?.result?.status || 'created'); } function isAwaitingUserInput(task) { return task?.status === 'awaiting_user_input'; } async function sendTaskReply(task, textarea, button, statusNode) { const taskId = task?.task_id; if (!taskId || !isAwaitingUserInput(task) || taskReplyStates.get(taskId) === 'sending') return; const message = normalizeText(textarea?.value); if (!message) { if (statusNode) statusNode.textContent = '请先填写补充信息。'; textarea?.focus(); return; } taskReplyStates.set(taskId, 'sending'); if (button) button.disabled = true; if (statusNode) statusNode.textContent = '正在提交补充信息…'; try { const result = await apiRequest('/api/messages', { method: 'POST', body: { message, task_id: taskId, conversation_id: task.conversation_id || undefined, idempotency_key: `manual:${taskId}:${makeRequestId()}` } }); taskReplyStates.delete(taskId); taskReplyDrafts.delete(taskId); if (result.task) { currentTaskId = result.task.task_id; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); upsertLocalTask(result.task); } else { await syncRemoteTasks(); } } catch (error) { taskReplyStates.delete(taskId); if (button?.isConnected) button.disabled = false; if (statusNode?.isConnected) statusNode.textContent = error.message || String(error); } } function taskImportantMessage(task) { if (task?.important_message) return task.important_message; const status = String(task?.status || task?.result?.status || '').toLowerCase(); if (status === 'awaiting_user_input') { const reply = String(task?.parse_response?.reply || task?.result?.reply || '').trim(); return reply ? { kind: 'awaiting_user_input', text: reply, recorded_at: task?.updated_at || '', input_request: task?.input_request || undefined } : null; } if (task?.error_summary && isFailureStatus(status)) { return { kind: 'error', text: task.error_summary.message || '任务处理失败。', recorded_at: task.error_summary.recorded_at || task.updated_at || '', error_summary: task.error_summary }; } if (status === 'completed' && task?.success_receipt) { const receipt = task.success_receipt; return { kind: 'success', text: task.message || '任务已完成。', recorded_at: receipt.recorded_at || task.updated_at || '', success_receipt: receipt }; } return null; } function renderTaskReplyForm(task) { const form = el('form', 'task-input-request-form'); form.noValidate = true; const textarea = el('textarea', 'task-reply-input'); textarea.id = 'taskReplyInput'; textarea.name = 'message'; textarea.rows = 4; textarea.maxLength = 200_000; textarea.placeholder = '请输入补充信息,提交后将沿用当前任务的 Agent 会话。'; textarea.value = taskReplyDrafts.get(task.task_id) || ''; textarea.setAttribute('aria-label', '补充当前任务的信息'); textarea.addEventListener('input', () => { taskReplyDrafts.set(task.task_id, textarea.value); }); form.append(textarea); const actions = el('div', 'task-input-request-actions'); const button = el('button', '', taskReplyStates.get(task.task_id) === 'sending' ? '提交中…' : '提交补充信息'); button.id = 'sendTaskReplyButton'; button.type = 'submit'; button.disabled = taskReplyStates.get(task.task_id) === 'sending'; const statusNode = el('span', 'task-input-request-status'); statusNode.id = 'taskReplyStatus'; statusNode.setAttribute('role', 'status'); actions.append(button, statusNode); form.append(actions); form.addEventListener('submit', (event) => { event.preventDefault(); void sendTaskReply(task, textarea, button, statusNode); }); return form; } function renderTaskImportantMessage(task) { const important = taskImportantMessage(task); if (!important) return null; const panel = el('section', 'task-output-panel task-important-message-panel'); panel.id = 'importantMessagePanel'; panel.append(el('div', 'task-output-label', '需返回/交互用户的重要消息')); const kind = important.kind; const kindMeta = kind === 'awaiting_user_input' ? { label: '等待补充', className: 'state-warn' } : kind === 'error' ? { label: '错误摘要', className: 'state-bad' } : { label: '成功回执', className: 'state-ok' }; const card = el('article', `task-important-message-card ${kindMeta.className}`); const heading = el('div', 'task-important-message-heading'); heading.append(el('strong', 'task-outcome-title', kindMeta.label)); heading.append(el('time', 'task-outcome-time', `记录时间:${formatDateTime(important.recorded_at)}`)); card.append(heading); card.append(el('p', 'task-outcome-message', important.text)); if (kind === 'success') { const receipt = important.success_receipt?.receipt || {}; const groups = displayValues(receipt.group_numbers || receipt.group_number); const orders = displayValues(receipt.order_numbers || receipt.order_number); if (groups.length) card.append(el('p', 'task-outcome-line', `团号:${groups.join('、')}`)); if (orders.length) card.append(el('p', 'task-outcome-line', `订单号:${orders.join('、')}`)); } panel.append(card); if (kind === 'awaiting_user_input') { const formPanel = el('section', 'task-input-request'); formPanel.id = 'inputRequestPanel'; const header = el('div', 'task-input-request-head'); header.append(el('div', 'task-output-label', '补充信息')); header.append(el( 'span', 'task-input-request-state', taskReplyStates.get(task.task_id) === 'sending' ? '提交中…' : '仅补充当前任务' )); formPanel.append(header, renderTaskReplyForm(task)); panel.append(formPanel); } return panel; } function isTaskPollable(task) { const localStatuses = [task?.status, task?.result?.status, task?.handoff_status] .map((value) => String(value || '').toLowerCase()); if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status))) return false; const status = taskServerStatus(task); if (TERMINAL_TASK_STATUSES.has(status) || MANUAL_RECONCILIATION_STATUSES.has(status)) return false; return Boolean( task?.confirmed_at && ( ['queued', 'accepted', 'running'].includes(status) || ['accepted', 'running'].includes(task?.handoff_status) ) ); } function canDeleteTask(task) { return Boolean(task); } function canStartConfirmedTask(task) { return Boolean( task?.operation && task?.confirmed_at && task?.status === 'confirmed' && task?.handoff_status === 'awaiting_handoff' ); } function canReconcileTask(task) { const result = task?.result && typeof task.result === 'object' ? task.result : {}; const failedWithReconciliation = taskCanonicalStatus(task) === 'failed' && result.reconciliation_required === true && ['reconciliation_pending', 'execution_uncertain', 'saved_unverified'].includes(String(result.status || '')); return Boolean( task?.operation && task?.confirmed_at && (taskCanonicalStatus(task) === 'reconciliation_pending' || failedWithReconciliation) && isLifecycleOperationView(selectedOperation(task)) ); } function canResumePrewriteTask(task) { const result = task?.result && typeof task.result === 'object' ? task.result : {}; const stage = String(result.stage || task?.stage || ''); return Boolean( task?.operation && task?.confirmed_at && taskCanonicalStatus(task) === 'running' && ['auto_executor', 'erp_keepalive_degraded'].includes(stage) && result.no_erp_write === true && result.write_attempted !== true ); } function isAutomaticTask(task) { return task?.confirmation_mode === 'automatic'; } async function autoDispatchReadyTasks({ force = false } = {}) { if (!authUser || autoHandoffInProgress) return; autoHandoffInProgress = true; try { let candidates = taskStore; if (organizationAutomationEnabled) { const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false'); const byId = new Map(taskStore.map((task) => [task.task_id, task])); for (const task of Array.isArray(result.tasks) ? result.tasks : []) { byId.set(task.task_id, task); cacheRuntimeTask(task); } candidates = [...byId.values()]; } const orderedCandidates = [...candidates].sort((left, right) => { const leftTime = Date.parse(left?.created_at || '') || 0; const rightTime = Date.parse(right?.created_at || '') || 0; return leftTime - rightTime || String(left?.task_id || '').localeCompare(String(right?.task_id || '')); }); for (const task of orderedCandidates) { if (!isAutomaticTask(task) || !task?.confirmed_at || task?.status !== 'confirmed' || task?.handoff_status !== 'awaiting_handoff') continue; const detailedTask = await ensureTaskDetails(task.task_id); if (!canStartConfirmedTask(detailedTask)) continue; const retryAt = autoHandoffRetryAt.get(task.task_id) || 0; if (!force && retryAt > Date.now()) continue; try { const handoff = await handoffTaskToExtension(detailedTask); if (handoff?.status === 'paused') { autoHandoffRetryAt.set(task.task_id, Date.now() + AUTO_HANDOFF_RETRY_MS); break; } else if (handoff?.status === 'queued') { autoHandoffRetryAt.set(task.task_id, Date.now() + AUTO_HANDOFF_RETRY_MS); break; } else { autoHandoffRetryAt.delete(task.task_id); break; } } catch (error) { autoHandoffRetryAt.set(task.task_id, Date.now() + AUTO_HANDOFF_RETRY_MS); // Keep the server-authoritative confirmed/awaiting_handoff state in // the local snapshot. Replacing it with waiting_extension would make // the task fail canStartConfirmedTask() and prevent a later // BRIDGE_READY event from retrying the pre-claim handoff. updateLocalTask(task.task_id, { stage: 'bridge', message: `全自动化等待插件:${error.message || String(error)}`, error: error.message || String(error) }); break; } } } finally { autoHandoffInProgress = false; } } function taskStateClass(status) { if (['completed', 'parse_response_received', 'task_confirmed'].includes(status)) return 'state-ok'; if (isFailureStatus(status)) return 'state-bad'; return 'state-warn'; } function taskDisplayText(task, output) { const status = output?.status; if (status === 'parse_sending') return '正在发送到外部解析服务'; if (status === 'parse_response_received') return '已收到外部解析结果'; if (status === 'parse_response_blocked') return '外部解析服务已阻断'; if (status === 'agent_parse_blocked') return '外部 Skill 阻断'; if (status === 'agent_parse_needs_input') return '等待用户补充'; if (status === 'agent_parse_passed') return '外部解析完成'; if (status === 'create_error') return '创建失败'; return taskStatusText(task); } function selectedOperation(task) { return task?.operation || task?.result?.operation || null; } const LIFECYCLE_ACTIONS = new Set([ 'passenger_list_import', 'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel', 'arrangement_transport', 'arrangement_other', 'order_update_independent', 'order_update_shared_plan', 'order_update_shared_child', 'order_cancel', 'order_restore', 'order_delete' ]); function isLifecycleOperationView(operation) { return LIFECYCLE_ACTIONS.has(String(operation?.action || '')); } function hasLifecycleTestContextView(operation) { const context = operation?.source?.test_context; return isLifecycleOperationView(operation) && Boolean(context && typeof context === 'object' && !Array.isArray(context) && Object.keys(context).length); } function lifecycleReviewIdentifier(refs = {}) { return String( refs.identifier || refs.order_no || refs.group_no || refs.child_order_no || refs.parent_group_no || refs.parent_plan_no || refs.plan_no || refs.ddid || refs.tid || '' ).trim(); } function operationReviewDetail(operation = {}) { const data = operation.data || {}; if (data.arrangement) return `${operation.action} / ${data.arrangement.mode || '未声明模式'}`; if (Array.isArray(data.updates?.actions)) { return data.updates.actions.map((item) => `${item.target || '未知字段'}:${item.operation || '未知动作'}`).join(','); } if (data.transition) return `${data.transition.mode || '未知路径'}:${data.transition.from_status || '未知'} → ${data.transition.to_status || '未知'}`; if (Array.isArray(data.export_types)) return `文件类型:${data.export_types.join('、')}(保存为后台附件)`; if (data.passenger_list) return `${data.passenger_list.operation || '名单操作'} / ${data.passenger_list.row_count || 0} 行`; return data.order_number?.suffix ? `显式 suffix:${data.order_number.suffix}` : '按标准 operation 执行'; } function renderOperationReview(task) { const operation = selectedOperation(task); if (!operation || operation.action === 'confirmation_export') return null; const data = operation.data || {}; const refs = data.existing_refs || {}; const context = operation.source?.test_context || {}; const lifecycleTest = hasLifecycleTestContextView(operation); const businessLookup = Boolean(data.customer?.name && Array.isArray(data.departure_dates) && data.departure_dates.length === 1); const panel = el('section', 'task-output-panel task-operation-review-panel'); const heading = el('div', 'task-output-heading'); heading.append(el('div', 'task-output-label', lifecycleTest ? '生命周期测试写入审核' : '标准 operation 审核')); panel.append(heading); const card = el('div', `task-operation-review-card ${lifecycleTest ? 'state-warn' : ''}`); const rows = [ ['action', operation.action || '未识别'], ['目标对象', refs.kind ? `${refs.kind} / ${lifecycleReviewIdentifier(refs) || (businessLookup ? '按业务条件检索' : '缺少编号')}` : businessLookup ? '按业务条件检索已有对象' : '新建对象'], ['目标日期', refs.departure_date || (Array.isArray(data.departure_dates) ? data.departure_dates.join('、') : '未提供')], ['业务选择', [data.customer?.name, data.product?.name, data.leader?.name].filter(Boolean).join(' / ') || '由当前对象确定'], ['操作明细', operationReviewDetail(operation)] ]; if (lifecycleTest) { rows.push( ['测试运行', context.run_id || '缺失'], ['账号 / 标记', `${context.account || '缺失'} / ${context.marker || '缺失'}`], ['创建引用', `${Array.isArray(context.created_refs) ? context.created_refs.length : 0} 个(服务端确认时精确复核当前对象)`], ['写入授权', context.allow_live_write === true ? '控制平面已在人工确认后授权' : '尚未授权;点击确认后由控制平面校验并注入'] ); } for (const [label, value] of rows) { const row = el('div', 'task-operation-review-row'); row.append(el('span', 'task-operation-review-key', label)); row.append(el('span', 'task-operation-review-value', String(value || '未提供'))); card.append(row); } if (lifecycleTest) { card.append(el('p', 'task-operation-review-note', '仅允许当前运行、AI 测试账号、2026 年 9 月且精确命中 created_refs 的对象;开启全自动后,AgentBus 任务不因生命周期类型转为人工确认。')); } panel.append(card); return panel; } function renderTaskArtifacts(task) { const report = task?.result?.report && typeof task.result.report === 'object' ? task.result.report : {}; const reportArtifacts = Array.isArray(report.artifacts) ? report.artifacts : []; const receipt = task?.success_receipt?.receipt && typeof task.success_receipt.receipt === 'object' ? task.success_receipt.receipt : {}; const receiptAttachments = [ ...(Array.isArray(receipt.reply_attachments) ? receipt.reply_attachments : []), ...(Array.isArray(receipt.attachments) ? receipt.attachments : []), ...(Array.isArray(task?.important_message?.attachments) ? task.important_message.attachments : []) ]; const fallbackByName = new Map( receiptAttachments .filter((item) => item && typeof item === 'object') .map((item) => [ String(item.file_name || item.name || item.fileName || '').trim().toLowerCase(), item ]) .filter(([name]) => name) ); const artifacts = reportArtifacts.length ? reportArtifacts.map((artifact) => { const fallback = fallbackByName.get( String(artifact?.file_name || artifact?.name || artifact?.fileName || '').trim().toLowerCase() ) || {}; return { ...fallback, ...artifact, file_name: artifact?.file_name || artifact?.name || artifact?.fileName || fallback.file_name || fallback.name || '团队文件', download_url: artifact?.download_url || artifact?.public_url || artifact?.url || fallback.download_url || fallback.public_url || fallback.url || '' }; }) : receiptAttachments; if (!artifacts.length) return null; const panel = el('section', 'task-output-panel task-artifact-panel'); const heading = el('div', 'task-output-heading'); heading.append(el('div', 'task-output-label', '后台附件')); panel.append(heading); const list = el('div', 'task-artifact-list'); if (reportArtifacts.length && report.backend_stored !== true) { list.append(el('p', 'task-artifact-pending', 'ERP 文件已读取,正在保存到平台后台…')); panel.append(list); return panel; } for (const artifact of artifacts) { const row = el('div', 'task-artifact-row'); const meta = el('div', 'task-artifact-meta'); meta.append(el('strong', 'task-artifact-name', artifact.file_name || '团队文件')); row.append(meta); const downloadUrl = artifact.download_url || artifact.public_url || artifact.url || (artifact.artifact_id && task?.task_id ? `/api/tasks/${encodeURIComponent(task.task_id)}/artifacts/${encodeURIComponent(artifact.artifact_id)}` : ''); if (downloadUrl) { const link = el('a', 'secondary-button task-artifact-download', '下载文件'); link.href = downloadUrl; link.setAttribute('download', artifact.file_name || 'team-file.bin'); link.rel = 'noopener'; row.append(link); } else { row.append(el('span', 'task-artifact-unavailable', '附件链接不可用')); } list.append(row); } panel.append(list); return panel; } function formatBusinessDate(value) { return value ? String(value) : '未填写'; } function formatDateTime(value) { if (!value) return '未记录'; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleString('zh-CN', { hour12: false }); } function formatMoney(value) { const amount = toNumber(value); return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; } const DISPLAY_REDACT_KEY_PATTERN = /(?:api[_-]?key|authorization|cookie|password|secret|csrf(?:[_-]?token)?)/i; const DISPLAY_MASK_KEY_PATTERN = /(?:session[_-]?id|idempotency[_-]?key)/i; function redactJsonForDisplay(value, key = '') { if (DISPLAY_REDACT_KEY_PATTERN.test(key)) return '[已隐藏]'; if (DISPLAY_MASK_KEY_PATTERN.test(key)) return value ? '[已生成]' : ''; if (Array.isArray(value)) return value.map((item) => redactJsonForDisplay(item, key)); if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([entryKey, entryValue]) => [ entryKey, redactJsonForDisplay(entryValue, entryKey) ]) ); } return value; } function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function appendField(container, label, value) { const item = el('div', 'detail-field'); item.append(el('span', '', label)); const displayValue = value === undefined || value === null || value === '' ? '未填写' : value; item.append(el('strong', '', displayValue)); container.append(item); } function appendMetric(container, label, value) { const item = el('div', 'metric'); item.append(el('span', '', label)); item.append(el('strong', '', value || '未填写')); container.append(item); } function createDetailBlock(title) { const block = el('section', 'detail-block'); block.append(el('h3', '', title)); return block; } function passengerRows(counts) { return [ ['成人', counts.adult], ['小孩占床', counts.child_bed], ['小孩不占床', counts.child_no_bed], ['婴儿', counts.infant], ['领队', counts.leader] ].map(([label, value]) => [label, toNumber(value)]); } function priceRows(data) { const counts = data.passenger_counts || {}; const prices = data.prices || {}; return [ ['成人', counts.adult, prices.adult], ['小孩占床', counts.child_bed, prices.child_bed], ['小孩不占床', counts.child_no_bed, prices.child_no_bed], ['婴儿', counts.infant, prices.infant], ['领队', counts.leader, prices.leader] ].map(([label, count, price]) => { const normalizedCount = toNumber(count); const normalizedPrice = toNumber(price); return { label, count: normalizedCount, price: normalizedPrice, amount: normalizedCount * normalizedPrice }; }).filter((row) => row.count || row.price); } function renderMiniTable(rows, columns) { const table = el('div', 'mini-table'); const head = el('div', 'mini-table-row mini-table-head'); for (const column of columns) head.append(el('span', '', column.label)); table.append(head); for (const row of rows) { const line = el('div', 'mini-table-row'); for (const column of columns) line.append(el('span', '', column.render(row))); table.append(line); } return table; } function taskStepState(task, step) { const status = task.status || task.result?.status || 'created'; const handoff = task.handoff_status || ''; const hasOperation = Boolean(selectedOperation(task)); if (step === 'parse') { if (['parse_failed', 'parse_blocked'].includes(status)) return 'blocked'; return hasOperation ? 'done' : 'active'; } if (step === 'handoff') { if (status === 'waiting_extension') return 'active'; if (handoff === 'accepted' || ['accepted', 'running', 'dry_run', 'saved_unverified', 'completed', 'blocked'].includes(status)) return 'done'; return hasOperation ? 'active' : 'wait'; } if (step === 'erp') { if (status === 'dry_run') return 'done'; if (status === 'blocked') return 'blocked'; if (['saved_unverified', 'completed'].includes(status)) return 'done'; if (['accepted', 'running', 'queued'].includes(status)) return 'active'; return 'wait'; } if (step === 'verify') { if (status === 'completed') return 'done'; if (status === 'saved_unverified') return 'active'; if (status === 'blocked') return 'blocked'; return 'wait'; } return 'wait'; } function parseErpReceiptText(value) { const raw = String(value || ''); const text = raw .replace(/\r/g, '') .replace(//gi, '\n') .replace(/<\/(p|div|li|tr)>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .trim(); if (!text) return null; const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); const valueAfterLabel = (label) => { const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n]+)`, 'i'); const line = lines.find((item) => pattern.test(item)); const source = line || text; const match = source.match(pattern); const value = match ? match[1].trim() : ''; return label === '团号' ? value.replace(/[\s。;;,,].*$/, '').trim() : value; }; const groupNumber = valueAfterLabel('团号'); const product = valueAfterLabel('产品'); const passengerText = valueAfterLabel('人数'); const success = /下单成功|成功|success|ສໍາເລັດ/i.test(text); if (!groupNumber && !product && !passengerText) return null; return { status: success ? 'submit_success_receipt' : 'submit_receipt', success, title: lines[0] || '', group_number: groupNumber, product, passenger_text: passengerText }; } function receiptFromTask(task) { const explicit = task?.success_receipt?.receipt; if (explicit) return explicit; const result = task?.result || {}; const direct = result.erp_receipt || result.report?.erp_receipt; if (direct) return direct; const alerts = [ ...(Array.isArray(result.alerts) ? result.alerts : []), ...(Array.isArray(result.report?.alerts) ? result.report.alerts : []) ]; const alertReceipt = alerts.map((alert) => alert.erp_receipt || parseErpReceiptText(alert.message)).find(Boolean); if (alertReceipt) return alertReceipt; return [ result.message, result.report?.message, task?.message, result.report?.raw_message ].map(parseErpReceiptText).find(Boolean) || null; } function displayValues(value) { if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean); if (value === undefined || value === null || value === '') return []; return [String(value).trim()].filter(Boolean); } function taskStageSnapshot(task) { const emptyStage = () => ({ mode: 'pending', current: null, currentLabel: '' }); if (!task) { return { agent: emptyStage(), business: emptyStage(), receipt: emptyStage() }; } const result = task.result || {}; const status = String(result.status || task.status || 'created'); const stage = String(result.stage || task.stage || ''); const operation = selectedOperation(task); const receipt = receiptFromTask(task); const parseFailed = ['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status) || (stage === 'parse' && /failed|blocked/i.test(status)); const parseNeedsInput = ['awaiting_user_input', 'agent_parse_needs_input'].includes(status); const agentSucceeded = Boolean(operation) || ['agent_parse_passed', 'awaiting_confirmation', 'confirmed', 'queued', 'accepted', 'running', 'paused', 'waiting_extension', 'saved_unverified', 'completed', 'blocked'].includes(status); const agent = parseFailed ? { mode: 'failed', current: 'failed', currentLabel: '已失败' } : parseNeedsInput ? { mode: 'needs', current: 'needs_input', currentLabel: '等待补充' } : agentSucceeded ? { mode: 'complete', current: 'success', currentLabel: '已成功' } : status === 'parse_running' || (status === 'running' && stage === 'parse') ? { mode: 'processing', current: 'submitted', currentLabel: '处理中' } : { mode: 'needs', current: 'submitted', currentLabel: '已提交' }; let business = emptyStage(); if (agentSucceeded) { if (status === 'completed' || status === 'batch_fallback_completed') { business = { mode: 'complete', current: 'completed', currentLabel: '已完成' }; } else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') { business = { mode: 'needs', current: 'processing', currentLabel: '处理中' }; } else if (['blocked', 'failed', 'cancelled', 'extension_error', 'batch_fallback_incomplete', 'live_submit_blocked', 'preflight_blocked'].includes(status)) { business = { mode: 'failed', current: 'failed', currentLabel: '已失败' }; } else if (status === 'accepted' || status === 'queued') { business = { mode: 'processing', current: 'accepted', currentLabel: '已接单' }; } else if (status === 'running' || ['browser_execution', 'open_order_form', 'wait_order_form', 'preflight', 'live_submit', 'verification', 'batch_fallback'].includes(stage)) { business = { mode: 'processing', current: 'processing', currentLabel: '处理中' }; } else if (['confirmed', 'waiting_extension', 'paused'].includes(status)) { business = { mode: 'needs', current: null, currentLabel: '需处理' }; } else { business = { mode: 'pending', current: null, currentLabel: '待开始' }; } } let receiptStage; if (receipt) { receiptStage = { mode: 'complete', current: 'received', currentLabel: '已获取回执' }; } else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') { receiptStage = { mode: 'needs', current: 'pending', currentLabel: '待获取' }; } else if (business.mode === 'failed') { receiptStage = { mode: 'pending', current: null, currentLabel: '待获取' }; } else if (business.mode === 'complete') { receiptStage = { mode: 'needs', current: 'pending', currentLabel: '待获取' }; } else { receiptStage = emptyStage(); } return { agent, business, receipt: receiptStage }; } function renderTaskStages(task) { const container = $('#taskStages'); if (!container) return; container.innerHTML = ''; const snapshot = taskStageSnapshot(task); const stages = [ { key: 'agent', title: 'AI处理阶段', options: [ { key: 'submitted', label: '已提交' }, { key: 'needs_input', label: '等待补充' }, { key: 'success', label: '已成功' }, { key: 'failed', label: '已失败' } ] }, { key: 'business', title: '系统写入处理', options: [ { key: 'accepted', label: '已接单' }, { key: 'processing', label: '处理中' }, { key: 'completed', label: '已完成' }, { key: 'failed', label: '已失败' } ] }, { key: 'receipt', title: '获取回执', options: [ { key: 'pending', label: '待获取' }, { key: 'received', label: '已获取回执' }, { key: 'failed', label: '获取失败' } ] } ]; const flow = el('div', 'task-stage-flow'); for (const stage of stages) { const state = snapshot[stage.key]; const card = el('article', `task-stage-card is-${state.mode}`); const heading = el('div', 'task-stage-heading'); heading.append(el('strong', 'task-stage-title', stage.title)); card.append(heading); const currentOption = stage.options.find((option) => option.key === state.current); const statusItem = el( 'span', 'task-stage-status is-current', currentOption?.label || state.currentLabel || '待开始' ); statusItem.setAttribute('role', 'status'); card.append(statusItem); flow.append(card); } container.append(flow); } function renderTaskDetail() { const container = $('#taskDetail'); if (!container) return; const previousLifecycle = $('#taskLifecycle'); if (previousLifecycle?.dataset.taskId && Number.isFinite(previousLifecycle.scrollTop)) { lifecycleScrollPositions.set(previousLifecycle.dataset.taskId, previousLifecycle.scrollTop); } container.innerHTML = ''; const task = selectedTask(); const taskState = $('#taskState'); const confirmButton = $('#confirmTaskButton'); const deleteButton = $('#deleteTaskButton'); const status = task?.status || task?.result?.status || 'created'; const importantMessagePanel = renderTaskImportantMessage(task); renderTaskStages(task); if (confirmButton) { const canConfirm = Boolean(task && requiresManualConfirmation(task)); const canStart = Boolean(task && !isAutomaticTask(task) && canStartConfirmedTask(task)); const canResume = Boolean(task && canResumePrewriteTask(task)); const canReconcile = Boolean(task && canReconcileTask(task)); const reconciliationBusy = Boolean(task && taskReconciliationStates.get(task.task_id) === 'running'); confirmButton.hidden = !canConfirm && !canStart && !canResume && !canReconcile; confirmButton.disabled = (!canConfirm && !canStart && !canResume && !canReconcile) || reconciliationBusy; const lifecycleReview = hasLifecycleTestContextView(selectedOperation(task)); confirmButton.textContent = reconciliationBusy ? '只读回查中…' : canReconcile ? RECONCILE_LABEL : canResume ? '恢复保存前执行' : canStart ? RETRY_HANDOFF_LABEL : lifecycleReview ? '审核并授权本次测试写入' : MANUAL_HANDOFF_LABEL; } if (deleteButton) { const deleteState = task ? taskDeleteStates.get(task.task_id) : ''; deleteButton.disabled = !canDeleteTask(task) || deleteState === 'deleting'; if (deleteState === 'deleting') deleteButton.textContent = '删除中…'; else if (deleteState === 'failed') deleteButton.textContent = '重试删除'; else deleteButton.textContent = '删除任务'; } if (taskState) { taskState.textContent = task ? taskStatusLabel(task) : '未创建'; taskState.className = `state ${task ? taskStateClass(status) : 'state-warn'}`; } const lifecyclePanel = el('section', 'task-output-panel task-lifecycle-panel'); const lifecycleHeading = el('div', 'task-output-heading'); lifecycleHeading.append(el('div', 'task-output-label', '任务生命周期')); const copyButton = el('button', 'secondary-button task-copy-button', '一键复制'); copyButton.type = 'button'; copyButton.dataset.copyLifecycle = 'true'; copyButton.title = '复制黑框中的全部内容'; copyButton.setAttribute('aria-label', '复制任务生命周期'); lifecycleHeading.append(copyButton); lifecyclePanel.append(lifecycleHeading); const lifecycle = renderTaskLifecycle(task); lifecycle.id = 'taskLifecycle'; lifecycle.dataset.taskId = task?.task_id || ''; lifecycle.setAttribute('role', 'log'); lifecycle.setAttribute('aria-live', 'polite'); lifecyclePanel.append(lifecycle); const panels = []; if (importantMessagePanel) panels.push(importantMessagePanel); const operationReviewPanel = renderOperationReview(task); if (operationReviewPanel) panels.push(operationReviewPanel); const artifactPanel = renderTaskArtifacts(task); if (artifactPanel) panels.push(artifactPanel); panels.push(lifecyclePanel); container.append(...panels); const rememberedScrollTop = task?.task_id ? lifecycleScrollPositions.get(task.task_id) : undefined; if (Number.isFinite(rememberedScrollTop)) lifecycle.scrollTop = rememberedScrollTop; loadSelectedTaskDetails(task); } const EXTERNAL_TRACE_MESSAGES = { not_started: '解析请求已创建,等待调用外部 Agent。', creating_session: '正在创建外部 Agent 会话。', session_created: '外部 Agent 会话已创建。', stream_connecting: '正在接收外部 Agent 的 SSE 响应。', stream_completed: '外部 Agent SSE 响应已完成。', result_received: '已收到 Agent 返回结果。', contract_validating: '正在校验 Agent 返回的标准 operation 契约。', contract_repairing: '首次结果未通过契约校验,正在发起一次同会话修复。', recovering_session: '外部 Agent 会话异常,正在恢复。', session_recreated: '外部 Agent 会话已重建。', completed_without_json: 'Agent 响应已结束,但未返回可识别的 JSON。', failed: '外部 Agent 请求失败。', not_configured: '外部解析服务未配置。' }; function externalTraceMessage(item) { return EXTERNAL_TRACE_MESSAGES[item?.stage] || `外部解析阶段:${item?.stage || 'unknown'}。`; } function taskLifecycleEntries(task) { const entries = []; let order = 0; for (const event of Array.isArray(task?.events) ? task.events : []) { entries.push({ ...event, source: 'task_event', group: event.stage || 'system', order: order++ }); } const trace = task?.parse_response?.external_request; for (const item of Array.isArray(trace?.timeline) ? trace.timeline : []) { entries.push({ id: `agent-trace-${item.sequence || order}`, status: 'info', stage: item.stage || 'external_parse', message: externalTraceMessage(item), created_at: item.at, payload: item, source: 'agent_trace', group: 'parse', order: order++ }); } const error = task?.error_summary; const failure = taskFailureInfo(task); const rawErrorText = error?.message || failure?.failure_message || ''; const errorText = taskFailureText(task) || rawErrorText; if (errorText) { const hasErrorEntry = entries.some((entry) => { const values = [entry.message, entry.payload?.failure_message, entry.payload?.message] .filter(Boolean) .map((value) => String(value)); return values.some((value) => value.includes(errorText) || (rawErrorText && value.includes(rawErrorText))); }); if (!hasErrorEntry) { const errorStage = error?.stage || error?.failure_stage || failure?.failure_stage || 'task'; entries.push({ id: `task-error-summary-${error?.recorded_at || task?.updated_at || 'latest'}`, status: 'error', stage: errorStage, message: errorText.startsWith('失败:') ? errorText : `失败:${failureStageLabel(errorStage)} · ${errorText}`, created_at: error?.recorded_at || task?.updated_at || '', payload: { error_code: error?.error_code || failure?.error_code || 'task_failed', failure_stage: error?.failure_stage || errorStage, failure_source: error?.failure_source || failure?.failure_source || '', failure_message: rawErrorText || errorText }, source: 'task_error_summary', group: errorStage, order: order++ }); } } return entries.sort((left, right) => { const leftTime = Date.parse(left.created_at || '') || 0; const rightTime = Date.parse(right.created_at || '') || 0; return leftTime - rightTime || left.order - right.order; }); } function lifecycleLevel(entry) { const source = `${entry.status || ''} ${entry.message || ''} ${entry.payload?.failure_stage || ''}`; if (/failed|blocked|error|uncertain|失败|阻断|错误|不可用/i.test(source)) return 'error'; if (/completed|received|accepted|confirmed|success|完成|收到|接单|确认|成功/i.test(source)) return 'ok'; if (/repair|recover|waiting|pending|queued|等待|恢复|修复/i.test(source)) return 'warn'; return 'info'; } function lifecycleGroupLabel(entry) { if (entry.source === 'agent_trace' || entry.group === 'parse') return 'AI处理阶段'; return taskLogStageLabel(entry.group || entry.stage); } function taskLifecycleDetails(entry, task) { const details = { event_id: entry.id, source: entry.source, status: entry.status || '', stage: entry.stage || '', created_at: entry.created_at || '', payload: entry.payload || {} }; if (entry.source === 'task_event' && entry.stage === 'parse' && ['awaiting_confirmation', 'awaiting_user_input', 'parse_failed'].includes(entry.status)) { details.parse_response = task?.parse_response || null; details.operation = task?.operation || null; details.failure = taskFailureInfo(task); } if (entry.source === 'task_event' && ['execution', 'browser_execution', 'preflight', 'live_submit', 'receipt', 'verification', 'reconciliation'].includes(entry.stage)) { details.executor_response = task?.result || null; details.failure = taskFailureInfo(task); } return details; } async function writeClipboardText(text) { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch (error) { // Fall through to the DOM fallback when clipboard permissions are unavailable. } } const input = el('textarea', 'clipboard-fallback-input', text); input.setAttribute('readonly', ''); input.style.position = 'fixed'; input.style.top = '-9999px'; input.style.left = '-9999px'; input.style.opacity = '0'; document.body.append(input); input.focus(); input.select(); let copied = false; try { copied = document.execCommand('copy'); } finally { input.remove(); } if (!copied) throw new Error('当前浏览器不支持复制。'); } async function copyTaskLifecycle(button) { const lifecycle = $('#taskLifecycle'); const text = lifecycle?.textContent || ''; if (!lifecycle || !text.trim()) return; button.disabled = true; button.classList.remove('is-copied', 'is-error'); try { await writeClipboardText(text); button.textContent = '已复制'; button.classList.add('is-copied'); } catch (error) { button.textContent = '复制失败'; button.classList.add('is-error'); } finally { window.setTimeout(() => { if (!button.isConnected) return; button.disabled = false; button.textContent = '一键复制'; button.classList.remove('is-copied', 'is-error'); }, 1600); } } function renderTaskLifecycle(task) { const entries = taskLifecycleEntries(task); if (!entries.length) return el('pre', 'task-lifecycle-console', '暂无任务生命周期记录。'); const lines = []; for (const entry of entries) { const level = lifecycleLevel(entry); const levelText = { error: 'ERROR', ok: 'OK', warn: 'WARN', info: 'INFO' }[level] || 'INFO'; const time = new Date(entry.created_at || Date.now()); const timeText = Number.isNaN(time.getTime()) ? '--:--:--' : time.toLocaleTimeString('zh-CN', { hour12: false }); const status = entry.status ? `[${entry.status}]` : '[event]'; lines.push(`${timeText} ${levelText.padEnd(5, ' ')} [${lifecycleGroupLabel(entry)}] ${status} ${entry.message || '状态已更新。'}`); const details = taskLifecycleDetails(entry, task); const hasTechnicalDetails = Object.keys(details.payload || {}).length > 0 || details.parse_response !== undefined || details.executor_response !== undefined; if (hasTechnicalDetails) { const technical = JSON.stringify(redactJsonForDisplay(details), null, 2) .split('\n') .map((line) => ` ${line}`) .join('\n'); lines.push(` 技术详情:\n${technical}`); } } return el('pre', 'task-lifecycle-console', lines.join('\n')); } function taskLogStageLabel(stage) { const normalized = String(stage || '').toLowerCase(); if (normalized === 'parse') return 'AI处理阶段'; if (['receipt', 'verification', 'reconciliation'].includes(normalized)) return '获取回执'; return '系统写入处理'; } function taskSourceInfo(task) { const source = task?.source === 'agentbus' ? 'agentbus' : 'manual'; return source === 'agentbus' ? { className: 'agentbus', label: 'AgentBus' } : { className: 'manual', label: '人工输入' }; } function configurePageMode() { document.body.classList.toggle('history-page', IS_HISTORY_PAGE); document.body.classList.toggle('channels-page-active', IS_CHANNELS_PAGE); const instructionPanel = document.querySelector('.instruction-panel'); const historyFilters = $('#historyFilters'); const channelsPage = $('#channelsPage'); const title = $('#taskListTitle'); const hint = $('#taskListHint'); const historyLink = $('#historyLink'); const workbenchNav = $('#workbenchNav'); const historyNav = $('#historyNav'); const channelsNav = $('#channelsNav'); if (instructionPanel) instructionPanel.hidden = IS_HISTORY_PAGE; if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; if (historyFilters) historyFilters.hidden = !IS_HISTORY_PAGE; if (title) title.textContent = IS_HISTORY_PAGE ? '历史任务目录' : '任务卡片'; if (hint) hint.textContent = IS_HISTORY_PAGE ? '全部未删除任务' : '最近任务'; if (historyLink) { historyLink.href = IS_HISTORY_PAGE ? '/' : '/history'; historyLink.textContent = IS_HISTORY_PAGE ? '返回 AI 操作台' : '查看更多'; historyLink.hidden = !IS_HISTORY_PAGE && taskListMeta.total <= HOME_TASK_LIMIT; } if (workbenchNav) { const workbenchActive = !IS_HISTORY_PAGE && !IS_CHANNELS_PAGE; workbenchNav.classList.toggle('is-active', workbenchActive); if (!workbenchActive) workbenchNav.removeAttribute('aria-current'); else workbenchNav.setAttribute('aria-current', 'page'); } if (historyNav) { historyNav.classList.toggle('is-active', IS_HISTORY_PAGE); if (IS_HISTORY_PAGE) historyNav.setAttribute('aria-current', 'page'); else historyNav.removeAttribute('aria-current'); } if (channelsNav) { channelsNav.classList.toggle('is-active', IS_CHANNELS_PAGE); if (IS_CHANNELS_PAGE) channelsNav.setAttribute('aria-current', 'page'); else channelsNav.removeAttribute('aria-current'); } if (IS_CHANNELS_PAGE) document.title = 'AgentBus 渠道 · LianSyn-platform'; else if (IS_HISTORY_PAGE) document.title = '历史任务 · LianSyn-platform'; const searchInput = $('#historySearchInput'); const statusInput = $('#historyStatusInput'); if (searchInput && searchInput.value !== historySearch) searchInput.value = historySearch; if (statusInput && statusInput.value !== historyStatus) statusInput.value = historyStatus; } function renderTaskListHeader() { const total = Math.max(taskListMeta.total, taskStore.length); const count = $('#taskCount'); if (count) { if (IS_HISTORY_PAGE) { count.textContent = `${total} 个任务`; } else if (total > HOME_TASK_LIMIT) { count.textContent = `最近 ${Math.min(taskStore.length, HOME_TASK_LIMIT)} 条 / 共 ${total} 个任务`; } else { count.textContent = `${Math.min(taskStore.length, HOME_TASK_LIMIT)} 个任务`; } } const historyLink = $('#historyLink'); if (historyLink) historyLink.hidden = !IS_HISTORY_PAGE && total <= HOME_TASK_LIMIT; } function renderHistoryPagination() { const pagination = $('#historyPagination'); if (!pagination) return; pagination.hidden = !IS_HISTORY_PAGE; if (!IS_HISTORY_PAGE) return; const total = taskListMeta.total; const start = total ? taskListMeta.offset + 1 : 0; const end = Math.min(taskListMeta.offset + taskStore.length, total); const summary = $('#historyPageSummary'); if (summary) summary.textContent = total ? `显示 ${start}-${end} 条,共 ${total} 条` : '暂无符合条件的任务'; const previous = $('#historyPreviousButton'); const next = $('#historyNextButton'); if (previous) previous.disabled = taskListMeta.offset <= 0; if (next) next.disabled = !taskListMeta.has_more; } function renderTaskCards() { const container = $('#taskCards'); if (!container) return; renderTaskListHeader(); renderHistoryPagination(); container.innerHTML = ''; const visibleTasks = IS_HISTORY_PAGE ? taskStore : taskStore.slice(0, HOME_TASK_LIMIT); if (!visibleTasks.length) { const empty = document.createElement('div'); empty.className = 'task-empty'; empty.textContent = IS_HISTORY_PAGE && (historySearch || historyStatus) ? '暂无符合筛选条件的任务。' : '暂无任务。'; container.appendChild(empty); renderTaskDetail(); return; } for (const task of visibleTasks) { const card = document.createElement('article'); card.className = `task-card${task.task_id === currentTaskId ? ' is-selected' : ''}`; card.dataset.taskId = task.task_id; const summary = task.summary || taskSummary(task.operation); const status = task.status || task.result?.status || 'created'; const source = taskSourceInfo(task); card.innerHTML = `
`; card.querySelector('.task-card-title').textContent = status === 'awaiting_user_input' ? '等待补充资料' : summary.operation_label || '输入解析任务'; card.querySelector('.task-source-tag').textContent = source.label; card.querySelector('.task-id').textContent = task.task_id; card.querySelector('.task-card-status .task-pill').textContent = taskStatusLabel(task); container.appendChild(card); } renderTaskDetail(); } function toNumber(value) { const normalized = String(value ?? '').replace(/[,,]/g, '').trim(); const num = Number(normalized); return Number.isFinite(num) ? num : 0; } function setBridgeState(text, type) { const el = $('#bridgeState'); if (!el) return; const value = el.querySelector('.status-icon-value'); if (value) value.textContent = text; else el.textContent = text; el.className = `status-icon ${type || 'state-warn'}`; } function setAiState(text, type) { const el = $('#aiState'); if (!el) return; const value = el.querySelector('.status-icon-value'); if (value) value.textContent = text; else el.textContent = text; el.className = `status-icon ${type || 'state-warn'}`; } function formatStatusTime(value) { if (!value) return '未记录'; const date = new Date(value); if (!Number.isFinite(date.getTime())) return String(value); return date.toLocaleString('zh-CN', { hour12: false }); } function statusBoolean(value, trueText = '正常', falseText = '异常', unknownText = '待检测') { if (value === true) return trueText; if (value === false) return falseText; return unknownText; } function statusDetailRow(label, value) { const row = document.createElement('div'); row.className = 'status-detail-row'; const name = document.createElement('span'); const detail = document.createElement('strong'); name.textContent = label; detail.textContent = value === undefined || value === null || value === '' ? '未提供' : String(value); row.append(name, detail); return row; } function setStatusExpanded(kind, expanded) { const button = kind === 'ai' ? $('#aiState') : $('#bridgeState'); if (button) button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } function closeStatusDetails() { const popover = $('#statusDetailsPopover'); if (popover) popover.hidden = true; setStatusExpanded('ai', false); setStatusExpanded('plugin', false); activeStatusDetails = ''; } function renderStatusDetails() { const popover = $('#statusDetailsPopover'); const title = $('#statusDetailsTitle'); const body = $('#statusDetailsBody'); const refresh = $('#statusDetailsRefresh'); if (!popover || !title || !body || !refresh || !activeStatusDetails) return; body.replaceChildren(); const summary = document.createElement('div'); summary.className = 'status-detail-summary state-warn'; if (activeStatusDetails === 'ai') { const status = latestAiStatus; const probe = status?.ai_probe || {}; title.textContent = 'AI 状态详情'; refresh.textContent = '重新检测 AI'; if (!status) { summary.textContent = '正在检测服务链路…'; } else if (status.ai_connected === true) { summary.className = 'status-detail-summary state-ok'; summary.textContent = '服务链路已连接'; } else { summary.className = 'status-detail-summary state-bad'; summary.textContent = '服务链路未连接'; } body.append( summary, statusDetailRow('控制平面 / 数据库', statusBoolean(status?.database_ready, '正常', '不可用')), statusDetailRow('AI 配置', statusBoolean(status?.ai_configured, '已配置', '未配置')), statusDetailRow('外部服务', statusBoolean(status?.ai_reachable, '可达', '不可达')), statusDetailRow( '鉴权证据', status?.ai_authenticated === true ? '已由成功任务验证' : status?.ai_authenticated === false ? '历史验证失败(不影响链路状态)' : '待真实任务验证' ), statusDetailRow('上游 HTTP', probe.http_status ? `HTTP ${probe.http_status}` : '未返回'), statusDetailRow('最后成功解析', formatStatusTime(probe.last_success_at)), statusDetailRow('最后鉴权失败', formatStatusTime(probe.last_auth_failure_at)), statusDetailRow('最近错误', status?.diagnostic_error || probe.error_code || probe.last_auth_failure_code || '无'), statusDetailRow('检查时间', formatStatusTime(status?.checked_at)) ); } else { const status = latestBridgeStatus; const recoveryStatus = String(status?.erpLinkRecovery?.status || status?.erpSession?.erp_keepalive?.recovery_status || ''); const recoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus); const hasWarning = status?.connected && (!status?.compatible || !status?.automationEnabled || !status?.erpHostAccessReady || status?.erpSessionReady === false || recoveryPending); title.textContent = '插件状态详情'; refresh.textContent = '重新检测插件'; if (!status) { summary.textContent = '正在检测页面桥接…'; } else if (!status.connected) { summary.className = 'status-detail-summary state-bad'; summary.textContent = '插件桥接未连接'; } else if (hasWarning) { summary.className = 'status-detail-summary state-warn'; summary.textContent = '插件已连接,但存在告警'; } else { summary.className = 'status-detail-summary state-ok'; summary.textContent = '插件桥接已连接'; } body.append( summary, statusDetailRow('页面桥接', statusBoolean(status?.connected, '已响应', '未响应')), statusDetailRow('插件版本', status?.version || '未知'), statusDetailRow('最低版本', REQUIRED_EXTENSION_VERSION), statusDetailRow('版本兼容', statusBoolean(status?.compatible, '兼容', '需要更新')), statusDetailRow('ERP 自动化', statusBoolean(status?.automationEnabled, '已开启', '已关闭')), statusDetailRow('ERP 页面权限', statusBoolean(status?.erpHostAccessReady, '可访问', '需在扩展设置中允许')), statusDetailRow('ERP 会话', statusBoolean(status?.erpSessionReady, '正常', '异常或待登录')), statusDetailRow('ERP 链路恢复', recoveryStatus || '未触发'), statusDetailRow('安装时间', formatStatusTime(status?.installedAt)), statusDetailRow('最近错误', status?.error || '无'), statusDetailRow('检查时间', formatStatusTime(status?.checkedAt)) ); } } async function toggleStatusDetails(kind) { const popover = $('#statusDetailsPopover'); if (!popover) return; if (!popover.hidden && activeStatusDetails === kind) { closeStatusDetails(); return; } setStatusExpanded('ai', kind === 'ai'); setStatusExpanded('plugin', kind === 'plugin'); activeStatusDetails = kind; popover.hidden = false; renderStatusDetails(); if (kind === 'ai') await pingAi(); else await pingBridge(); } async function pingAi() { try { const response = await fetchWithTimeout('/api/status', { cache: 'no-store' }, DEFAULT_API_TIMEOUT_MS); const result = await response.json(); latestAiStatus = { ...result, checked_at: result.checked_at || new Date().toISOString(), diagnostic_error: response.ok ? '' : result.message || result.error || `HTTP ${response.status}` }; if (!response.ok || result.ai_connected !== true) { setAiState('未连接', 'state-bad'); renderStatusDetails(); return false; } setAiState('已连接', 'state-ok'); renderStatusDetails(); return true; } catch (error) { latestAiStatus = { ai_connected: false, diagnostic_error: error.message || String(error), checked_at: new Date().toISOString() }; setAiState('未连接', 'state-bad'); renderStatusDetails(); return false; } } function applyBridgePayload(payload = {}) { const wasBridgeConnected = bridgeConnected; const previousBridgeInstalledAt = String(latestBridgeStatus?.installedAt || ''); bridgeConnected = Boolean(payload.ok); if (!bridgeConnected) { erpAutomationEnabled = false; erpHostAccessReady = false; erpSessionReady = false; extensionCompatible = false; latestBridgeStatus = { connected: false, compatible: false, automationEnabled: false, version: payload.version || '', installedAt: payload.bridge_installed_at || '', error: payload.message || payload.error || '插件桥接未返回成功状态。', checkedAt: new Date().toISOString() }; setBridgeState('未连接', 'state-bad'); renderStatusDetails(); return false; } const bridgeInstalledAt = String(payload.bridge_installed_at || ''); const erpSession = payload.erp_session || {}; const erpAccessError = new Set([ 'erp_host_permission_missing', 'erp_host_not_allowlisted', 'erp_page_access_denied' ]).has(String(erpSession.error_code || '')); erpHostAccessReady = erpSession.host_permission_granted !== false && !erpAccessError; erpSessionReady = erpSession.session_ready !== false && erpSession.erp_keepalive?.last_status !== 'session_expired'; const recoveryStatus = String(erpSession.erp_link_recovery?.status || erpSession.erp_keepalive?.recovery_status || ''); const erpLinkRecoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus); const parseVersion = (value) => String(value || '').split('.').map((part) => Number(part) || 0); const current = parseVersion(payload.version); const required = parseVersion(REQUIRED_EXTENSION_VERSION); let versionComparison = 0; for (let index = 0; index < 3; index += 1) { const currentPart = current[index] || 0; const requiredPart = required[index] || 0; if (currentPart === requiredPart) continue; versionComparison = currentPart > requiredPart ? 1 : -1; break; } extensionCompatible = versionComparison >= 0; erpAutomationEnabled = payload.erp_automation_enabled !== false; latestBridgeStatus = { connected: true, compatible: extensionCompatible, automationEnabled: erpAutomationEnabled, erpHostAccessReady, erpSessionReady, erpSession, erpLinkRecovery: { status: recoveryStatus || 'not_started', pending: erpLinkRecoveryPending }, version: payload.version || '', installedAt: bridgeInstalledAt, error: !extensionCompatible ? `插件版本低于最低要求 ${REQUIRED_EXTENSION_VERSION}。` : !erpAutomationEnabled ? 'ERP 自动化开关已关闭。' : !erpHostAccessReady ? erpSession.message || '扩展暂时没有 ERP 页面访问权限。' : !erpSessionReady ? erpSession.message || 'ERP 会话异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录。' : erpLinkRecoveryPending ? '已检测到 ERP 链路异常,插件正在刷新并进行只读复测。' : '', checkedAt: new Date().toISOString() }; if (!extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || erpLinkRecoveryPending) { setBridgeState('已连接,有告警', 'state-warn'); } else { setBridgeState('已连接', 'state-ok'); } renderStatusDetails(); const bridgeReadyForDispatch = !wasBridgeConnected || Boolean(bridgeInstalledAt && bridgeInstalledAt !== previousBridgeInstalledAt); queueMicrotask(() => autoDispatchReadyTasks({ force: bridgeReadyForDispatch }).catch(() => {})); return true; } function setTaskState(text) { const node = $('#taskState'); if (!node) return; node.textContent = text; const normalized = String(text || ''); const type = /失败|阻断|错误|不可用/.test(normalized) ? 'state-bad' : /完成|收到|确认|成功|已连接/.test(normalized) ? 'state-ok' : 'state-warn'; node.className = `state ${type}`; } function setOutput(output) { renderTaskDetail(); } function makeRequestId() { if (crypto.randomUUID) return crypto.randomUUID(); return `REQ-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function sendToExtension(type, payload = {}, timeoutMs = 2500) { const requestId = makeRequestId(); const promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('插件桥接未响应,请确认扩展已安装并刷新本页。')); }, timeoutMs); pending.set(requestId, { resolve, reject, timer }); }); window.postMessage({ source: BUSINESS_SOURCE, requestId, type, payload }, window.location.origin); return promise; } window.addEventListener('message', (event) => { if (event.source !== window) return; const message = event.data || {}; if (message.source !== EXTENSION_SOURCE) return; if (message.type === 'BRIDGE_READY') { applyBridgePayload(message.payload || { ok: true }); return; } const waiter = pending.get(message.requestId); if (!waiter) return; if (message.type === 'ERROR' && /Extension context invalidated/i.test(String(message.payload?.message || ''))) { // A stale content-script handler can answer after an extension reload; // keep the request open for the current bridge response. return; } clearTimeout(waiter.timer); pending.delete(message.requestId); if (message.type === 'ERROR' || message.payload?.ok === false) { waiter.reject(new Error(message.payload?.message || '插件返回错误。')); return; } waiter.resolve(message.payload); }); async function parseRawInstruction(rawText, taskId) { const receivedAt = new Date().toISOString(); const requestStartedAt = new Date().toISOString(); const pendingRequest = { service: 'external_parse_api', transport: 'sse', stage: 'sending', session_id_present: false, event_count: 0, updated_at: requestStartedAt }; updateLocalTask(taskId, { status: 'running', stage: 'parse', message: '正在发送到外部解析服务……', error: '' }); setTaskState('正在发送到外部解析服务'); setOutput({ status: 'parse_sending', task_id: taskId, message: '业务系统已发起解析请求,正在创建独立 session。', external_request: pendingRequest }); let response; try { response = await fetch('/api/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ raw_text: rawText, task_id: taskId, received_at: receivedAt }) }); } catch (error) { const result = { status: 'parse_blocked', blockers: [`无法连接本地解析服务:${error.message}`], operation: null, external_request: { ...pendingRequest, stage: 'local_request_failed', error_code: 'local_parse_api_unreachable', updated_at: new Date().toISOString() } }; updateLocalTask(taskId, { status: 'parse_failed', stage: 'parse', message: '无法连接本地解析服务。', error: result.blockers.join(';'), result, parse_response: result }); setTaskState('解析服务连接失败'); setOutput(result); return result; } let result; try { result = await response.json(); } catch (error) { result = { status: 'parse_blocked', blockers: [`解析服务返回了无效响应:${error.message}`], operation: null, external_request: { ...pendingRequest, stage: 'invalid_response', error_code: 'local_parse_api_invalid_response', updated_at: new Date().toISOString() } }; } if (!response.ok) { result = { status: 'parse_blocked', blockers: [result.message || result.error || `解析服务调用失败:HTTP ${response.status}`], operation: null, external_request: result.external_request || { ...pendingRequest, stage: 'local_api_error', http_status: response.status, updated_at: new Date().toISOString() } }; } const hasBlockers = Array.isArray(result.blockers) && result.blockers.length > 0; const needsInput = result.status === 'agent_parse_needs_input'; updateLocalTask(taskId, { status: needsInput ? 'awaiting_user_input' : hasBlockers ? 'parse_failed' : 'running', stage: 'parse', message: needsInput ? '外部解析服务需要用户补充信息。' : hasBlockers ? '外部解析服务已返回阻断结果。' : '外部解析服务已返回结果,正在整理业务字段。', error: hasBlockers ? result.blockers.join(';') : '', parse_response: result }); setTaskState(needsInput ? '等待用户补充' : hasBlockers ? '外部解析服务已返回阻断' : '已收到外部解析结果'); setOutput({ status: needsInput ? 'parse_response_needs_input' : hasBlockers ? 'parse_response_blocked' : 'parse_response_received', task_id: taskId, external_request: result.external_request || { ...pendingRequest, stage: 'response_received', updated_at: new Date().toISOString() }, result }); return result; } async function handoffTaskToExtension(task) { const automaticTask = isAutomaticTask(task); if (!task?.confirmed_at) { throw new Error(automaticTask ? '自动任务缺少确认时间,已停止下发。' : '任务尚未人工确认,不能提交到 ERP 插件。'); } if (task.status !== 'confirmed' || task.handoff_status !== 'awaiting_handoff') { throw new Error('任务已领取或已进入插件执行流程,系统不会再次提交。'); } if (!bridgeConnected || !extensionCompatible) { if (automaticTask) { updateLocalTask(task.task_id, { message: `全自动化已接管,等待插件连接或版本升级(最低 ${REQUIRED_EXTENSION_VERSION})。`, error: '插件不可用。' }); } else { updateLocalTask(task.task_id, { status: 'waiting_extension', stage: 'bridge', message: `任务已确认,但插件未连接或版本低于 ${REQUIRED_EXTENSION_VERSION};本次未提交,请连接插件后再次点击。`, error: '插件不可用。', handoff_status: 'waiting_extension' }); } throw new Error('插件未连接或版本过低,任务已保留且未提交到插件。'); } const erpLinkRecoveryPending = latestBridgeStatus?.erpLinkRecovery?.pending === true; if (!erpHostAccessReady || !erpSessionReady || erpLinkRecoveryPending) { const message = !erpHostAccessReady ? '扩展当前没有 ERP 页面访问权限,任务已保留且未提交;请在 chrome://extensions 中允许 ERP 网站访问并重载扩展。' : erpLinkRecoveryPending ? '插件正在刷新 ERP 链路并进行只读复测,任务已保留且未提交;请稍后重新检测。' : 'ERP 会话当前异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录后再提交。'; updateLocalTask(task.task_id, automaticTask ? { message, error: message } : { status: 'waiting_extension', stage: 'bridge', message, error: message, handoff_status: 'waiting_extension' }); throw new Error(message); } if (!erpAutomationEnabled) { if (automaticTask) { updateLocalTask(task.task_id, { message: '全自动化已接管,但插件 ERP 操作开关已关闭,等待恢复后继续。', error: '插件 ERP 操作开关已关闭。' }); throw new Error('插件 ERP 操作开关已关闭,自动任务暂未下发。'); } updateLocalTask(task.task_id, { status: 'paused', stage: 'automation_disabled', message: '任务已确认,但插件 ERP 操作开关已关闭;本次未提交到插件。', handoff_status: 'paused', error: '' }); return { ok: true, task_id: task.task_id, status: 'paused', erp_automation_enabled: false, message: '插件 ERP 操作开关已关闭,未交给 ERP 自动执行器;开启后请再次点击提交。' }; } updateLocalTask(task.task_id, { status: 'queued', stage: 'handoff', message: '正在向服务端申请唯一 ERP 执行权。', handoff_status: 'syncing' }); let claim; try { claim = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/claim`, { method: 'POST', body: { connection_id: BROWSER_CONNECTION_ID } }); if (claim.task) upsertLocalTask(claim.task); if (!claim.claimed) { if (claim.queue_status === 'waiting') { const queuePosition = Number(claim.queue_position || 0); updateLocalTask(task.task_id, { status: 'confirmed', stage: 'handoff', handoff_status: 'awaiting_handoff', message: queuePosition > 0 ? `插件当前正在处理其他任务,本任务排队第 ${queuePosition} 位。` : '插件当前正在处理其他任务,任务已持久化排队。' }); return { ok: true, task_id: task.task_id, status: 'queued', queue_status: 'waiting', queue_position: claim.queue_position, dispatched: false, message: '插件同一时间只处理一个任务,本任务已进入服务端队列。' }; } startPolling(); return { ok: true, task_id: task.task_id, status: 'already_claimed', dispatched: false, message: '服务端已存在执行记录,本次没有再次发送给插件。' }; } const executionId = claim.execution_id; if (!executionId) throw new Error('服务端没有返回唯一执行编号,已停止下发。'); const result = await sendToExtension('CREATE_TASK', { task: claim.task || task, execution_id: executionId }, 4000); if (result?.accepted === false || result?.status === 'duplicate_blocked') { throw new Error(result.message || '插件检测到重复任务,已阻止再次执行。'); } currentTaskId = result.task_id || task.task_id; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); updateLocalTask(task.task_id, { status: 'running', stage: 'auto_executor', message: '插件已接单,后台自动完成 ERP 预检、填单、保存并回查。', handoff_status: 'running', handoff_response: { ...result, execution_id: executionId } }); startPolling(); return { ...result, execution_id: executionId }; } catch (error) { if (claim?.claimed && claim.execution_id) { const uncertainResult = { task_id: task.task_id, execution_id: claim.execution_id, status: 'execution_uncertain', stage: 'dispatch', execution_phase: 'dispatch_uncertain', write_attempted: true, message: `任务领取后插件响应不确定:${error.message}。已停止自动重试,请回查 ERP。` }; try { const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/result`, { method: 'POST', body: { connection_id: BROWSER_CONNECTION_ID, execution_id: claim.execution_id, result: uncertainResult } }); if (persisted.task) upsertLocalTask(persisted.task, { forceVisible: false }); } catch (persistError) { updateLocalTask(task.task_id, { status: 'reconciliation_pending', stage: 'reconciliation', handoff_status: 'reconciliation_pending', message: uncertainResult.message, error: persistError.message }); } } throw error; } } async function confirmAndSubmitToErpPlugin(task) { task = await ensureTaskDetails(task?.task_id); if (!requiresManualConfirmation(task)) return; const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || latestBridgeStatus?.erpLinkRecovery?.pending === true) { throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION}、ERP 页面/会话不可用或 ERP 操作开关未开启;任务尚未确认,也未提交到插件。`); } const response = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/confirm`, { method: 'POST', body: {} }); const confirmedTask = response.task; upsertLocalTask(confirmedTask); try { const handoff = await handoffTaskToExtension(confirmedTask); if (handoff?.status === 'paused') { setTaskState('ERP 操作已关闭'); setOutput({ status: 'task_confirmed_paused', task_id: task.task_id, message: handoff.message }); } else { setOutput({ status: 'task_confirmed_and_handed_off', task_id: task.task_id, result: handoff }); } } catch (error) { setTaskState('已确认,等待插件连接'); setOutput({ status: 'task_confirmed_waiting_extension', task_id: task.task_id, message: error.message }); } } async function pingBridge() { try { const result = await sendToExtension('PING', {}, 1200); applyBridgePayload(result); if (authUser && result?.ok) { await apiRequest('/api/connections/heartbeat', { method: 'POST', body: { connection_id: `administrator-browser:${location.origin}`, extension_version: result.version || '', metadata: { bridge_installed_at: result.bridge_installed_at || '' } } }); } return bridgeConnected; } catch (error) { bridgeConnected = false; erpAutomationEnabled = false; extensionCompatible = false; latestBridgeStatus = { connected: false, compatible: false, automationEnabled: false, version: '', installedAt: '', error: error.message || String(error), checkedAt: new Date().toISOString() }; setBridgeState('未连接', 'state-bad'); renderStatusDetails(); return false; } } async function createTask() { if (!authUser) { showLoginPanel('请先登录。'); return; } const rawText = $('#rawInstruction').value; const task = await apiRequest('/api/tasks', { method: 'POST', body: { raw_text: rawText, idempotency_key: `create:${makeRequestId()}` } }); const remoteTask = task.task; currentTaskId = remoteTask.task_id; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); upsertLocalTask(remoteTask); setTaskState('任务已入队'); setOutput({ status: 'task_created', task_id: currentTaskId, message: '任务已写入平台数据库,等待服务端解析。' }); startRemoteEventStream(); } async function refreshBackgroundState() { if (!authUser || backgroundRefreshInProgress) return; backgroundRefreshInProgress = true; try { await Promise.allSettled([ pingAi(), pingBridge(), syncRemoteTasks() ]); } finally { backgroundRefreshInProgress = false; } } async function pollResult() { if (!currentTaskId) { setOutput('还没有任务。'); return; } const result = await pollTaskResult(currentTaskId); setTaskState(result ? `${result.stage} / ${result.status}` : `等待插件反馈:${currentTaskId}`); setOutput(result || { status: 'waiting', task_id: currentTaskId }); } async function deleteTask(taskId) { const task = findTaskById(taskId); if (!task) return; if (taskDeleteStates.get(taskId) === 'deleting') return; taskDeleteStates.set(taskId, 'deleting'); renderTaskDetail(); try { const needsPluginCancellation = Boolean( task.confirmed_at || ['accepted', 'syncing', 'waiting_extension', 'paused'].includes(task.handoff_status) ); if (needsPluginCancellation) { // Platform deletion is authoritative. Do not make the operator wait for // an extension response; stopping the extension is best effort only and // a write already in flight cannot be rolled back here. void sendToExtension('DELETE_TASK', { task_id: taskId }, 4000).catch(() => {}); } await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { method: 'DELETE' }); taskDeleteStates.delete(taskId); taskStore = taskStore.filter((item) => item.task_id !== taskId); runtimeTaskStore.delete(taskId); remoteTaskStore.delete(taskId); taskDetailStore.delete(taskId); taskDetailRequests.delete(taskId); localTaskOverlayStore.delete(taskId); if (currentTaskId === taskId || !taskStore.some((item) => item.task_id === currentTaskId)) { currentTaskId = taskStore[0]?.task_id || ''; } if (currentTaskId) sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); else sessionStorage.removeItem('liansyn_platform_current_task_id'); saveTaskStore(); if (!hasPollableRuntimeTasks()) stopPolling(); renderTaskCards(); void syncRemoteTasks().catch(() => {}); } catch (error) { taskDeleteStates.set(taskId, 'failed'); if (currentTaskId === taskId) setTaskState('删除失败'); setOutput({ status: 'delete_error', task_id: taskId, message: `未删除任务:${error.message || String(error)}` }); renderTaskCards(); if (currentTaskId === taskId) setTaskState('删除失败'); throw error; } } async function deleteSelectedTask() { const task = selectedTask(); if (!task) return; return deleteTask(task.task_id); } async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { const knownTask = findTaskById(taskId); if (!allowReconciliation && knownTask) { const localStatuses = [knownTask.status, knownTask.result?.status, knownTask.handoff_status] .map((value) => String(value || '').toLowerCase()); if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status))) return null; } const result = await sendToExtension('GET_TASK_RESULT', { task_id: taskId }, 3000); if (result.result) { try { const executionId = result.result.execution_id; if (!executionId) { throw new Error(`插件版本过低或执行记录缺少 execution_id,请更新到 ${REQUIRED_EXTENSION_VERSION};系统不会重复下发。`); } const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/result`, { method: 'POST', body: { connection_id: BROWSER_CONNECTION_ID, execution_id: executionId, result: result.result } }); if (persisted.task) upsertLocalTask(persisted.task, { forceVisible: false }); } catch (error) { updateLocalTask(taskId, { result: result.result, status: result.result.status || 'running', stage: result.result.stage || '', message: `插件已返回结果,但服务端持久化失败:${error.message}`, error: error.message }); } } return result.result || null; } async function reconcileTaskReceipt(task) { if (!canReconcileTask(task)) throw new Error('当前任务不在可只读回查的生命周期不确定状态。'); const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpHostAccessReady) throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 页面权限不可用,未执行回查。`); taskReconciliationStates.set(task.task_id, 'running'); renderTaskDetail(); try { const result = await sendToExtension('RECONCILE_TASK', { task_id: task.task_id }, 60_000); await pollTaskResult(task.task_id, { allowReconciliation: true }); await syncRemoteTasks(); setTaskState(result?.reconciliation_resolved ? '回查已收敛' : '仍待回查'); return result; } finally { taskReconciliationStates.delete(task.task_id); renderTaskDetail(); } } async function resumePrewriteTaskExecution(task) { if (!canResumePrewriteTask(task)) throw new Error('当前任务没有可证明的保存前零写状态,禁止恢复。'); const response = await sendToExtension('RESUME_PREWRITE_TASK', { task_id: task.task_id }, 3000); updateLocalTask(task.task_id, { status: 'running', stage: 'resume_prewrite', message: response?.message || '已请求恢复保存前任务,等待插件执行结果。', error: '' }); startPolling(); setTaskState('恢复执行中'); return response; } async function pollAllTaskResults() { if (!bridgeConnected || pollInProgress) return; pollInProgress = true; try { const activeTasks = runtimeTasks().filter(isTaskPollable); for (const task of activeTasks) { try { await pollTaskResult(task.task_id); } catch (error) { updateLocalTask(task.task_id, { message: `查询插件反馈失败:${error.message}`, error: error.message }); } } } finally { pollInProgress = false; } if (!hasPollableRuntimeTasks()) stopPolling(); } function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; } function startPolling() { stopPolling(); pollAllTaskResults().catch((error) => { setTaskState('轮询失败'); setOutput({ status: 'poll_error', message: error.message }); }); pollTimer = setInterval(() => { pollAllTaskResults().catch((error) => { setTaskState('轮询失败'); setOutput({ status: 'poll_error', message: error.message }); }); }, 2500); } async function clearTasks() { try { await sendToExtension('CLEAR_TASKS', {}, 3000); } catch (error) { // Local business-system cards can still be cleared when the extension is disconnected. } currentTaskId = ''; taskStore = []; runtimeTaskStore.clear(); remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); localTaskOverlayStore.clear(); saveTaskStore(); sessionStorage.removeItem('liansyn_platform_current_task_id'); stopPolling(); setTaskState('已清空'); setOutput('暂无任务。'); renderTaskCards(); } async function initializeSession() { let me; try { me = await apiRequest('/api/auth/me'); await refreshCsrfToken(); } catch (error) { showLoginPanel(error?.code === 'request_timeout' ? '登录状态恢复超时,请刷新页面重试。' : '登录状态已失效,请重新登录。'); return false; } showAuthenticatedApp(me.user); try { await syncAutomationSettings(); } catch (error) { setTaskState('全自动化设置读取失败'); } try { await syncChannels(); } catch (error) { const message = $('#channelMessage'); if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; } try { await syncRemoteTasks(); } catch (error) { // A task-list/database failure must not erase a valid administrator // session. Keep the workbench available and surface the sync error. setTaskState('任务同步失败'); setOutput({ status: 'sync_error', message: error.message || String(error) }); } startRemoteEventStream(); return true; } document.addEventListener('DOMContentLoaded', async () => { configurePageMode(); // Remove credentials/configuration left by the retired browser-side parser. localStorage.removeItem('liansyn_platform_agent_config'); sessionStorage.removeItem('liansyn_platform_agent_api_key'); $('#rawInstruction').value = sessionStorage.getItem('liansyn_platform_raw_instruction') || ''; $('#rawInstruction').addEventListener('input', () => { sessionStorage.setItem('liansyn_platform_raw_instruction', $('#rawInstruction').value); }); showAuthChecking(); // AI health is a public, read-only control-plane probe. Refresh it even // before login so the header does not confuse "not logged in" with // "external Agent disconnected". pingAi().catch(() => {}); $('#loginForm').addEventListener('submit', async (event) => { event.preventDefault(); const errorNode = $('#loginError'); if (errorNode) errorNode.textContent = ''; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; try { const response = await fetchWithTimeout('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', credentials: 'same-origin', body: JSON.stringify({ username: $('#loginUsername').value, password: $('#loginPassword').value }) }, AUTH_API_TIMEOUT_MS); const result = await response.json(); if (!response.ok) throw new Error(result.message || '登录失败。'); csrfToken = result.csrf_token || ''; showAuthenticatedApp(result.user); $('#loginPassword').value = ''; try { await syncAutomationSettings(); } catch (error) { setTaskState('全自动化设置读取失败'); } try { await syncChannels(); } catch (error) { const message = $('#channelMessage'); if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; } await syncRemoteTasks(); startRemoteEventStream(); await pingAi(); await pingBridge(); } catch (error) { if (errorNode) errorNode.textContent = error.message || String(error); } finally { if (submitButton) submitButton.disabled = false; } }); $('#logoutButton').addEventListener('click', async () => { try { await apiRequest('/api/auth/logout', { method: 'POST', body: {} }); } catch (error) { // The server may already have expired the session; clear the local view regardless. } taskStore = []; runtimeTaskStore.clear(); remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); localTaskOverlayStore.clear(); currentTaskId = ''; sessionStorage.removeItem('liansyn_platform_current_task_id'); stopPolling(); showLoginPanel('已退出登录。'); renderTaskCards(); }); $('#createTaskButton').addEventListener('click', () => { createTask().catch((error) => { const result = { status: 'create_error', task_id: currentTaskId, message: error.message }; updateLocalTask(currentTaskId, { status: 'parse_failed', stage: 'parse', message: '创建任务时发生未处理错误。', error: error.message, result }); setTaskState('创建失败'); setOutput(result); }); }); $('#historyFilters').addEventListener('submit', (event) => { event.preventDefault(); historySearch = normalizeText($('#historySearchInput').value).slice(0, 200); historyStatus = normalizeText($('#historyStatusInput').value).slice(0, 80); historyOffset = 0; currentTaskId = ''; syncRemoteTasks().catch((error) => { setTaskState('历史任务筛选失败'); setOutput({ status: 'history_sync_error', message: error.message || String(error) }); }); }); $('#historyResetButton').addEventListener('click', () => { historySearch = ''; historyStatus = ''; historyOffset = 0; currentTaskId = ''; configurePageMode(); syncRemoteTasks().catch((error) => { setTaskState('历史任务刷新失败'); setOutput({ status: 'history_sync_error', message: error.message || String(error) }); }); }); $('#historyPreviousButton').addEventListener('click', () => { if (!IS_HISTORY_PAGE || historyOffset <= 0) return; historyOffset = Math.max(0, historyOffset - HISTORY_TASK_PAGE_SIZE); currentTaskId = ''; syncRemoteTasks().catch((error) => setOutput({ status: 'history_sync_error', message: error.message || String(error) })); }); $('#historyNextButton').addEventListener('click', () => { if (!IS_HISTORY_PAGE || !taskListMeta.has_more) return; historyOffset += HISTORY_TASK_PAGE_SIZE; currentTaskId = ''; syncRemoteTasks().catch((error) => setOutput({ status: 'history_sync_error', message: error.message || String(error) })); }); $('#automationToggleButton').addEventListener('click', () => { toggleAutomationSetting().catch((error) => { setTaskState('全自动化设置失败'); setOutput({ status: 'automation_settings_error', message: error.message || String(error) }); }); }); $('#channelForm')?.addEventListener('submit', (event) => { event.preventDefault(); createChannelFromForm().catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#channelList')?.addEventListener('click', (event) => { const button = event.target.closest('[data-channel-action][data-channel-id]'); if (!button) return; const channelId = button.dataset.channelId; const channel = channelList.find((item) => item.id === channelId); if (!channelId || !channel) return; const action = button.dataset.channelAction; const operation = action === 'toggle' ? updateChannelEnabled(channelId, !channel.enabled) : action === 'rename' ? renameChannel(channelId) : rotateChannelKey(channelId); operation.catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#aiState').addEventListener('click', () => { toggleStatusDetails('ai').catch(() => {}); }); $('#bridgeState').addEventListener('click', () => { toggleStatusDetails('plugin').catch(() => {}); }); $('#statusDetailsClose').addEventListener('click', closeStatusDetails); $('#statusDetailsRefresh').addEventListener('click', () => { const refresh = activeStatusDetails === 'ai' ? pingAi() : pingBridge(); refresh.catch(() => {}); }); document.addEventListener('click', (event) => { if (!activeStatusDetails) return; if (event.target.closest('#statusDetailsPopover, #aiState, #bridgeState')) return; closeStatusDetails(); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && activeStatusDetails) { closeStatusDetails(); } }); $('#confirmTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; const action = requiresManualConfirmation(task) ? confirmAndSubmitToErpPlugin(task) : canResumePrewriteTask(task) ? resumePrewriteTaskExecution(task) : canReconcileTask(task) ? reconcileTaskReceipt(task) : canStartConfirmedTask(task) ? handoffTaskToExtension(task) : null; if (!action) return; action.catch((error) => setOutput({ status: 'confirmation_error', task_id: task.task_id, message: error.message })); }); $('#deleteTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; if (!window.confirm(`确认彻底删除任务 ${task.task_id}?平台记录将不可恢复;插件任务只会尽力停止。`)) return; deleteSelectedTask().catch((error) => setOutput({ status: 'delete_error', task_id: task.task_id, message: error.message || String(error) })); }); $('#taskCards').addEventListener('click', (event) => { const card = event.target.closest('.task-card[data-task-id]'); const taskId = card?.dataset.taskId; if (!taskId) return; const task = taskStore.find((item) => item.task_id === taskId); if (!task) return; currentTaskId = taskId; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); renderTaskCards(); }); $('#taskDetail').addEventListener('click', (event) => { const button = event.target.closest('[data-copy-lifecycle]'); if (!button) return; void copyTaskLifecycle(button); }); if (await initializeSession()) { renderTaskCards(); pingAi().catch(() => {}); pingBridge().catch(() => {}); // The control plane caches this health probe; keep the browser refresh // interval conservative so status checks cannot pressure the Agent API. setInterval(() => { refreshBackgroundState().catch(() => {}); }, 30_000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { stopPolling(); return; } refreshBackgroundState().catch(() => {}); if (hasPollableRuntimeTasks()) startPolling(); }); if (currentTaskId) renderTaskDetail(); if (hasPollableRuntimeTasks()) startPolling(); } });