Spaces:
Running
Running
| import crypto from 'node:crypto'; | |
| import { refreshCodexCache } from './codex-data.js'; | |
| import { renameSessionNameIndexRow } from './codex-data-parser.js'; | |
| import { getActiveRuns, runCodexTurn } from './codex-runner.js'; | |
| import { registerMobileSession } from './mobile-session-index.js'; | |
| import { broadcast } from './app-sockets.js'; | |
| import { syncCodexAppThreadMetadata } from './codex-app-state-sync.js'; | |
| export const recentTurns = new Map(); | |
| export const conversationQueues = new Map(); | |
| export const sessionQueueKeys = new Map(); | |
| export const recentImagePromptsByProject = new Map(); | |
| export const activeImageRuns = new Map(); | |
| export let feishuAuthState = { token: null, pendingStates: {} }; | |
| export const MAX_RECENT_TURNS = 80; | |
| export function rememberTurn(turnId, patch) { | |
| if (!turnId) { | |
| return null; | |
| } | |
| const existing = recentTurns.get(turnId) || { turnId, createdAt: new Date().toISOString() }; | |
| const next = { | |
| ...existing, | |
| ...patch, | |
| turnId, | |
| updatedAt: new Date().toISOString() | |
| }; | |
| recentTurns.set(turnId, next); | |
| if (recentTurns.size > MAX_RECENT_TURNS) { | |
| const oldest = [...recentTurns.entries()].sort( | |
| (a, b) => new Date(a[1].updatedAt || a[1].createdAt || 0) - new Date(b[1].updatedAt || b[1].createdAt || 0) | |
| )[0]?.[0]; | |
| if (oldest) { | |
| recentTurns.delete(oldest); | |
| } | |
| } | |
| return next; | |
| } | |
| export function rememberTurnEvent(payload) { | |
| if (!payload?.turnId) { | |
| return; | |
| } | |
| const patch = { | |
| projectId: payload.projectId, | |
| sessionId: payload.sessionId || undefined, | |
| previousSessionId: payload.previousSessionId || undefined | |
| }; | |
| if (payload.type === 'chat-started') { | |
| patch.status = 'running'; | |
| patch.startedAt = payload.startedAt || new Date().toISOString(); | |
| patch.label = '正在思考'; | |
| } else if (payload.type === 'thread-started') { | |
| patch.status = 'running'; | |
| patch.label = '正在思考'; | |
| } else if (payload.type === 'status-update') { | |
| patch.status = payload.status || 'running'; | |
| patch.kind = payload.kind || undefined; | |
| patch.label = payload.label || undefined; | |
| patch.detail = payload.detail || undefined; | |
| } else if (payload.type === 'assistant-update') { | |
| patch.status = 'running'; | |
| patch.hadAssistantText = true; | |
| patch.assistantPreview = payload.content || ''; | |
| patch.messageId = payload.messageId || undefined; | |
| patch.label = '正在回复'; | |
| } else if (payload.type === 'chat-complete') { | |
| patch.status = 'completed'; | |
| patch.completedAt = payload.completedAt || new Date().toISOString(); | |
| patch.hadAssistantText = Boolean(payload.hadAssistantText); | |
| patch.usage = payload.usage || null; | |
| patch.label = '任务已完成'; | |
| } else if (payload.type === 'chat-error') { | |
| patch.status = 'failed'; | |
| patch.error = payload.error || '任务失败'; | |
| patch.label = '任务失败'; | |
| } else if (payload.type === 'chat-aborted') { | |
| patch.status = 'aborted'; | |
| patch.label = '已中止'; | |
| } else { | |
| return; | |
| } | |
| rememberTurn(payload.turnId, patch); | |
| } | |
| export function getActiveImageRuns() { | |
| return [...activeImageRuns.values()].map((run) => ({ | |
| sessionId: run.sessionId, | |
| previousSessionId: run.previousSessionId, | |
| startedAt: run.startedAt, | |
| status: run.status, | |
| turnId: run.turnId, | |
| kind: 'image_generation_call', | |
| label: run.label | |
| })); | |
| } | |
| export function payloadReferencesSession(payload, sessionId) { | |
| return [ | |
| payload?.sessionId, | |
| payload?.previousSessionId, | |
| payload?.draftSessionId, | |
| payload?.selectedSessionId | |
| ].some((value) => value && value === sessionId); | |
| } | |
| export function sessionHasActiveWork(sessionId) { | |
| if (!sessionId) { | |
| return false; | |
| } | |
| const activeRuns = [...getActiveRuns(), ...getActiveImageRuns()]; | |
| if (activeRuns.some((run) => payloadReferencesSession(run, sessionId))) { | |
| return true; | |
| } | |
| for (const turn of recentTurns.values()) { | |
| if ( | |
| (turn.status === 'accepted' || turn.status === 'queued' || turn.status === 'running') && | |
| payloadReferencesSession(turn, sessionId) | |
| ) { | |
| return true; | |
| } | |
| } | |
| for (const state of conversationQueues.values()) { | |
| if (state.running && state.sessionId === sessionId) { | |
| return true; | |
| } | |
| if (state.jobs.some((job) => payloadReferencesSession(job, sessionId))) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| export function rememberConversationAlias(queueKey, sessionId) { | |
| if (queueKey && sessionId) { | |
| sessionQueueKeys.set(sessionId, queueKey); | |
| } | |
| } | |
| export function resolveConversationKey(...ids) { | |
| for (const id of ids) { | |
| if (id && sessionQueueKeys.has(id)) { | |
| return sessionQueueKeys.get(id); | |
| } | |
| } | |
| const queueKey = ids.find(Boolean) || crypto.randomUUID(); | |
| for (const id of ids) { | |
| rememberConversationAlias(queueKey, id); | |
| } | |
| return queueKey; | |
| } | |
| export function getConversationQueue(queueKey) { | |
| if (!conversationQueues.has(queueKey)) { | |
| conversationQueues.set(queueKey, { | |
| sessionId: null, | |
| running: false, | |
| jobs: [] | |
| }); | |
| } | |
| return conversationQueues.get(queueKey); | |
| } | |
| export function emitJobEvent(job, payload) { | |
| const enriched = { projectId: job.project.id, ...payload }; | |
| rememberTurnEvent(enriched); | |
| broadcast(enriched); | |
| } | |
| export function enqueueChatJob(job) { | |
| const state = getConversationQueue(job.queueKey); | |
| rememberConversationAlias(job.queueKey, job.selectedSessionId); | |
| rememberConversationAlias(job.queueKey, job.draftSessionId); | |
| const queued = state.running || state.jobs.length > 0; | |
| state.jobs.push(job); | |
| if (queued) { | |
| const sessionId = state.sessionId || job.selectedSessionId || job.draftSessionId; | |
| rememberTurn(job.turnId, { | |
| status: 'queued', | |
| label: '已加入队列', | |
| sessionId: sessionId || null | |
| }); | |
| broadcast({ | |
| type: 'status-update', | |
| projectId: job.project.id, | |
| sessionId, | |
| turnId: job.turnId, | |
| kind: 'turn', | |
| status: 'queued', | |
| label: '已加入队列', | |
| detail: '', | |
| timestamp: new Date().toISOString() | |
| }); | |
| } | |
| runNextQueuedChat(job.queueKey); | |
| return queued; | |
| } | |
| export function runNextQueuedChat(queueKey) { | |
| const state = getConversationQueue(queueKey); | |
| if (state.running) { | |
| return; | |
| } | |
| const job = state.jobs.shift(); | |
| if (!job) { | |
| return; | |
| } | |
| state.running = true; | |
| const sessionId = state.sessionId || job.selectedSessionId; | |
| runCodexTurn( | |
| { | |
| sessionId, | |
| draftSessionId: job.draftSessionId, | |
| projectPath: job.project.path, | |
| message: job.codexMessage, | |
| model: job.model, | |
| reasoningEffort: job.reasoningEffort, | |
| permissionMode: job.permissionMode, | |
| turnId: job.turnId | |
| }, | |
| (payload) => { | |
| if (payload.sessionId) { | |
| state.sessionId = payload.sessionId; | |
| rememberConversationAlias(queueKey, payload.sessionId); | |
| } | |
| if (payload.previousSessionId) { | |
| rememberConversationAlias(queueKey, payload.previousSessionId); | |
| } | |
| emitJobEvent(job, payload); | |
| } | |
| ).then(async (finalSessionId) => { | |
| if (finalSessionId) { | |
| state.sessionId = finalSessionId; | |
| rememberConversationAlias(queueKey, finalSessionId); | |
| const turn = recentTurns.get(job.turnId) || {}; | |
| const updatedAt = turn.completedAt || new Date().toISOString(); | |
| const title = job.displayMessage.slice(0, 52); | |
| const mobileSession = await registerMobileSession({ | |
| id: finalSessionId, | |
| projectPath: job.project.path, | |
| title, | |
| summary: job.displayMessage, | |
| updatedAt | |
| }); | |
| try { | |
| await renameSessionNameIndexRow(finalSessionId, mobileSession?.title || title, updatedAt, { refreshUpdatedAt: true }); | |
| } catch (error) { | |
| console.warn(`[sessions] Failed to sync Codex session index session=${finalSessionId}: ${error.message}`); | |
| } | |
| try { | |
| await syncCodexAppThreadMetadata({ | |
| threadId: finalSessionId, | |
| title: mobileSession?.title || title, | |
| preview: turn.assistantPreview || job.displayMessage, | |
| updatedAt, | |
| cwd: job.project.path | |
| }); | |
| } catch (error) { | |
| console.warn(`[sessions] Failed to sync Codex App state session=${finalSessionId}: ${error.message}`); | |
| } | |
| } | |
| rememberTurn(job.turnId, { | |
| projectId: job.project.id, | |
| sessionId: finalSessionId || sessionId || job.selectedSessionId || job.draftSessionId || null, | |
| previousSessionId: job.draftSessionId || job.selectedSessionId || null | |
| }); | |
| }).finally(async () => { | |
| try { | |
| const snapshot = await refreshCodexCache(); | |
| broadcast({ type: 'sync-complete', syncedAt: snapshot.syncedAt, projects: snapshot.projects }); | |
| } catch (error) { | |
| console.warn('[sync] Failed to refresh after chat:', error.message); | |
| } finally { | |
| state.running = false; | |
| if (state.jobs.length) { | |
| setTimeout(() => runNextQueuedChat(queueKey), 0); | |
| } | |
| } | |
| }); | |
| } | |
| export function setActiveImageRun(turnId, value) { | |
| activeImageRuns.set(turnId, value); | |
| } | |
| export function getActiveImageRun(turnId) { | |
| return activeImageRuns.get(turnId) || null; | |
| } | |
| export function deleteActiveImageRun(turnId) { | |
| activeImageRuns.delete(turnId); | |
| } | |