import { activityStepFromPayload, briefActivityLabel, isGenericActivityLabel, statusMessageId } from './app-activity-labels.js'; import { payloadRunKeys } from './app-core-utils.js'; export function mergeActivityStep(currentSteps, step) { if (!step) { return currentSteps || []; } const steps = [...(currentSteps || [])]; const existingIndex = steps.findIndex((item) => item.id === step.id); if (existingIndex >= 0) { steps[existingIndex] = { ...steps[existingIndex], ...step }; return steps.slice(-8); } const sameWorkIndex = steps.findIndex( (item) => item.kind === step.kind && item.label === step.label && (item.command || '') === (step.command || '') ); if (sameWorkIndex >= 0) { steps[sameWorkIndex] = { ...steps[sameWorkIndex], ...step }; return steps.slice(-8); } const last = steps[steps.length - 1]; if (last && last.label === step.label && last.detail === step.detail && last.status === step.status) { return steps; } return [...steps, step].slice(-8); } export function isVisibleActivityStep(step, messageStatus) { if (!step) { return false; } const label = String(step.label || '').trim(); if (isGenericActivityLabel(label)) { return false; } if ( ['reasoning', 'message', 'agent_message'].includes(step.kind) && /^(正在思考中?|正在处理|正在回复|正在整理回复)$/.test(label) ) { return false; } if (messageStatus !== 'failed' && step.kind === 'command_execution' && step.status === 'failed') { return false; } if (messageStatus !== 'failed' && /blocked by policy|rejected/i.test(`${step.detail || ''}\n${step.output || ''}\n${step.error || ''}`)) { return false; } return true; } export function upsertStatusMessage(current, payload) { const id = statusMessageId(payload); const existingIndex = current.findIndex((message) => message.id === id); const previous = existingIndex >= 0 ? current[existingIndex] : null; const normalizedPayload = payload.kind === 'agent_message' ? { ...payload, label: briefActivityLabel(payload.label || payload.content) } : payload; const detail = normalizedPayload.kind === 'reasoning' ? previous?.detail || '' : normalizedPayload.detail || previous?.detail || ''; const status = normalizedPayload.status || previous?.status || 'running'; const isTerminalStatus = ['aborted', 'completed', 'failed'].includes(status); const kind = normalizedPayload.kind || (isTerminalStatus ? 'turn' : previous?.kind || 'turn'); const isTurnLevel = kind === 'turn' || kind === 'error'; const nextMessage = { id, role: 'activity', turnId: normalizedPayload.turnId || previous?.turnId || null, sessionId: normalizedPayload.sessionId || previous?.sessionId || null, content: isTurnLevel ? (normalizedPayload.label || previous?.content || '正在处理') : (previous?.content || '正在处理'), label: isTurnLevel ? (normalizedPayload.label || previous?.label || '正在处理') : (previous?.label || '正在处理'), detail, kind, status: isTurnLevel ? status : (previous?.status || 'running'), timestamp: normalizedPayload.timestamp || previous?.timestamp || new Date().toISOString(), activities: mergeActivityStep(previous?.activities || [], activityStepFromPayload(normalizedPayload)) }; if (existingIndex >= 0) { const next = [...current]; next[existingIndex] = nextMessage; return next; } return [...current, nextMessage]; } export function upsertActivityMessage(current, payload) { const id = statusMessageId(payload); const existingIndex = current.findIndex((message) => message.id === id); const previous = existingIndex >= 0 ? current[existingIndex] : null; const isTurnLevel = payload.kind === 'turn' || payload.kind === 'error'; const activity = activityStepFromPayload(payload, 'activity'); if (!activity && !previous) { return current; } const activities = activity ? mergeActivityStep(previous?.activities || [], activity) : previous?.activities || []; const nextMessage = { id, role: 'activity', turnId: payload.turnId || previous?.turnId || null, sessionId: payload.sessionId || previous?.sessionId || null, content: previous?.content || '正在处理', label: previous?.label || '正在处理', detail: payload.detail || previous?.detail || activity?.detail || '', kind: payload.kind || previous?.kind || 'activity', status: isTurnLevel ? (payload.status || previous?.status || 'running') : (previous?.status || 'running'), timestamp: previous?.timestamp || payload.timestamp || new Date().toISOString(), activities }; if (existingIndex >= 0) { const next = [...current]; next[existingIndex] = nextMessage; return next; } return [...current, nextMessage]; } export function completeStatusMessage(current, payload) { const id = statusMessageId(payload); return current.filter((message) => message.id !== id); } export function hasAssistantMessageForTurn(messages, payload) { return messages.some( (message) => message.role === 'assistant' && payload?.turnId && message.turnId === payload.turnId && typeof message.content === 'string' && message.content.trim() ); } export function removeActivityMessagesForTurn(messages, payload) { const keys = new Set(payloadRunKeys(payload)); if (!keys.size) { return messages; } return messages.filter((message) => { if (message.role !== 'activity') { return true; } return !payloadRunKeys(message).some((key) => keys.has(key)); }); } function normalizedContent(message) { return String(message?.content || '').trim(); } function contentKey(message) { return `${message?.role || ''}\n${normalizedContent(message)}`; } function countServerContent(messages) { const counts = new Map(); for (const message of messages) { const key = contentKey(message); if (!normalizedContent(message)) { continue; } counts.set(key, (counts.get(key) || 0) + 1); } return counts; } function isPendingLocalMessage(message) { return ( message?.role === 'activity' || message?.preview || String(message?.id || '').startsWith('local-') ); } function countPendingLocalContent(messages) { const counts = new Map(); for (const message of messages || []) { if (!isPendingLocalMessage(message) || !normalizedContent(message)) { continue; } const key = contentKey(message); counts.set(key, (counts.get(key) || 0) + 1); } return counts; } function consumeContentMatch(counts, message) { const key = contentKey(message); const count = counts.get(key) || 0; if (!count) { return false; } if (count === 1) { counts.delete(key); } else { counts.set(key, count - 1); } return true; } function turnScopedRunKeys(payload) { return [payload?.turnId, payload?.previousSessionId].filter(Boolean); } export function mergeServerMessagesWithLocalState(current, serverMessages, options = {}) { const next = Array.isArray(serverMessages) ? [...serverMessages] : []; const activeKeys = new Set((options.activeRuns || []).flatMap((run) => payloadRunKeys(run))); const serverIds = new Set(next.map((message) => message.id).filter(Boolean)); const serverKeysByRole = new Map(); for (const message of next) { for (const key of turnScopedRunKeys(message)) { const role = message.role || ''; const roleKeys = serverKeysByRole.get(role) || new Set(); roleKeys.add(key); serverKeysByRole.set(role, roleKeys); } } const serverContentCounts = countServerContent(next); const pendingLocalContentCounts = countPendingLocalContent(current); const shouldPreserveLocalRun = (message) => { if (options.preserveLocalRuns) { return true; } const keys = message.turnId ? [message.turnId] : payloadRunKeys(message); return keys.some((key) => activeKeys.has(key)); }; const serverHasRoleKey = (message) => { const roleKeys = serverKeysByRole.get(message.role || ''); const keys = turnScopedRunKeys(message); return Boolean(keys.length && keys.some((key) => roleKeys?.has(key))); }; const serverHasAssistantForRun = (message) => { const roleKeys = serverKeysByRole.get('assistant'); const keys = turnScopedRunKeys(message); return Boolean(keys.length && keys.some((key) => roleKeys?.has(key))); }; const hasServerMessageForLocal = (message) => { if (serverIds.has(message.id)) { return true; } if (message.role === 'activity' || message.preview) { return serverHasAssistantForRun(message); } if (serverHasRoleKey(message)) { return true; } if ((pendingLocalContentCounts.get(contentKey(message)) || 0) > 1) { return false; } return consumeContentMatch(serverContentCounts, message); }; for (const message of current || []) { if (message.transient) { continue; } if (!isPendingLocalMessage(message)) { continue; } if (!shouldPreserveLocalRun(message)) { continue; } if (hasServerMessageForLocal(message)) { continue; } next.push(message); } return next; } export function upsertAssistantMessage(current, payload) { const content = String(payload.content || '').trim(); if (!content) { return current; } const id = payload.messageId || `assistant-${payload.turnId || Date.now()}`; const isPreview = Boolean(payload.preview); const nextMessage = { id, role: 'assistant', content, timestamp: new Date().toISOString(), turnId: payload.turnId || null, sessionId: payload.sessionId || null, kind: payload.kind, preview: isPreview || undefined }; const withoutActivity = removeActivityMessagesForTurn(current, payload); const withoutStalePreview = !isPreview && payload.turnId ? withoutActivity.filter( (message) => !(message.role === 'assistant' && message.preview && message.turnId === payload.turnId) ) : withoutActivity; const withoutDuplicateTurnAssistant = !isPreview && payload.turnId ? withoutStalePreview.filter( (message) => !(message.role === 'assistant' && message.turnId === payload.turnId && message.id !== id) ) : withoutStalePreview; const existingIndex = withoutDuplicateTurnAssistant.findIndex((message) => message.id === id); if (existingIndex >= 0) { const next = [...withoutDuplicateTurnAssistant]; next[existingIndex] = nextMessage; return next; } return [...withoutDuplicateTurnAssistant, nextMessage]; }