import { z } from 'zod'; import { db } from '../db/client.js'; import { chatRuntimeService } from './chat-runtime-service.js'; import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; import { NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; import { cleanupNotificationMessagesForStaleTargetClients } from './notification-message-service.js'; export const CHAT_CONVERSATION_TABLE = 'chat_conversations'; export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages'; export const CHAT_CONVERSATION_CLIENT_TABLE = 'chat_conversation_clients'; export const CHAT_CONVERSATION_REQUEST_TABLE = 'chat_conversation_requests'; export const CHAT_CONVERSATION_ACTIVITY_TABLE = 'chat_conversation_request_activities'; export const CHAT_CONVERSATION_SOURCE_CHANGE_TABLE = 'chat_conversation_source_changes'; export const CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES = [ 'session_id', 'request_id', 'requester_client_id', 'chat_type_id', 'chat_type_label', 'request_origin', 'parent_request_id', 'status', 'status_message', 'user_message_id', 'user_text', 'response_message_id', 'response_text', 'usage_snapshot', 'total_tokens', 'manual_prompt_completed_at', 'manual_verification_completed_at', 'answered_at', 'terminal_at', 'created_at', 'updated_at', ] as const; export const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000; const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000; const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const; const conversationPayloadSchema = z.object({ sessionId: z.string().trim().min(1).max(120), clientId: z.string().trim().max(120).nullable().optional(), title: z.string().trim().max(200).nullable().optional(), draftText: z.string().max(200000).nullable().optional(), requestBadgeLabel: z.string().trim().max(120).nullable().optional(), codexModel: z.string().trim().max(120).nullable().optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), generalSectionName: z.string().trim().max(120).nullable().optional(), contextLabel: z.string().trim().max(200).nullable().optional(), contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).nullable().optional(), notifyOffline: z.boolean().optional(), }); const conversationMessagePayloadSchema = z.object({ sessionId: z.string().trim().min(1).max(120), messageId: z.number().int().positive(), author: z.enum(['codex', 'system', 'user']), text: z.string().max(200000), timestamp: z.string().trim().max(40), clientRequestId: z.string().trim().max(120).nullable().optional(), parts: z.array(z.custom()).optional(), }); export type ChatConversationItem = { sessionId: string; clientId: string | null; draftText: string; title: string; requestBadgeLabel: string | null; codexModel: string | null; chatTypeId: string | null; lastChatTypeId: string | null; generalSectionName: string | null; contextLabel: string | null; contextDescription: string | null; roomScope: Record | null; notifyOffline: boolean; hasUnreadResponse: boolean; hasPendingAttention: boolean; currentRequestId: string | null; currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null; currentJobMessage: string | null; currentQueueSize: number; currentStatusUpdatedAt: string | null; isPendingWork: boolean; pendingWorkReason: 'prompt' | 'analysis' | 'design' | null; lastRequestPreview: string; lastMessagePreview: string; lastResponsePreview: string; createdAt: string; updatedAt: string; lastMessageAt: string | null; }; export type StoredChatMessage = { id: number; author: 'codex' | 'system' | 'user'; text: string; timestamp: string; clientRequestId?: string | null; parts?: ChatMessagePart[]; }; export type ChatConversationRequestStatus = | 'accepted' | 'queued' | 'started' | 'completed' | 'failed' | 'cancelled' | 'removed'; export type ChatConversationRequestUsageSnapshot = { tokenTotals: { total: number; input: number; output: number; cached: number; reasoning: number; }; totalTokens: number; }; export type ChatConversationRequestItem = { sessionId: string; requestId: string; requesterClientId: string | null; chatTypeId?: string | null; chatTypeLabel?: string; requestOrigin: 'composer' | 'prompt' | null; sharedResourceTokenId: string | null; parentRequestId: string | null; status: ChatConversationRequestStatus; statusMessage: string | null; userMessageId: number | null; userText: string; responseMessageId: number | null; responseText: string; usageSnapshot: ChatConversationRequestUsageSnapshot | null; totalTokens: number | null; hasResponse: boolean; canDelete: boolean; manualPromptCompletedAt?: string | null; manualVerificationCompletedAt?: string | null; createdAt: string; updatedAt: string; answeredAt: string | null; terminalAt: string | null; }; export type ChatConversationActivityLogItem = { sessionId: string; requestId: string; lines: string[]; updatedAt: string | null; }; type ChatPromptStepSelectionPatch = { stepKey: string; stepTitle?: string | null; selectedValues: string[]; freeText?: string | null; skipped?: boolean; }; type ChatPromptSelectionPatch = { promptIndex: number; promptTitle: string; promptSignature: string; sourceMessageId?: number; selectedValues: string[]; freeText?: string | null; stepSelections?: ChatPromptStepSelectionPatch[]; summaryText?: string | null; attachments?: Array<{ id: string; name: string; path: string; publicUrl: string; size: number; mimeType: string; }>; }; export type ChatSourceChangeSnapshotItem = { id: string; sessionId: string; clientId: string | null; conversationTitle: string; chatTypeId: string | null; chatTypeLabel: string; requestId: string; requestTitle: string; questionText: string; answerText: string; status: ChatConversationRequestStatus; sourceChangedAt: string; updatedAt: string; featureTags: string[]; changedFiles: string[]; currentSourceFiles: string[]; diffBlocks: string[]; hasSourceChanges: boolean; reviewStatus: 'reviewed' | 'not-reviewed'; sourceChangeKind: 'request' | 'verification-group'; sourceEntryIds: string[]; conversationDeletedAt: string | null; }; export type RecoverableChatConversationRequestItem = { sessionId: string; clientId: string | null; chatTypeId: string | null; lastChatTypeId: string | null; generalSectionName: string | null; contextLabel: string | null; contextDescription: string | null; currentRequestId: string | null; currentJobStatus: ChatConversationItem['currentJobStatus']; requestId: string; requestOrigin: 'composer' | 'prompt' | null; sharedResourceTokenId: string | null; parentRequestId: string | null; status: ChatConversationRequestStatus; userText: string; createdAt: string; updatedAt: string; currentStatusUpdatedAt: string | null; }; export type ChatConversationDetailPage = { messages: StoredChatMessage[]; requests: ChatConversationRequestItem[]; activityLogs: ChatConversationActivityLogItem[]; oldestLoadedMessageId: number | null; hasOlderMessages: boolean; }; type ChatConversationRequestStatusPatch = { requestId: string; status?: ChatConversationRequestStatus; userMessageId?: number | null; userText?: string | null; responseMessageId?: number | null; responseText?: string | null; }; type ChatConversationResponseCandidate = { id: number; messageId: number; author: StoredChatMessage['author']; text: string; clientRequestId: string | null; createdAt: string | null; }; type ChatConversationClientPreference = { sessionId: string; clientId: string; notifyOffline: boolean; lastReadResponseMessageId: number | null; }; type ChatConversationOfflineNotificationClientRow = { clientId: string; notifyOffline: boolean; hasActivePushRegistration: boolean; }; function normalizeDateTimeValue(value: unknown) { if (value == null) { return null; } if (value instanceof Date) { return value.toISOString(); } const normalized = String(value).trim(); if (!normalized) { return null; } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString(); } function normalizeChatConversationUsageMetric(value: unknown) { const numericValue = Number(value); return Number.isFinite(numericValue) ? Math.max(0, Math.round(numericValue)) : 0; } function mapChatConversationRequestUsageSnapshot(value: unknown): ChatConversationRequestUsageSnapshot | null { const parsedValue = typeof value === 'string' && value.trim() ? (() => { try { return JSON.parse(value); } catch { return null; } })() : value; if (!parsedValue || typeof parsedValue !== 'object') { return null; } const snapshot = parsedValue as Partial & { tokenTotals?: Partial | null; }; const tokenTotals: Partial = snapshot.tokenTotals && typeof snapshot.tokenTotals === 'object' ? snapshot.tokenTotals : {}; return { tokenTotals: { total: normalizeChatConversationUsageMetric(tokenTotals.total), input: normalizeChatConversationUsageMetric(tokenTotals.input), output: normalizeChatConversationUsageMetric(tokenTotals.output), cached: normalizeChatConversationUsageMetric(tokenTotals.cached), reasoning: normalizeChatConversationUsageMetric(tokenTotals.reasoning), }, totalTokens: normalizeChatConversationUsageMetric(snapshot.totalTokens), }; } function parseStringArray(value: unknown) { if (typeof value !== 'string') { return [] as string[]; } try { const parsed = JSON.parse(value) as unknown; if (!Array.isArray(parsed)) { return [] as string[]; } return parsed .map((item) => String(item ?? '').trim()) .filter(Boolean); } catch { return [] as string[]; } } function stringifyStringArray(value: string[]) { return JSON.stringify( Array.from( new Set( value.map((item) => String(item ?? '').trim()).filter(Boolean), ), ), ); } function createPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } function createCompactText(value: string | null | undefined, limit = 88) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; } function normalizeClientIdSet(clientIds: Iterable) { return new Set( Array.from(clientIds, (item) => String(item ?? '').trim()).filter(Boolean), ); } export function collectRegisteredNotificationClientIds( rows: Array<{ device_id?: unknown; client_id?: unknown; }>, ) { return new Set( rows .flatMap((row) => [row.device_id, row.client_id]) .map((value) => String(value ?? '').trim()) .filter(Boolean), ); } export function selectStaleOfflineNotificationClientIds( rows: ChatConversationOfflineNotificationClientRow[], options?: { keepClientIds?: Iterable; }, ) { const keepClientIds = normalizeClientIdSet(options?.keepClientIds ?? []); return rows .filter((row) => { if (!row.notifyOffline) { return false; } if (!row.clientId) { return false; } if (keepClientIds.has(row.clientId)) { return false; } return row.hasActivePushRegistration !== true; }) .map((row) => row.clientId); } const SOURCE_CHANGE_VERIFICATION_PATTERN = /^\s*\[\[source-change-verification:(.+?)\]\]\s*$/iu; const PROMPT_PART_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/iu; type SourceChangeVerificationFeature = { key: string; label: string; entryRefs: Array<{ sessionId: string; requestId: string; }>; }; type SourceChangeVerificationMetadata = { version: number; features: SourceChangeVerificationFeature[]; }; function parseJsonRecord(value: string) { try { const parsed = JSON.parse(value) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } } function parseSourceChangeVerificationMetadata(text: string) { const lines = String(text ?? '').split('\n'); for (const line of lines) { const matched = line.match(SOURCE_CHANGE_VERIFICATION_PATTERN); if (!matched?.[1]) { continue; } const record = parseJsonRecord(matched[1]); if (!record) { continue; } const features = Array.isArray(record.features) ? record.features.flatMap((feature) => { if (!feature || typeof feature !== 'object' || Array.isArray(feature)) { return []; } const featureRecord = feature as Record; const key = String(featureRecord.key ?? '').trim(); const label = String(featureRecord.label ?? '').trim(); const entryRefs = Array.isArray(featureRecord.entryRefs) ? featureRecord.entryRefs.flatMap((entryRef) => { if (!entryRef || typeof entryRef !== 'object' || Array.isArray(entryRef)) { return []; } const entryRefRecord = entryRef as Record; const sessionId = String(entryRefRecord.sessionId ?? '').trim(); const requestId = String(entryRefRecord.requestId ?? '').trim(); return sessionId && requestId ? [{ sessionId, requestId }] : []; }) : []; return key && label && entryRefs.length > 0 ? [{ key, label, entryRefs }] : []; }) : []; if (features.length === 0) { continue; } return { version: Number(record.version ?? 1) || 1, features, } satisfies SourceChangeVerificationMetadata; } return null; } function parseSelectedPromptValues(text: string) { const selectedValues = new Set(); const lines = String(text ?? '').split('\n'); for (const line of lines) { const matched = line.match(PROMPT_PART_PATTERN); if (!matched?.[1]) { continue; } const record = parseJsonRecord(matched[1]); if (!record) { continue; } (Array.isArray(record.selectedValues) ? record.selectedValues : []).forEach((value) => { const normalized = String(value ?? '').trim(); if (normalized) { selectedValues.add(normalized); } }); } return selectedValues; } function normalizePromptSelectionValues(values: unknown) { return (Array.isArray(values) ? values : []) .map((value) => String(value ?? '').trim()) .filter(Boolean) .filter((value, index, items) => items.indexOf(value) === index); } export function buildChatPromptTargetSignature(part: Extract) { const stepsSignature = (part.steps ?? []) .map((step) => [ step.key, step.title, step.description ?? '', step.multiple ? 'multi' : 'single', step.optional ? 'optional' : 'required', step.mode ?? '', step.freeTextLabel ?? '', step.freeTextPlaceholder ?? '', ...step.options.map((option) => [option.value, option.label, option.description ?? ''].join('~')), ].join('|'), ) .join('||'); const optionsSignature = part.options .map((option) => [option.value, option.label, option.description ?? ''].join('~')) .join('|'); return [ part.title, part.description ?? '', part.mode ?? '', part.multiple ? 'multi' : 'single', part.submitLabel ?? '', part.freeTextLabel ?? '', part.freeTextPlaceholder ?? '', part.responseTemplate ?? '', part.currentStepKey ?? '', optionsSignature, stepsSignature, ].join('::'); } export function applyChatPromptSelectionPatch( parts: ChatMessagePart[] | undefined, selection: ChatPromptSelectionPatch, resolvedAt: string, ) { const promptParts = (parts ?? []).flatMap((part, index) => (part.type === 'prompt' ? [{ index, part }] : [])); const directMatch = promptParts[selection.promptIndex] ?? null; const matchedPrompt = directMatch && directMatch.part.title === selection.promptTitle && buildChatPromptTargetSignature(directMatch.part) === selection.promptSignature ? directMatch : promptParts.find( ({ part }) => part.title === selection.promptTitle && buildChatPromptTargetSignature(part) === selection.promptSignature, ) ?? null; if (!matchedPrompt) { return null; } const nextSelectedValues = normalizePromptSelectionValues(selection.selectedValues); const stepSelectionMap = new Map( (selection.stepSelections ?? []).map((step) => [ String(step.stepKey ?? '').trim(), { selectedValues: normalizePromptSelectionValues(step.selectedValues), }, ]), ); const nextParts = [...(parts ?? [])]; const currentPart = matchedPrompt.part; const nextSteps = currentPart.steps?.map((step) => { const matchedStep = stepSelectionMap.get(step.key); return matchedStep ? { ...step, selectedValues: matchedStep.selectedValues, } : step; }); const aggregatedStepSelectedValues = nextSteps?.flatMap((step) => normalizePromptSelectionValues(step.selectedValues ?? [])).filter(Boolean) ?? []; const resolvedSelectedValues = nextSelectedValues.length > 0 ? nextSelectedValues : aggregatedStepSelectedValues; const lastStepKey = (selection.stepSelections ?? []) .map((step) => String(step.stepKey ?? '').trim()) .filter(Boolean) .at(-1); nextParts[matchedPrompt.index] = { ...currentPart, steps: nextSteps, currentStepKey: lastStepKey || currentPart.currentStepKey, selectedValues: resolvedSelectedValues, readOnly: true, resolvedBy: 'user', resolvedAt, resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null, attachments: Array.isArray(selection.attachments) ? selection.attachments : [], }; return nextParts; } function selectChatPromptSelectionMessageCandidate( messageRows: Record[], selection: ChatPromptSelectionPatch, resolvedAt: string, ) { for (const messageRow of messageRows) { const nextParts = applyChatPromptSelectionPatch(parseChatMessageParts(messageRow.parts_json), selection, resolvedAt); if (!nextParts) { continue; } return { messageRow, nextParts, }; } return null; } function normalizePromptSelectionSourceMessageId(selection: ChatPromptSelectionPatch) { return Number.isInteger(selection.sourceMessageId) && (selection.sourceMessageId ?? 0) > 0 ? Math.trunc(selection.sourceMessageId as number) : null; } export function collectPromptSelectionCandidateRequestIds( requestRows: Array<{ request_id?: unknown; parent_request_id?: unknown; created_at?: unknown; }>, parentRequestId: string, ) { const normalizedParentRequestId = parentRequestId.trim(); if (!normalizedParentRequestId) { return []; } const normalizedRows = requestRows .map((row, index) => ({ requestId: String(row.request_id ?? '').trim(), parentRequestId: String(row.parent_request_id ?? '').trim(), createdAt: normalizeDateTimeValue(row.created_at) ?? '', order: index, })) .filter((row) => row.requestId); const childRowsByParentRequestId = new Map(); normalizedRows.forEach((row) => { const parentId = row.parentRequestId; if (!parentId) { return; } const currentRows = childRowsByParentRequestId.get(parentId) ?? []; currentRows.push(row); childRowsByParentRequestId.set(parentId, currentRows); }); const includedRequestIds = new Set([normalizedParentRequestId]); const pendingParentRequestIds = [normalizedParentRequestId]; while (pendingParentRequestIds.length > 0) { const currentParentRequestId = pendingParentRequestIds.shift() ?? ''; const childRows = childRowsByParentRequestId.get(currentParentRequestId) ?? []; childRows.forEach((row) => { if (includedRequestIds.has(row.requestId)) { return; } includedRequestIds.add(row.requestId); pendingParentRequestIds.push(row.requestId); }); } return normalizedRows .filter((row) => includedRequestIds.has(row.requestId)) .sort((left, right) => { const createdAtDiff = right.createdAt.localeCompare(left.createdAt); if (createdAtDiff !== 0) { return createdAtDiff; } return right.order - left.order; }) .map((row) => row.requestId); } function sanitizeSourceChangeGroupKey(value: string, fallback: string) { const normalized = String(value ?? '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 24); return normalized || fallback; } function buildVerificationGroupRequestId(requestId: string, featureKey: string, index: number) { const normalizedRequestId = String(requestId ?? '').trim() || 'verification'; const safeKey = sanitizeSourceChangeGroupKey(featureKey, `group-${index + 1}`); const suffix = `::vf:${index + 1}:${safeKey}`; const baseLimit = Math.max(1, 120 - suffix.length); return `${normalizedRequestId.slice(0, baseLimit)}${suffix}`; } function createRequestTitle(userText: string, fallback: string) { const compact = createCompactText(userText, 72); return compact || fallback; } export function hasMeaningfulChatSourceArtifacts(snapshot: { changedFiles?: string[] | null; currentSourceFiles?: string[] | null; diffBlocks?: string[] | null; }) { return [snapshot.changedFiles, snapshot.currentSourceFiles, snapshot.diffBlocks].some((items) => Array.isArray(items) && items.some((item) => typeof item === 'string' && item.trim().length > 0), ); } function extractDiffBlocks(text: string) { return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g)) .map((match) => match[1]?.trim() ?? '') .filter(Boolean); } function normalizeWorkspaceFilePath(value: string) { const normalized = String(value ?? '') .trim() .replace(/\\/g, '/') .replace(/^file:\/\//, '') .replace(/[)>.,]+$/, '') .replace(/:\d+(?::\d+)?$/, ''); if (!normalized) { return ''; } const resourceMarker = '/resource/'; const resourceIndex = normalized.lastIndexOf(resourceMarker); if (resourceIndex >= 0) { const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, ''); return innerPath; } const apiResourceMarker = '/api/chat/resources/'; const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker); if (apiResourceIndex >= 0) { return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, ''); } const legacyWorkspaceMarker = '/workspace/main-project/'; const legacyWorkspaceIndex = normalized.lastIndexOf(legacyWorkspaceMarker); if (legacyWorkspaceIndex >= 0) { return normalized.slice(legacyWorkspaceIndex + legacyWorkspaceMarker.length); } for (const prefix of CURRENT_SOURCE_PREFIXES) { const marker = `/${prefix}`; const markerIndex = normalized.lastIndexOf(marker); if (markerIndex >= 0) { return normalized.slice(markerIndex + 1); } if (normalized.startsWith(prefix)) { return normalized; } } return normalized.replace(/^\/+/, '').replace(/^\.\//, ''); } function isCurrentSourcePath(path: string) { return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix)); } function extractChangedFiles(text: string) { const matches = Array.from( text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), ) .flatMap((match) => [match[1], match[2], match[3]]) .filter((value): value is string => Boolean(value)); return Array.from( new Set( matches .map((item) => normalizeWorkspaceFilePath(item)) .filter(Boolean), ), ).slice(0, 60); } function extractCurrentSourceFiles(text: string) { const textWithoutChatResourcePaths = text .replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ') .replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' '); const diffPathMatches = Array.from( text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), ) .flatMap((match) => [match[1], match[2], match[3]]) .filter((value): value is string => Boolean(value)) .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); const workspacePathMatches = [ ...(text.match(/\[[^\]]*]\((\/[^)\s]+)\)/g) ?? []).map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), ...(text.match(/\/(?:[^/\s)]+\/)*(?:src|docs|etc|public|scripts)\/[^\s)`]+/g) ?? []), ] .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []) .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60); } function deriveFeatureTags(files: string[]) { const tags = new Set(); files.forEach((file) => { const segments = file.split('/').filter(Boolean); if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) { tags.add(`feature:${segments[2]}`); return; } if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) { tags.add(`component:${segments[2]}`); return; } if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) { tags.add(`widget:${segments[2]}`); return; } if (segments[0] === 'docs' && segments[1]) { tags.add(`docs:${segments[1]}`); return; } if (segments[0]) { tags.add(segments[0]); } }); return Array.from(tags); } const SOURCE_CHANGE_MENU_RULES: Array<{ title: string; patterns: RegExp[]; }> = [ { title: 'Preview App / 모바일 앱 열기', patterns: [ /^src\/app\/main\/PreviewAppOverlay\.(?:ts|tsx)$/u, /^src\/app\/main\/PreviewAppWindow\.(?:ts|tsx)$/u, /^src\/app\/main\/previewRuntime\.(?:ts|tsx|js)$/u, /^src\/app\/main\/appUpdate\.(?:ts|tsx|js)$/u, ], }, { title: '리소스 관리 / 리소스 관리', patterns: [ /^src\/app\/main\/ResourceManagementPage\.(?:ts|tsx)$/u, /^src\/app\/main\/resourceManagerApi\.(?:ts|tsx)$/u, /^etc\/servers\/work-server\/src\/routes\/resource-manager\.(?:ts|js)$/u, /^etc\/servers\/work-server\/src\/services\/resource-manager-service\.(?:ts|js)$/u, ], }, { title: 'Codex Live / 변경 이력', patterns: [/^src\/app\/main\/ChatSourceChangesPage\.(?:ts|tsx)$/u], }, { title: '앱로그 / 에러 로그', patterns: [ /^src\/app\/main\/errorLogApi\.(?:ts|js)$/u, /^src\/app\/main\/mainChatPanel\/ErrorLogViewer\.(?:ts|tsx|js)$/u, /^src\/app\/main\/mainChatPanel\/useErrorLogs\.(?:ts|js)$/u, ], }, { title: '채팅 관리 / 유형 권한 관리', patterns: [/^src\/app\/main\/chatTypeAccess\.(?:ts|tsx)$/u], }, { title: '채팅 관리 / 공통 문맥 관리', patterns: [/^src\/app\/main\/chatContextSettingsAccess\.(?:ts|tsx)$/u], }, { title: '작업 / release 검수', patterns: [/^src\/features\/planBoard\/ReleaseReviewPage\.(?:ts|tsx)$/u], }, { title: '작업 / 작업 요청', patterns: [ /^src\/features\/planBoard\/PlanBoardPage\.(?:ts|tsx)$/u, /^etc\/servers\/work-server\/src\/routes\/plan\.(?:ts|js)$/u, /^etc\/servers\/work-server\/src\/services\/plan-service\.(?:ts|js)$/u, /^etc\/servers\/work-server\/src\/services\/plan-schedule-service\.(?:ts|js)$/u, ], }, { title: 'Servers / Command', patterns: [ /^src\/features\/serverCommand\/.+\.(?:ts|tsx)$/u, /^etc\/servers\/work-server\/src\/routes\/server-command\.(?:ts|js)$/u, /^etc\/servers\/work-server\/src\/services\/server-command-service\.(?:ts|js)$/u, ], }, { title: 'Play / Layout Editor', patterns: [ /^src\/features\/layout\/.+\.(?:ts|tsx)$/u, /^src\/views\/play\/LayoutPlaygroundView\.(?:ts|tsx)$/u, ], }, { title: 'APIs / Components', patterns: [/^src\/components\/.+/u], }, { title: 'APIs / Widgets', patterns: [/^src\/widgets\/.+/u], }, { title: 'Codex Live / Codex Live', patterns: [ /^src\/app\/main\/MainChatPanel\.(?:ts|tsx)$/u, /^src\/app\/main\/mainChatPanel\/.+/u, /^etc\/servers\/work-server\/src\/routes\/chat\.(?:ts|js)$/u, /^etc\/servers\/work-server\/src\/services\/chat-(?:message-parts|room-service|service)\.(?:ts|js)$/u, ], }, ]; function resolveDocsScreenTitle(filePath: string) { const matched = filePath.match(/^docs\/([^/]+)/u); if (!matched?.[1]) { return null; } if (matched[1] === 'project') { return 'Docs / 프로젝트 구조'; } return `Docs / ${matched[1]}`; } export function inferSourceChangeScreenTitle( files: string[], fallbackTitle?: string | null, ) { const normalizedFiles = Array.from( new Set( files .map((file) => normalizeWorkspaceFilePath(file)) .map((file) => file.trim()) .filter(Boolean), ), ); for (const rule of SOURCE_CHANGE_MENU_RULES) { if (normalizedFiles.some((file) => rule.patterns.some((pattern) => pattern.test(file)))) { return rule.title; } } for (const file of normalizedFiles) { const docsTitle = resolveDocsScreenTitle(file); if (docsTitle) { return docsTitle; } } const normalizedFallback = String(fallbackTitle ?? '').trim(); return normalizedFallback || '새 대화'; } const PENDING_WORK_ANALYSIS_PATTERNS = [ /분석/u, /검토/u, /조사/u, /원인/u, /파악/u, /\banalysis\b/i, /\binvestigat(?:e|ion)\b/i, ] as const; const PENDING_WORK_DESIGN_PATTERNS = [ /설계/u, /프롬프트/u, /시안/u, /구조/u, /방향/u, /기획/u, /플로우/u, /아키텍처/u, /\bdesign\b/i, /\barchitecture\b/i, ] as const; const PENDING_WORK_IMPLEMENTATION_PATTERNS = [ /구현했/u, /수정했/u, /반영했/u, /적용했/u, /완료했/u, /마무리했/u, /배포했/u, /검증했/u, /빌드.*통과/u, /테스트.*통과/u, /캡처/u, /preview/iu, /변경 파일/u, /diff/u, /\bimplement(?:ed|ation)?\b/i, /\bfix(?:ed)?\b/i, /\bverified?\b/i, /\btested?\b/i, ] as const; const PENDING_WORK_RESPONSE_HOLD_PATTERNS = [ /원하시면/u, /진행해드릴/u, /이어(?:서|가)/u, /다음 단계/u, /선택/u, /옵션/u, /후속/u, /\bif you want\b/i, /\bnext step\b/i, ] as const; function normalizePendingWorkText(text: string | null | undefined) { return String(text ?? '').replace(/\s+/g, ' ').trim(); } function hasPendingWorkPattern(text: string, patterns: readonly RegExp[]) { return patterns.some((pattern) => pattern.test(text)); } function resolvePendingWorkReasonFromText(text: string) { if (!text) { return null; } if (hasPendingWorkPattern(text, PENDING_WORK_DESIGN_PATTERNS)) { return 'design' as const; } if (hasPendingWorkPattern(text, PENDING_WORK_ANALYSIS_PATTERNS)) { return 'analysis' as const; } return null; } function hasOpenPromptParts(parts: ChatMessagePart[] | undefined) { return (parts ?? []).some((part) => { if (part.type !== 'prompt' || part.readOnly === true) { return false; } if ((part.selectedValues?.length ?? 0) > 0) { return false; } if ((part.resultText?.trim() ?? '').length > 0) { return false; } if ((part.resolvedAt?.trim() ?? '').length > 0 || part.resolvedBy != null) { return false; } return true; }); } function resolvePendingWorkState(args: { requestText?: string | null; responseText?: string | null; latestCodexParts?: ChatMessagePart[] | undefined; }) { if (hasOpenPromptParts(args.latestCodexParts)) { return { isPendingWork: true, pendingWorkReason: 'prompt' as const, }; } const requestText = normalizePendingWorkText(args.requestText); const responseText = normalizePendingWorkText(args.responseText); const requestReason = resolvePendingWorkReasonFromText(requestText); if (!requestReason) { return { isPendingWork: false, pendingWorkReason: null, }; } if (hasPendingWorkPattern(responseText, PENDING_WORK_IMPLEMENTATION_PATTERNS)) { return { isPendingWork: false, pendingWorkReason: null, }; } if (!responseText) { return { isPendingWork: true, pendingWorkReason: requestReason, }; } const responseReason = resolvePendingWorkReasonFromText(responseText); if (responseReason || hasPendingWorkPattern(responseText, PENDING_WORK_RESPONSE_HOLD_PATTERNS)) { return { isPendingWork: true, pendingWorkReason: responseReason ?? requestReason, }; } return { isPendingWork: false, pendingWorkReason: null, }; } function isPendingAttentionPromptPart( part: NonNullable, ): part is Extract { return ( part.type === 'prompt' && part.readOnly !== true && part.resolvedBy == null && !(part.resolvedAt?.trim() ?? '') ); } function hasPendingAttentionPromptMessageParts(parts: ChatMessagePart[] | undefined) { return (parts ?? []).some((part) => isPendingAttentionPromptPart(part)); } function hasPendingAttentionVerificationTarget(text: string | null | undefined) { const normalized = String(text ?? '').trim(); if (!normalized) { return false; } if (normalized.length > 720) { return true; } return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized); } function isConversationAttentionPending(options: { request: ChatConversationRequestItem; relatedMessages: StoredChatMessage[]; childRequestCountByParentId: Map; }) { const { request, relatedMessages, childRequestCountByParentId } = options; if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') { return true; } if (!request.manualPromptCompletedAt) { const hasOpenPrompt = relatedMessages.some( (message) => (message.author === 'codex' || message.author === 'system') && hasPendingAttentionPromptMessageParts(message.parts), ); if (hasOpenPrompt) { return true; } } if ((childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0) { return false; } const hasVerificationTarget = relatedMessages.some( (message) => (message.author === 'codex' || message.author === 'system') && hasPendingAttentionVerificationTarget(message.text), ); if (!hasVerificationTarget) { return false; } return !request.manualVerificationCompletedAt; } async function getConversationPendingAttentionMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((item) => item.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const [requestRows, messageRows] = await Promise.all([ db(CHAT_CONVERSATION_REQUEST_TABLE) .select('*') .whereIn('session_id', normalizedSessionIds) .orderBy('created_at', 'asc') .orderBy('request_id', 'asc'), db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('session_id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'display_timestamp') .whereIn('session_id', normalizedSessionIds) .andWhere((builder) => { applyVisibleConversationMessageCondition(builder); }) .orderBy('created_at', 'asc') .orderBy('message_id', 'asc') .orderBy('id', 'asc'), ]); const requestRowsBySession = new Map(); requestRows.forEach((row) => { const request = mapRequestRow(row); const current = requestRowsBySession.get(request.sessionId) ?? []; current.push(request); requestRowsBySession.set(request.sessionId, current); }); const messageRowsBySession = new Map(); messageRows.forEach((row) => { const message = mapMessageRow(row); const sessionId = String(row.session_id ?? '').trim(); if (!sessionId) { return; } const current = messageRowsBySession.get(sessionId) ?? []; current.push(message); messageRowsBySession.set(sessionId, current); }); return normalizedSessionIds.reduce>((result, sessionId) => { const requests = requestRowsBySession.get(sessionId) ?? []; const messages = messageRowsBySession.get(sessionId) ?? []; const childRequestCountByParentId = requests.reduce>((map, request) => { const parentRequestId = request.parentRequestId?.trim() || ''; if (parentRequestId) { map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1); } return map; }, new Map()); const requestMessagesById = messages.reduce>((map, message) => { const requestId = message.clientRequestId?.trim() || ''; if (!requestId) { return map; } const current = map.get(requestId) ?? []; current.push(message); map.set(requestId, current); return map; }, new Map()); result.set( sessionId, requests.some((request) => isConversationAttentionPending({ request, relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [], childRequestCountByParentId, }), ), ); return result; }, new Map()); } const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [ /이전\s*(채팅|대화|문맥)/u, /이전\s*요청/u, /마지막\s*요청/u, /요청내역/u, /두\s*단어/u, /최근\s*작업\s*(뱃지|badge|라벨)/iu, /(?:이어서|이어진|이어가|계속|추가로|연달아|후속|마저)/u, /^(?:그리고|그럼|그러면|또|또한|근데|그런데|여기도|여기서도|이것도|그것도|저것도|이거|그거|저거)/u, /\b(?:also|continue|continued|follow[\s-]?up|same|again)\b/i, ] as const; function normalizeRequestPreviewText(text: string) { return String(text ?? '').replace(/\s+/g, ' ').trim(); } function isContextDependentRequestPreview(text: string) { const normalized = normalizeRequestPreviewText(text); if (!normalized) { return false; } if (CONTEXT_DEPENDENT_REQUEST_PATTERNS.some((pattern) => pattern.test(normalized))) { return true; } if (normalized.length <= 16) { return true; } return false; } function buildLatestRequestPreview( requests: Array<{ text: string; createdAt: string | null }>, ): { text: string; createdAt: string | null } | null { const normalizedRequests = requests .map((request) => ({ text: normalizeRequestPreviewText(request.text), createdAt: request.createdAt, })) .filter((request) => Boolean(request.text)); const latestRequest = normalizedRequests[0]; if (!latestRequest) { return null; } if (!isContextDependentRequestPreview(latestRequest.text)) { return latestRequest; } const previousRequest = normalizedRequests.slice(1).find((request) => !isContextDependentRequestPreview(request.text)) ?? normalizedRequests[1] ?? null; if (!previousRequest) { return latestRequest; } return { text: `${previousRequest.text} ${latestRequest.text}`.trim(), createdAt: latestRequest.createdAt, }; } function isPreviewableConversationMessage(row: { author?: unknown; text?: unknown }) { const author = String(row.author ?? ''); const text = String(row.text ?? '').trim(); if (!text) { return false; } if (author === 'system' && text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`)) { return false; } return author === 'user' || author === 'codex'; } const CHAT_CONVERSATION_LIST_CONTEXT_DESCRIPTION_MAX_LENGTH = 240; function deriveIsolatedChatRoomScopeFromContextDescription(value: string | null | undefined) { const normalized = String(value ?? '').trim(); if (!normalized.includes('## 격리 채팅방 범위')) { return null; } const lines = normalized.split('\n').map((line) => line.trim()); const sectionStartIndex = lines.findIndex((line) => line === '## 격리 채팅방 범위'); if (sectionStartIndex < 0) { return null; } const values = new Map(); for (const line of lines.slice(sectionStartIndex + 1)) { if (!line.startsWith('- ')) { if (line.startsWith('## ')) { break; } continue; } const separatorIndex = line.indexOf(':'); if (separatorIndex <= 2) { continue; } const key = line.slice(2, separatorIndex).trim(); const rawValue = line.slice(separatorIndex + 1).trim(); values.set(key, rawValue === '없음' ? '' : rawValue); } const menuTitle = values.get('현재 활성 메뉴')?.trim() ?? ''; const featureTitle = values.get('현재 기능')?.trim() ?? ''; const pageUrl = values.get('pageUrl')?.trim() ?? ''; if (!menuTitle && !featureTitle && !pageUrl) { return null; } return { topMenu: values.get('topMenu')?.trim() || 'unknown', menuTitle: menuTitle || '현재 메뉴', featureTitle: featureTitle || menuTitle || '현재 기능', focusedComponentId: values.get('focusedComponentId')?.trim() || null, pageUrl, selectionSummary: values.get('현재 선택')?.trim() || null, selectionIds: (values.get('선택 ID') ?? '') .split(',') .map((item) => item.trim()) .filter(Boolean), errorSummary: null, sourceAppId: null, launchedAt: '', } satisfies Record; } function summarizeConversationListContextDescription(value: string | null | undefined) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return null; } if (normalized.length <= CHAT_CONVERSATION_LIST_CONTEXT_DESCRIPTION_MAX_LENGTH) { return normalized; } return `${normalized.slice(0, CHAT_CONVERSATION_LIST_CONTEXT_DESCRIPTION_MAX_LENGTH - 3).trimEnd()}...`; } function mapConversationRow(row: Record): ChatConversationItem { const contextDescription = row.context_description == null ? null : String(row.context_description); return { sessionId: String(row.session_id ?? ''), clientId: row.client_id == null ? null : String(row.client_id), draftText: row.draft_text == null ? '' : String(row.draft_text), title: String(row.title ?? '새 대화'), requestBadgeLabel: row.request_badge_label == null ? null : String(row.request_badge_label), codexModel: row.codex_model == null ? null : String(row.codex_model), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), contextLabel: row.context_label == null ? null : String(row.context_label), contextDescription, roomScope: deriveIsolatedChatRoomScopeFromContextDescription(contextDescription), notifyOffline: Boolean(row.notify_offline), hasUnreadResponse: Boolean(row.has_unread_response), hasPendingAttention: false, currentRequestId: row.current_request_id == null ? null : String(row.current_request_id), currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message), currentQueueSize: Number(row.current_queue_size ?? 0), currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), isPendingWork: false, pendingWorkReason: null, lastRequestPreview: '', lastMessagePreview: String(row.last_message_preview ?? ''), lastResponsePreview: '', createdAt: normalizeDateTimeValue(row.created_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', lastMessageAt: normalizeDateTimeValue(row.last_message_at), }; } function mapMessageRow(row: Record): StoredChatMessage { return { id: Number(row.message_id ?? row.id ?? 0), author: String(row.author ?? 'codex') as StoredChatMessage['author'], text: String(row.text ?? ''), timestamp: String(row.display_timestamp ?? ''), clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), parts: parseChatMessageParts(row.parts_json), }; } export function isVisibleConversationMessage(message: StoredChatMessage) { if (message.author !== 'system') { return true; } return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); } function applyVisibleConversationMessageCondition(builder: any) { builder.whereNot('author', 'system').orWhere((nestedBuilder: any) => { nestedBuilder.where('author', '=', 'system').andWhere('text', 'like', `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n%`); }); } function mapClientPreferenceRow(row: Record): ChatConversationClientPreference { return { sessionId: String(row.session_id ?? ''), clientId: String(row.client_id ?? ''), notifyOffline: Boolean(row.notify_offline), lastReadResponseMessageId: row.last_read_response_message_id == null ? null : Number(row.last_read_response_message_id), }; } function mapRequestRow(row: Record): ChatConversationRequestItem { const status = String(row.status ?? 'accepted') as ChatConversationRequestStatus; const hasResponse = row.response_message_id != null || String(row.response_text ?? '').trim().length > 0; const canDelete = !hasResponse && !['queued', 'started', 'completed'].includes(status); const requestOrigin = String(row.request_origin ?? '').trim(); const parentRequestId = String(row.parent_request_id ?? '').trim(); return { sessionId: String(row.session_id ?? ''), requestId: String(row.request_id ?? ''), requesterClientId: row.requester_client_id == null ? null : String(row.requester_client_id), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), chatTypeLabel: row.chat_type_label == null ? '' : String(row.chat_type_label), requestOrigin: requestOrigin === 'prompt' || requestOrigin === 'composer' ? requestOrigin : null, sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id), parentRequestId: parentRequestId || null, status, statusMessage: row.status_message == null ? null : String(row.status_message), userMessageId: row.user_message_id == null ? null : Number(row.user_message_id), userText: String(row.user_text ?? ''), responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id), responseText: String(row.response_text ?? ''), usageSnapshot: mapChatConversationRequestUsageSnapshot(row.usage_snapshot), totalTokens: row.total_tokens == null || row.total_tokens === '' ? null : Number.isFinite(Number(row.total_tokens)) ? Math.max(0, Math.round(Number(row.total_tokens))) : null, hasResponse, canDelete, manualPromptCompletedAt: normalizeDateTimeValue(row.manual_prompt_completed_at), manualVerificationCompletedAt: normalizeDateTimeValue(row.manual_verification_completed_at), createdAt: normalizeDateTimeValue(row.created_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', answeredAt: normalizeDateTimeValue(row.answered_at), terminalAt: normalizeDateTimeValue(row.terminal_at), }; } function mapSourceChangeSnapshotRow(row: Record): ChatSourceChangeSnapshotItem { const sessionId = String(row.session_id ?? '').trim(); const requestId = String(row.request_id ?? '').trim(); const changedFiles = parseStringArray(row.changed_files_json); const currentSourceFiles = parseStringArray(row.current_source_files_json); return { id: `${sessionId}:${requestId}`, sessionId, clientId: row.client_id == null ? null : String(row.client_id), conversationTitle: inferSourceChangeScreenTitle( [...changedFiles, ...currentSourceFiles], String(row.conversation_title ?? '새 대화'), ), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), chatTypeLabel: row.chat_type_label == null ? '' : String(row.chat_type_label), requestId, requestTitle: String(row.request_title ?? requestId), questionText: String(row.question_text ?? ''), answerText: String(row.answer_text ?? ''), status: String(row.status ?? 'completed') as ChatConversationRequestStatus, sourceChangedAt: normalizeDateTimeValue(row.source_changed_at) ?? normalizeDateTimeValue(row.answered_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', featureTags: parseStringArray(row.feature_tags_json), changedFiles, currentSourceFiles, diffBlocks: parseStringArray(row.diff_blocks_json), hasSourceChanges: Boolean(row.has_source_changes), reviewStatus: String(row.review_status ?? 'not-reviewed') === 'reviewed' ? 'reviewed' : 'not-reviewed', sourceChangeKind: String(row.source_change_kind ?? 'request') === 'verification-group' ? 'verification-group' : 'request', sourceEntryIds: parseStringArray(row.source_entry_ids_json), conversationDeletedAt: normalizeDateTimeValue(row.conversation_deleted_at), }; } function buildSourceChangeSnapshotPayload(args: { sessionId: string; clientId?: string | null; conversationTitle?: string | null; chatTypeId?: string | null; chatTypeLabel?: string | null; requestId: string; status: ChatConversationRequestStatus; questionText: string; answerText: string; answeredAt?: string | null; updatedAt?: string | null; reviewStatus?: 'reviewed' | 'not-reviewed'; sourceChangeKind?: 'request' | 'verification-group'; sourceEntryIds?: string[]; }) { const changedFiles = extractChangedFiles(args.answerText); const diffBlocks = extractDiffBlocks(args.answerText); const currentSourceFiles = extractCurrentSourceFiles(args.answerText); const sourceChangeTitle = inferSourceChangeScreenTitle( [...changedFiles, ...currentSourceFiles], args.conversationTitle, ); const featureTags = deriveFeatureTags(changedFiles); const hasSourceChanges = hasMeaningfulChatSourceArtifacts({ changedFiles, currentSourceFiles, diffBlocks, }); const sourceChangedAt = args.answeredAt?.trim() || args.updatedAt?.trim() || new Date().toISOString(); return { session_id: args.sessionId, client_id: normalizeClientId(args.clientId), request_id: args.requestId, conversation_title: sourceChangeTitle, chat_type_id: args.chatTypeId?.trim() || null, chat_type_label: args.chatTypeLabel?.trim() || null, request_title: createRequestTitle(args.questionText, args.requestId), question_text: args.questionText, answer_text: args.answerText, status: args.status, answered_at: args.answeredAt?.trim() || null, source_changed_at: sourceChangedAt, feature_tags_json: stringifyStringArray(featureTags), changed_files_json: stringifyStringArray(changedFiles), current_source_files_json: stringifyStringArray(currentSourceFiles), diff_blocks_json: stringifyStringArray(diffBlocks), has_source_changes: hasSourceChanges, review_status: args.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed', source_change_kind: args.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request', source_entry_ids_json: stringifyStringArray(args.sourceEntryIds ?? []), conversation_deleted_at: null, updated_at: db.fn.now(), }; } function normalizeClientId(clientId?: string | null) { return clientId?.trim() || null; } function getTimeValue(value: string | null | undefined) { if (!value) { return 0; } const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; } function isRuntimeRequestActive(requestId?: string | null) { const normalizedRequestId = requestId?.trim() || null; if (!normalizedRequestId) { return false; } const detail = chatRuntimeService.getJobDetail(normalizedRequestId); return detail.item != null && detail.terminalStatus == null; } function isTerminalRequestStatus(status: ChatConversationRequestStatus | null | undefined) { return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed'; } function isPreparingChatReplyText(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); } function hasStoredRequestResponse(request: { responseMessageId?: number | null; responseText?: string | null; }) { const normalizedResponseText = String(request.responseText ?? '').trim(); if (normalizedResponseText.length > 0) { return !isPreparingChatReplyText(normalizedResponseText); } return request.responseMessageId != null; } function isConversationRequestActive( conversation: { current_request_id?: unknown; current_job_status?: unknown; } | null | undefined, requestId?: string | null, ) { const normalizedRequestId = requestId?.trim() || null; if (!normalizedRequestId) { return false; } const currentRequestId = String(conversation?.current_request_id ?? '').trim() || null; const currentJobStatus = String(conversation?.current_job_status ?? '').trim(); if (currentRequestId !== normalizedRequestId) { return false; } return currentJobStatus === 'queued' || currentJobStatus === 'started'; } function hasConversationMetadata( conversation: { title?: unknown; draft_text?: unknown; request_badge_label?: unknown; chat_type_id?: unknown; last_chat_type_id?: unknown; general_section_name?: unknown; context_label?: unknown; context_description?: unknown; current_request_id?: unknown; current_job_status?: unknown; } | null | undefined, ) { return [ conversation?.title, conversation?.draft_text, conversation?.request_badge_label, conversation?.chat_type_id, conversation?.last_chat_type_id, conversation?.general_section_name, conversation?.context_label, conversation?.context_description, conversation?.current_request_id, conversation?.current_job_status, ].some((value) => String(value ?? '').trim().length > 0); } export function isManagedChatShareSessionId(sessionId: string | null | undefined) { return String(sessionId ?? '').trim().startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX); } export function normalizeStaleRequestItem( item: ChatConversationRequestItem, conversation: { current_request_id?: unknown; current_job_status?: unknown; current_status_updated_at?: unknown; } | null | undefined, ) { const runtimeActive = isRuntimeRequestActive(item.requestId); const lastUpdatedAt = Math.max( getTimeValue(conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at)), getTimeValue(item.updatedAt), ); const isDetachedStaleInProgressState = !runtimeActive && !isConversationRequestActive(conversation, item.requestId) && (item.status === 'queued' || item.status === 'started') && !hasStoredRequestResponse(item) && !isTerminalRequestStatus(item.status) && lastUpdatedAt > 0 && Date.now() - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS; if ( shouldClearConversationJobState({ currentRequestId: String(conversation?.current_request_id ?? ''), currentJobStatus: conversation?.current_job_status == null ? null : String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'], currentStatusUpdatedAt: conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at), runtimeActive, request: item, }) || isDetachedStaleInProgressState ) { return { ...item, status: 'failed' as const, statusMessage: item.statusMessage ?? '중단된 오래된 요청', canDelete: true, terminalAt: item.terminalAt ?? item.updatedAt, }; } return item; } export function shouldClearConversationJobState(params: { currentRequestId?: string | null; currentJobStatus?: ChatConversationItem['currentJobStatus']; currentStatusUpdatedAt?: string | null; runtimeActive?: boolean; nowMs?: number; request: | { requestId?: string | null; status?: ChatConversationRequestStatus | null; responseMessageId?: number | null; responseText?: string | null; terminalAt?: string | null; updatedAt?: string | null; } | null | undefined; }) { const currentJobStatus = params.currentJobStatus ?? null; const requestStatus = params.request?.status ?? null; const hasStoredResponse = hasStoredRequestResponse(params.request ?? {}); if (!currentJobStatus) { return false; } const currentRequestId = params.currentRequestId?.trim() || null; if (!currentRequestId) { return true; } const requestId = params.request?.requestId?.trim() || null; if (!requestId || requestId !== currentRequestId) { return false; } const runtimeActive = params.runtimeActive === true; const lastUpdatedAt = Math.max( getTimeValue(params.currentStatusUpdatedAt), getTimeValue(params.request?.updatedAt), ); const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now(); const isStaleInProgressState = !runtimeActive && (currentJobStatus === 'queued' || currentJobStatus === 'started') && !hasStoredRequestResponse(params.request ?? {}) && !isTerminalRequestStatus(params.request?.status ?? null) && lastUpdatedAt > 0 && nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS; return ( (requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) || hasStoredResponse || isStaleInProgressState ); } function getRequestStatusRank(status: ChatConversationRequestStatus | null | undefined) { switch (status) { case 'accepted': return 0; case 'queued': return 1; case 'started': return 2; case 'completed': case 'failed': case 'cancelled': case 'removed': return 3; default: return -1; } } function getDefaultChatConversationRequestStatusMessage(status: ChatConversationRequestStatus) { switch (status) { case 'accepted': return '요청을 접수했습니다.'; case 'queued': return '대기열 등록'; case 'started': return '요청 처리 중'; case 'completed': return '요청 처리 완료'; case 'failed': return '요청 처리 실패'; case 'cancelled': return '요청 실행 중단'; case 'removed': return '요청 기록이 제거되었습니다.'; default: return null; } } export function mergeChatConversationRequestStatus( currentStatus: ChatConversationRequestStatus | null | undefined, incomingStatus: ChatConversationRequestStatus | null | undefined, ): ChatConversationRequestStatus { const normalizedCurrent = currentStatus ?? null; const normalizedIncoming = incomingStatus ?? null; if (!normalizedCurrent && !normalizedIncoming) { return 'accepted'; } if (!normalizedCurrent) { return normalizedIncoming ?? 'accepted'; } if (!normalizedIncoming) { return normalizedCurrent; } if (isTerminalRequestStatus(normalizedCurrent) && !isTerminalRequestStatus(normalizedIncoming)) { return normalizedCurrent; } if (!isTerminalRequestStatus(normalizedCurrent) && isTerminalRequestStatus(normalizedIncoming)) { return normalizedIncoming; } return getRequestStatusRank(normalizedIncoming) >= getRequestStatusRank(normalizedCurrent) ? normalizedIncoming : normalizedCurrent; } export function buildChatConversationRequestPatchFromMessage(message: { id: number; author: StoredChatMessage['author']; text: string; clientRequestId?: string | null; }): ChatConversationRequestStatusPatch | null { const normalizedRequestId = message.clientRequestId?.trim() || null; if (!normalizedRequestId) { return null; } if (message.author === 'user') { return { requestId: normalizedRequestId, status: 'accepted', userMessageId: message.id, userText: message.text, }; } if (message.author === 'codex') { return { requestId: normalizedRequestId, status: 'started', responseMessageId: message.id, responseText: message.text, }; } return null; } export function selectChatConversationResponseCandidate( request: { requestId: string; createdAt: string; responseMessageId?: number | null; }, nextRequest: { createdAt: string; } | undefined, messages: ChatConversationResponseCandidate[], ) { const normalizedRequestId = request.requestId.trim(); if (!normalizedRequestId) { return null; } const directMatches = messages.filter( (message) => message.author === 'codex' && message.clientRequestId?.trim() === normalizedRequestId, ); if (directMatches.length > 0) { return directMatches.at(-1) ?? null; } if (request.responseMessageId != null) { const responseMessageMatch = messages.find( (message) => message.author === 'codex' && message.messageId === request.responseMessageId, ); if (responseMessageMatch) { return responseMessageMatch; } } const requestCreatedAt = getTimeValue(request.createdAt); const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); const windowMatches = messages.filter((message) => { if (message.author !== 'codex') { return false; } const linkedRequestId = message.clientRequestId?.trim() || null; if (linkedRequestId && linkedRequestId !== normalizedRequestId) { return false; } const createdAt = getTimeValue(message.createdAt); if (requestCreatedAt > 0 && createdAt > 0 && createdAt < requestCreatedAt) { return false; } if (nextRequestCreatedAt > 0 && createdAt > 0 && createdAt >= nextRequestCreatedAt) { return false; } return Boolean(String(message.text ?? '').trim()); }); return windowMatches.at(-1) ?? null; } async function getLatestPreviewableMessageMap(sessionIds: string[]) { const normalizedSessionIds = sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('session_id', 'author', 'text', 'created_at') .whereIn('session_id', normalizedSessionIds) .whereIn('author', ['user', 'codex']) .orderBy('session_id', 'asc') .orderBy('created_at', 'desc') .orderBy('id', 'desc'); const messageMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); if (!sessionId || messageMap.has(sessionId) || !isPreviewableConversationMessage(row)) { continue; } messageMap.set(sessionId, { text: String(row.text ?? ''), createdAt: normalizeDateTimeValue(row.created_at), }); } return messageMap; } async function getLatestRequestPreviewMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'user_text', 'created_at', 'status', 'request_id') .whereIn('session_id', normalizedSessionIds) .whereNot('status', 'removed') .orderBy('session_id', 'asc') .orderBy('created_at', 'desc') .orderBy('request_id', 'desc'); const requestMap = new Map(); const requestRowsBySession = new Map>(); const completedSessionIds = new Set(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const userText = String(row.user_text ?? '').trim(); if (!sessionId || completedSessionIds.has(sessionId) || !userText) { continue; } const requestRows = requestRowsBySession.get(sessionId) ?? []; requestRows.push({ text: userText, createdAt: normalizeDateTimeValue(row.created_at), }); if (requestRows.length >= 5) { completedSessionIds.add(sessionId); } requestRowsBySession.set(sessionId, requestRows); if (completedSessionIds.size >= normalizedSessionIds.length) { break; } } for (const sessionId of normalizedSessionIds) { const preview = buildLatestRequestPreview(requestRowsBySession.get(sessionId) ?? []); if (!preview) { continue; } requestMap.set(sessionId, preview); } return requestMap; } async function getLatestResponsePreviewMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'response_text', 'answered_at', 'updated_at', 'status', 'request_id') .whereIn('session_id', normalizedSessionIds) .whereNot('status', 'removed') .orderBy('session_id', 'asc') .orderByRaw('COALESCE(answered_at, updated_at, created_at) desc') .orderBy('request_id', 'desc'); const responseMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const responseText = String(row.response_text ?? '').trim(); if (!sessionId || responseMap.has(sessionId) || !responseText) { continue; } responseMap.set(sessionId, { text: responseText, createdAt: normalizeDateTimeValue(row.answered_at ?? row.updated_at), }); } return responseMap; } function resolveConversationPreviewOverride( mapped: ChatConversationItem, latestMessage: { text: string; createdAt: string | null } | undefined, latestRequest: { text: string; createdAt: string | null } | undefined, ) { const latestMessageTime = getTimeValue(latestMessage?.createdAt); const latestRequestTime = getTimeValue(latestRequest?.createdAt); if (latestRequest && latestRequestTime > latestMessageTime) { return { ...mapped, lastMessagePreview: createPreview(latestRequest.text), lastMessageAt: latestRequest.createdAt, }; } if (latestMessage) { return { ...mapped, lastMessagePreview: createPreview(latestMessage.text), lastMessageAt: latestMessage.createdAt, }; } return mapped; } async function getLatestResponseMessageIdMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'response_message_id') .whereIn('session_id', normalizedSessionIds) .whereNotNull('response_message_id') .orderBy('session_id', 'asc') .orderBy('response_message_id', 'desc'); const responseMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const responseMessageId = row.response_message_id == null ? null : Number(row.response_message_id); if (!sessionId || responseMessageId == null || responseMap.has(sessionId)) { continue; } responseMap.set(sessionId, responseMessageId); } return responseMap; } async function getLatestCodexPromptPartsMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('session_id', 'parts_json', 'created_at', 'message_id') .whereIn('session_id', normalizedSessionIds) .andWhere('author', 'codex') .orderBy('session_id', 'asc') .orderBy('created_at', 'desc') .orderBy('message_id', 'desc'); const promptPartMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); if (!sessionId || promptPartMap.has(sessionId)) { continue; } const parts = parseChatMessageParts(row.parts_json); if ((parts ?? []).some((part) => part.type === 'prompt')) { promptPartMap.set(sessionId, parts ?? []); } } return promptPartMap; } async function getLatestResponseMessageId(sessionId: string) { const responseMap = await getLatestResponseMessageIdMap([sessionId]); return responseMap.get(sessionId.trim()) ?? null; } export async function ensureChatConversationTables() { const hasConversationTable = await db.schema.hasTable(CHAT_CONVERSATION_TABLE); if (!hasConversationTable) { await db.schema.createTable(CHAT_CONVERSATION_TABLE, (table) => { table.string('session_id', 120).primary(); table.string('client_id', 120).nullable().index(); table.string('title', 200).notNullable().defaultTo('새 대화'); table.text('draft_text').notNullable().defaultTo(''); table.string('request_badge_label', 120).nullable(); table.string('codex_model', 120).nullable(); table.string('chat_type_id', 120).nullable(); table.string('last_chat_type_id', 120).nullable(); table.string('general_section_name', 120).nullable(); table.string('context_label', 200).nullable(); table.text('context_description').nullable(); table.boolean('notify_offline').notNullable().defaultTo(false); table.string('current_request_id', 120).nullable(); table.string('current_job_status', 40).nullable(); table.text('current_job_message').nullable(); table.integer('current_queue_size').notNullable().defaultTo(0); table.timestamp('current_status_updated_at', { useTz: true }).nullable(); table.text('last_message_preview').notNullable().defaultTo(''); table.timestamp('last_message_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } const requiredConversationColumns: Array<[string, (table: any) => void]> = [ ['client_id', (table) => table.string('client_id', 120).nullable().index()], ['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')], ['draft_text', (table) => table.text('draft_text').notNullable().defaultTo('')], ['request_badge_label', (table) => table.string('request_badge_label', 120).nullable()], ['codex_model', (table) => table.string('codex_model', 120).nullable()], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()], ['general_section_name', (table) => table.string('general_section_name', 120).nullable()], ['context_label', (table) => table.string('context_label', 200).nullable()], ['context_description', (table) => table.text('context_description').nullable()], ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], ['current_request_id', (table) => table.string('current_request_id', 120).nullable()], ['current_job_status', (table) => table.string('current_job_status', 40).nullable()], ['current_job_message', (table) => table.text('current_job_message').nullable()], ['current_queue_size', (table) => table.integer('current_queue_size').notNullable().defaultTo(0)], ['current_status_updated_at', (table) => table.timestamp('current_status_updated_at', { useTz: true }).nullable()], ['last_message_preview', (table) => table.text('last_message_preview').notNullable().defaultTo('')], ['last_message_at', (table) => table.timestamp('last_message_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredConversationColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_TABLE, (table) => { createColumn(table); }); } } const hasClientTable = await db.schema.hasTable(CHAT_CONVERSATION_CLIENT_TABLE); if (!hasClientTable) { await db.schema.createTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('client_id', 120).notNullable().index(); table.boolean('notify_offline').notNullable().defaultTo(false); table.bigInteger('last_read_response_message_id').nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'client_id']); }); } const requiredClientColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['client_id', (table) => table.string('client_id', 120).notNullable().index()], ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], ['last_read_response_message_id', (table) => table.bigInteger('last_read_response_message_id').nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredClientColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_CLIENT_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => { createColumn(table); }); } } const hasMessageTable = await db.schema.hasTable(CHAT_CONVERSATION_MESSAGE_TABLE); if (!hasMessageTable) { await db.schema.createTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.bigInteger('message_id').notNullable(); table.string('author', 20).notNullable(); table.text('text').notNullable(); table.text('parts_json').notNullable().defaultTo('[]'); table.string('display_timestamp', 40).notNullable().defaultTo(''); table.string('client_request_id', 120).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'message_id']); }); } const requiredMessageColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['message_id', (table) => table.bigInteger('message_id').notNullable()], ['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')], ['text', (table) => table.text('text').notNullable().defaultTo('')], ['parts_json', (table) => table.text('parts_json').notNullable().defaultTo('[]')], ['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')], ['client_request_id', (table) => table.string('client_request_id', 120).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredMessageColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_MESSAGE_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => { createColumn(table); }); } } const hasRequestTable = await db.schema.hasTable(CHAT_CONVERSATION_REQUEST_TABLE); if (!hasRequestTable) { await db.schema.createTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('request_id', 120).notNullable(); table.string('requester_client_id', 200).nullable(); table.string('chat_type_id', 120).nullable(); table.string('chat_type_label', 200).nullable(); table.string('request_origin', 40).nullable(); table.string('shared_resource_token_id', 120).nullable().index(); table.string('parent_request_id', 120).nullable(); table.string('status', 40).notNullable().defaultTo('accepted'); table.text('status_message').nullable(); table.bigInteger('user_message_id').nullable(); table.text('user_text').notNullable().defaultTo(''); table.bigInteger('response_message_id').nullable(); table.text('response_text').notNullable().defaultTo(''); table.text('usage_snapshot').nullable(); table.integer('total_tokens').nullable(); table.timestamp('manual_prompt_completed_at', { useTz: true }).nullable(); table.timestamp('manual_verification_completed_at', { useTz: true }).nullable(); table.timestamp('answered_at', { useTz: true }).nullable(); table.timestamp('terminal_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'request_id']); }); } const requiredRequestColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable()], ['requester_client_id', (table) => table.string('requester_client_id', 200).nullable()], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['chat_type_label', (table) => table.string('chat_type_label', 200).nullable()], ['request_origin', (table) => table.string('request_origin', 40).nullable()], ['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).nullable().index()], ['parent_request_id', (table) => table.string('parent_request_id', 120).nullable()], ['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')], ['status_message', (table) => table.text('status_message').nullable()], ['user_message_id', (table) => table.bigInteger('user_message_id').nullable()], ['user_text', (table) => table.text('user_text').notNullable().defaultTo('')], ['response_message_id', (table) => table.bigInteger('response_message_id').nullable()], ['response_text', (table) => table.text('response_text').notNullable().defaultTo('')], ['usage_snapshot', (table) => table.text('usage_snapshot').nullable()], ['total_tokens', (table) => table.integer('total_tokens').nullable()], ['manual_prompt_completed_at', (table) => table.timestamp('manual_prompt_completed_at', { useTz: true }).nullable()], ['manual_verification_completed_at', (table) => table.timestamp('manual_verification_completed_at', { useTz: true }).nullable()], ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()], ['terminal_at', (table) => table.timestamp('terminal_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredRequestColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_REQUEST_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => { createColumn(table); }); } } const hasActivityTable = await db.schema.hasTable(CHAT_CONVERSATION_ACTIVITY_TABLE); if (!hasActivityTable) { await db.schema.createTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('request_id', 120).notNullable().index(); table.integer('line_no').notNullable(); table.text('text').notNullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'request_id', 'line_no']); }); } const requiredActivityColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable().index()], ['line_no', (table) => table.integer('line_no').notNullable()], ['text', (table) => table.text('text').notNullable().defaultTo('')], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredActivityColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_ACTIVITY_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => { createColumn(table); }); } } const hasSourceChangeTable = await db.schema.hasTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE); if (!hasSourceChangeTable) { await db.schema.createTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('client_id', 120).nullable().index(); table.string('request_id', 120).notNullable(); table.string('conversation_title', 200).notNullable().defaultTo('새 대화'); table.string('chat_type_id', 120).nullable(); table.string('chat_type_label', 200).nullable(); table.string('request_title', 200).notNullable().defaultTo(''); table.text('question_text').notNullable().defaultTo(''); table.text('answer_text').notNullable().defaultTo(''); table.string('status', 40).notNullable().defaultTo('completed'); table.timestamp('answered_at', { useTz: true }).nullable(); table.timestamp('source_changed_at', { useTz: true }).nullable(); table.text('feature_tags_json').notNullable().defaultTo('[]'); table.text('changed_files_json').notNullable().defaultTo('[]'); table.text('current_source_files_json').notNullable().defaultTo('[]'); table.text('diff_blocks_json').notNullable().defaultTo('[]'); table.boolean('has_source_changes').notNullable().defaultTo(false); table.string('review_status', 40).notNullable().defaultTo('not-reviewed'); table.string('source_change_kind', 40).notNullable().defaultTo('request'); table.text('source_entry_ids_json').notNullable().defaultTo('[]'); table.timestamp('conversation_deleted_at', { useTz: true }).nullable(); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'request_id']); }); } const requiredSourceChangeColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['client_id', (table) => table.string('client_id', 120).nullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable()], ['conversation_title', (table) => table.string('conversation_title', 200).notNullable().defaultTo('새 대화')], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['chat_type_label', (table) => table.string('chat_type_label', 200).nullable()], ['request_title', (table) => table.string('request_title', 200).notNullable().defaultTo('')], ['question_text', (table) => table.text('question_text').notNullable().defaultTo('')], ['answer_text', (table) => table.text('answer_text').notNullable().defaultTo('')], ['status', (table) => table.string('status', 40).notNullable().defaultTo('completed')], ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()], ['source_changed_at', (table) => table.timestamp('source_changed_at', { useTz: true }).nullable()], ['feature_tags_json', (table) => table.text('feature_tags_json').notNullable().defaultTo('[]')], ['changed_files_json', (table) => table.text('changed_files_json').notNullable().defaultTo('[]')], ['current_source_files_json', (table) => table.text('current_source_files_json').notNullable().defaultTo('[]')], ['diff_blocks_json', (table) => table.text('diff_blocks_json').notNullable().defaultTo('[]')], ['has_source_changes', (table) => table.boolean('has_source_changes').notNullable().defaultTo(false)], ['review_status', (table) => table.string('review_status', 40).notNullable().defaultTo('not-reviewed')], ['source_change_kind', (table) => table.string('source_change_kind', 40).notNullable().defaultTo('request')], ['source_entry_ids_json', (table) => table.text('source_entry_ids_json').notNullable().defaultTo('[]')], ['conversation_deleted_at', (table) => table.timestamp('conversation_deleted_at', { useTz: true }).nullable()], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredSourceChangeColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => { createColumn(table); }); } } } export async function getChatConversation(sessionId: string, clientId?: string | null) { const normalizedSessionId = sessionId.trim(); let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); if (!row) { return null; } const currentRequestId = String(row.current_request_id ?? '').trim() || null; if ( shouldClearConversationJobState({ currentRequestId, currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), runtimeActive: isRuntimeRequestActive(currentRequestId), request: currentRequestId ? await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: currentRequestId, }) .first() .then((requestRow) => requestRow ? { requestId: String(requestRow.request_id ?? ''), status: String(requestRow.status ?? '') as ChatConversationRequestStatus, responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseText: String(requestRow.response_text ?? ''), terminalAt: normalizeDateTimeValue(requestRow.terminal_at), updatedAt: normalizeDateTimeValue(requestRow.updated_at), } : null, ) : null, }) ) { const shouldMarkRequestFailed = currentRequestId && !isRuntimeRequestActive(currentRequestId) && ['queued', 'started'].includes(String(row.current_job_status ?? '').trim()); if (shouldMarkRequestFailed) { await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: currentRequestId, }) .whereIn('status', ['queued', 'started']) .update({ status: 'failed', status_message: '중단된 오래된 요청', terminal_at: db.fn.now(), updated_at: db.fn.now(), }); } await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); if (!row) { return null; } } const mapped = mapConversationRow(row); const latestPreviewMessageMap = await getLatestPreviewableMessageMap([normalizedSessionId]); const latestRequestPreviewMap = await getLatestRequestPreviewMap([normalizedSessionId]); const previewResolvedConversation = resolveConversationPreviewOverride( mapped, latestPreviewMessageMap.get(normalizedSessionId), latestRequestPreviewMap.get(normalizedSessionId), ); const normalizedClientId = normalizeClientId(clientId); if (!normalizedClientId) { return previewResolvedConversation; } const preference = await getChatConversationClientPreference(sessionId, normalizedClientId); const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId); return { ...previewResolvedConversation, clientId: normalizedClientId, notifyOffline: preference?.notifyOffline ?? previewResolvedConversation.notifyOffline, hasUnreadResponse: latestResponseMessageId != null && latestResponseMessageId > (preference?.lastReadResponseMessageId ?? 0), }; } export async function createChatConversation(payload: z.input) { const parsed = conversationPayloadSchema.parse(payload); const normalizedClientId = normalizeClientId(parsed.clientId); const notifyOffline = parsed.notifyOffline ?? true; await db(CHAT_CONVERSATION_TABLE) .insert({ session_id: parsed.sessionId, client_id: normalizedClientId, title: parsed.title?.trim() || '새 대화', draft_text: parsed.draftText ?? '', request_badge_label: parsed.requestBadgeLabel?.trim() || null, codex_model: parsed.codexModel?.trim() || null, chat_type_id: parsed.chatTypeId?.trim() || null, last_chat_type_id: parsed.lastChatTypeId?.trim() || parsed.chatTypeId?.trim() || null, general_section_name: parsed.generalSectionName?.trim() || null, context_label: parsed.contextLabel?.trim() || null, context_description: parsed.contextDescription?.trim() || null, notify_offline: notifyOffline, current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: null, last_message_preview: '', last_message_at: null, created_at: db.fn.now(), updated_at: db.fn.now(), }) .onConflict('session_id') .ignore(); if (normalizedClientId) { const existingPreference = await getChatConversationClientPreference(parsed.sessionId, normalizedClientId); if (!existingPreference) { await upsertChatConversationClientPreference(parsed.sessionId, normalizedClientId, notifyOffline); } } const conversation = await getChatConversation(parsed.sessionId, normalizedClientId); if (!conversation) { throw new Error('채팅방을 저장했지만 다시 불러오지 못했습니다.'); } return conversation; } export async function updateChatConversationContext( sessionId: string, payload: { title?: string | null; draftText?: string | null; requestBadgeLabel?: string | null; clientId?: string | null; codexModel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; generalSectionName?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean | null; }, ) { const normalizedClientId = normalizeClientId(payload.clientId); const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); if (!current) { return null; } const nextUpdatePayload: Record = { ...buildChatConversationContextUpdateFields({ current, payload, }), updated_at: db.fn.now(), }; await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update(nextUpdatePayload); if (normalizedClientId && payload.notifyOffline != null) { await upsertChatConversationClientPreference(sessionId, normalizedClientId, payload.notifyOffline); } return getChatConversation(sessionId, normalizedClientId); } export function resolveNextConversationChatTypeId(currentChatTypeId?: string | null, requestedChatTypeId?: string | null) { const normalizedCurrentChatTypeId = String(currentChatTypeId ?? '').trim() || null; const normalizedRequestedChatTypeId = String(requestedChatTypeId ?? '').trim() || null; return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null; } export function buildChatConversationContextUpdateFields(args: { current: Record; payload: { title?: string | null; draftText?: string | null; requestBadgeLabel?: string | null; clientId?: string | null; codexModel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; generalSectionName?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean | null; }; }) { const hasDefinedPayloadValue = (key: keyof typeof args.payload) => Object.prototype.hasOwnProperty.call(args.payload, key) && args.payload[key] !== undefined; const currentTitle = String(args.current.title ?? '').trim() || null; const currentDraftText = typeof args.current.draft_text === 'string' ? String(args.current.draft_text) : ''; const currentRequestBadgeLabel = String(args.current.request_badge_label ?? '').trim() || null; const currentCodexModel = String(args.current.codex_model ?? '').trim() || null; const currentGeneralSectionName = String(args.current.general_section_name ?? '').trim() || null; const currentContextLabel = String(args.current.context_label ?? '').trim() || null; const currentContextDescription = String(args.current.context_description ?? '').trim() || null; const normalizedClientId = normalizeClientId(args.payload.clientId); const currentChatTypeId = String(args.current.chat_type_id ?? '').trim() || null; const requestedChatTypeId = args.payload.chatTypeId?.trim() || null; const nextChatTypeId = resolveNextConversationChatTypeId(currentChatTypeId, requestedChatTypeId); const requestedTitle = args.payload.title?.trim() || null; const requestedContextLabel = args.payload.contextLabel?.trim() || null; const requestedContextDescription = args.payload.contextDescription?.trim() || null; const hasRequestedCodexModel = hasDefinedPayloadValue('codexModel'); const hasRequestedChatTypeId = hasDefinedPayloadValue('chatTypeId'); const hasRequestedLastChatTypeId = hasDefinedPayloadValue('lastChatTypeId'); const hasRequestedTitle = hasDefinedPayloadValue('title'); const hasRequestedDraftText = hasDefinedPayloadValue('draftText'); const hasRequestedRequestBadgeLabel = hasDefinedPayloadValue('requestBadgeLabel'); const hasRequestedGeneralSectionName = hasDefinedPayloadValue('generalSectionName'); const hasRequestedContextLabel = hasDefinedPayloadValue('contextLabel'); const hasRequestedContextDescription = hasDefinedPayloadValue('contextDescription'); const hasRequestedNotifyOffline = hasDefinedPayloadValue('notifyOffline'); const nextUpdatePayload: Record = {}; if (hasRequestedTitle) { nextUpdatePayload.title = resolveNextConversationContextValue(currentTitle, requestedTitle, hasRequestedTitle) ?? '새 대화'; } if (hasRequestedDraftText) { nextUpdatePayload.draft_text = args.payload.draftText ?? currentDraftText; } if (hasRequestedRequestBadgeLabel) { nextUpdatePayload.request_badge_label = resolveNextConversationContextValue( currentRequestBadgeLabel, args.payload.requestBadgeLabel, hasRequestedRequestBadgeLabel, ); } if (hasRequestedCodexModel) { nextUpdatePayload.codex_model = resolveNextConversationContextValue( currentCodexModel, args.payload.codexModel, hasRequestedCodexModel, ); } if (normalizedClientId && normalizedClientId !== String(args.current.client_id ?? '').trim()) { nextUpdatePayload.client_id = normalizedClientId; } if (hasRequestedChatTypeId) { nextUpdatePayload.chat_type_id = nextChatTypeId; } if (hasRequestedChatTypeId || hasRequestedLastChatTypeId) { nextUpdatePayload.last_chat_type_id = hasRequestedLastChatTypeId ? args.payload.lastChatTypeId?.trim() || nextChatTypeId || args.current.last_chat_type_id || null : nextChatTypeId; } if (hasRequestedGeneralSectionName) { nextUpdatePayload.general_section_name = resolveNextConversationContextValue( currentGeneralSectionName, args.payload.generalSectionName, hasRequestedGeneralSectionName, ); } if (hasRequestedContextLabel) { nextUpdatePayload.context_label = resolveNextConversationContextValue( currentContextLabel, requestedContextLabel, hasRequestedContextLabel, ); } if (hasRequestedContextDescription) { nextUpdatePayload.context_description = resolveNextConversationContextValue( currentContextDescription, requestedContextDescription, hasRequestedContextDescription, ); } if (normalizedClientId == null && hasRequestedNotifyOffline && args.payload.notifyOffline != null) { nextUpdatePayload.notify_offline = args.payload.notifyOffline; } return nextUpdatePayload; } export function resolveNextConversationContextValue( currentValue: string | null | undefined, requestedValue: string | null | undefined, hasRequestedValue: boolean, ) { if (!hasRequestedValue) { return String(currentValue ?? '').trim() || null; } return String(requestedValue ?? '').trim() || null; } export async function listChatConversations( clientId?: string | null, limit = 50, unreadStateClientId?: string | null, ) { const normalizedClientId = normalizeClientId(clientId); const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId); const normalizedLimit = Math.max(1, Math.min(200, Math.round(limit))); let conversationListScopeClientId = normalizedClientId; const buildConversationListQuery = (targetClientId?: string | null) => { const query = db(CHAT_CONVERSATION_TABLE) .select('*') .orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST') .orderByRaw('last_message_at DESC NULLS LAST') .orderByRaw('updated_at DESC NULLS LAST') .orderByRaw('created_at DESC NULLS LAST') .limit(normalizedLimit); if (targetClientId) { query.where((builder) => { builder .where({ client_id: targetClientId }) .orWhereExists( db(CHAT_CONVERSATION_CLIENT_TABLE) .select(db.raw('1')) .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`) .andWhere({ client_id: targetClientId }), ); }); } return query; }; let rows = await buildConversationListQuery(normalizedClientId); // Browser storage reset can regenerate client_id and hide existing rooms. // When that happens, fall back to the recent global list so the user can recover. if (normalizedClientId && rows.length === 0) { conversationListScopeClientId = null; rows = await buildConversationListQuery(null); } const sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean); const currentRequestIds = Array.from( new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)), ); if (sessionIds.length > 0 && currentRequestIds.length > 0) { const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'request_id', 'status', 'response_message_id', 'response_text', 'terminal_at') .whereIn('session_id', sessionIds) .whereIn('request_id', currentRequestIds); const requestMap = new Map( requestRows.map((requestRow) => [ `${String(requestRow.session_id ?? '').trim()}:${String(requestRow.request_id ?? '').trim()}`, { requestId: String(requestRow.request_id ?? ''), status: String(requestRow.status ?? '') as ChatConversationRequestStatus, responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseText: String(requestRow.response_text ?? ''), terminalAt: normalizeDateTimeValue(requestRow.terminal_at), }, ]), ); const staleSessionIds = rows .filter((row) => shouldClearConversationJobState({ currentRequestId: String(row.current_request_id ?? ''), currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')), request: requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null, }), ) .map((row) => String(row.session_id ?? '').trim()) .filter(Boolean); if (staleSessionIds.length > 0) { const staleRequestIds = rows .filter((row) => staleSessionIds.includes(String(row.session_id ?? '').trim())) .map((row) => ({ sessionId: String(row.session_id ?? '').trim(), requestId: String(row.current_request_id ?? '').trim(), status: String(row.current_job_status ?? '').trim(), })) .filter((item) => item.requestId && (item.status === 'queued' || item.status === 'started')); for (const staleItem of staleRequestIds) { await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: staleItem.sessionId, request_id: staleItem.requestId, }) .whereIn('status', ['queued', 'started']) .update({ status: 'failed', status_message: '중단된 오래된 요청', terminal_at: db.fn.now(), updated_at: db.fn.now(), }); } await db(CHAT_CONVERSATION_TABLE) .whereIn('session_id', staleSessionIds) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); rows = await buildConversationListQuery(conversationListScopeClientId); } } rows = rows.filter((row) => !isManagedChatShareSessionId(String(row.session_id ?? ''))); if (rows.length > 0) { const candidateSessionIds = rows.map((row) => String(row.session_id ?? '').trim()).filter(Boolean); const [messageSessionRows, requestSessionRows] = await Promise.all([ db(CHAT_CONVERSATION_MESSAGE_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds), db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds), ]); const visibleSessionIds = new Set( [...messageSessionRows, ...requestSessionRows] .map((row) => String(row.session_id ?? '').trim()) .filter(Boolean), ); rows = rows.filter((row) => { const sessionId = String(row.session_id ?? '').trim(); return visibleSessionIds.has(sessionId) || hasConversationMetadata(row); }); } const latestPreviewMessageMap = await getLatestPreviewableMessageMap( rows.map((row) => String(row.session_id ?? '')), ); const latestRequestPreviewMap = await getLatestRequestPreviewMap( rows.map((row) => String(row.session_id ?? '')), ); const latestResponsePreviewMap = await getLatestResponsePreviewMap( rows.map((row) => String(row.session_id ?? '')), ); const pendingAttentionBySessionId = await getConversationPendingAttentionMap( rows.map((row) => String(row.session_id ?? '')), ); const latestResponseMessageIdMap = await getLatestResponseMessageIdMap( rows.map((row) => String(row.session_id ?? '')), ); const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap( rows.map((row) => String(row.session_id ?? '')), ); const mapListConversationRow = (row: Record) => ({ ...mapConversationRow(row), contextDescription: summarizeConversationListContextDescription(row.context_description as string | null | undefined), }); if (!normalizedUnreadStateClientId) { return rows .map((row) => { const mapped = mapListConversationRow(row); const pendingWorkState = resolvePendingWorkState({ requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '', responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '', latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId), }); return { ...resolveConversationPreviewOverride( mapped, latestPreviewMessageMap.get(mapped.sessionId), latestRequestPreviewMap.get(mapped.sessionId), ), ...pendingWorkState, lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), hasUnreadResponse: false, hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true, }; }) .sort((left, right) => (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt), ); } if (rows.length === 0) { return []; } const preferences = await db(CHAT_CONVERSATION_CLIENT_TABLE) .select('*') .where({ client_id: normalizedUnreadStateClientId }) .whereIn( 'session_id', rows.map((row) => String(row.session_id ?? '')).filter(Boolean), ); const preferenceMap = new Map( preferences.map((row) => { const mapped = mapClientPreferenceRow(row); return [mapped.sessionId, mapped]; }), ); return rows .map((row) => { const mapped = mapListConversationRow(row); const preference = preferenceMap.get(mapped.sessionId); const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId); const pendingWorkState = resolvePendingWorkState({ requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '', responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '', latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId), }); return { ...resolveConversationPreviewOverride( mapped, latestPreviewMessage, latestRequestPreviewMap.get(mapped.sessionId), ), ...pendingWorkState, lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), clientId: normalizedUnreadStateClientId, notifyOffline: preference?.notifyOffline ?? mapped.notifyOffline, hasUnreadResponse: (latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) > (preference?.lastReadResponseMessageId ?? 0), hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true, }; }) .sort((left, right) => (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt), ); } export async function listChatConversationMessages( sessionId: string, options: { limit?: number; beforeMessageId?: number | null; } = {}, ) { const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200))); const normalizedBeforeMessageId = Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 ? Math.trunc(options.beforeMessageId as number) : null; const query = db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('*') .where({ session_id: sessionId.trim() }) .modify((builder) => { if (normalizedBeforeMessageId !== null) { builder.where('message_id', '<', normalizedBeforeMessageId); } builder.andWhere((visibilityBuilder) => { applyVisibleConversationMessageCondition(visibilityBuilder); }); }) .orderBy('message_id', 'desc') .orderBy('id', 'desc') .limit(normalizedLimit); const latestRows = await query; return latestRows.reverse().map((row: Parameters[0]) => mapMessageRow(row)); } export async function listChatConversationActivityLogsByRequestIds( sessionId: string, requestIds: string[], ): Promise { const normalizedSessionId = sessionId.trim(); const normalizedRequestIds = Array.from(new Set(requestIds.map((item) => item.trim()).filter(Boolean))); if (!normalizedSessionId || normalizedRequestIds.length === 0) { return []; } const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .select('session_id', 'request_id', 'text', 'line_no', 'created_at') .where({ session_id: normalizedSessionId }) .whereIn('request_id', normalizedRequestIds) .orderBy('request_id', 'asc') .orderBy('line_no', 'asc') .orderBy('id', 'asc'); const activityMap = new Map(); for (const row of rows) { const requestId = String(row.request_id ?? '').trim(); if (!requestId) { continue; } const existing = activityMap.get(requestId); if (existing) { existing.lines.push(String(row.text ?? '')); existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt; continue; } activityMap.set(requestId, { sessionId: String(row.session_id ?? normalizedSessionId), requestId, lines: [String(row.text ?? '')], updatedAt: normalizeDateTimeValue(row.created_at), }); } return normalizedRequestIds .map((requestId) => activityMap.get(requestId)) .filter(Boolean) as ChatConversationActivityLogItem[]; } async function resolveConversationRequestCursor(sessionId: string, beforeMessageId: number) { const normalizedSessionId = sessionId.trim(); const normalizedBeforeMessageId = Math.trunc(beforeMessageId); if (!normalizedSessionId || normalizedBeforeMessageId <= 0) { return null; } const directRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('request_id', 'created_at') .where({ session_id: normalizedSessionId }) .andWhere((builder) => { builder.where('user_message_id', normalizedBeforeMessageId).orWhere('response_message_id', normalizedBeforeMessageId); }) .first(); if (directRequestRow) { return { requestId: String(directRequestRow.request_id ?? '').trim(), createdAt: normalizeDateTimeValue(directRequestRow.created_at) ?? '', }; } const messageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('client_request_id') .where({ session_id: normalizedSessionId, message_id: normalizedBeforeMessageId, }) .first(); const linkedRequestId = String(messageRow?.client_request_id ?? '').trim(); if (!linkedRequestId) { return null; } const linkedRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('request_id', 'created_at') .where({ session_id: normalizedSessionId, request_id: linkedRequestId, }) .first(); if (!linkedRequestRow) { return null; } return { requestId: String(linkedRequestRow.request_id ?? '').trim(), createdAt: normalizeDateTimeValue(linkedRequestRow.created_at) ?? '', }; } export async function listChatConversationDetailPage( sessionId: string, options: { limit?: number; beforeMessageId?: number | null; } = {}, ): Promise { const normalizedSessionId = sessionId.trim(); await getChatConversation(normalizedSessionId, null); const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 10))); const normalizedBeforeMessageId = Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 ? Math.trunc(options.beforeMessageId as number) : null; const requestCursor = normalizedBeforeMessageId == null ? null : await resolveConversationRequestCursor(normalizedSessionId, normalizedBeforeMessageId); const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .whereNot('status', 'removed') .modify((builder) => { if (!requestCursor) { return; } builder.andWhere((cursorBuilder) => { cursorBuilder .where('created_at', '<', requestCursor.createdAt) .orWhere((sameTimeBuilder) => { sameTimeBuilder.where('created_at', '=', requestCursor.createdAt).andWhere('request_id', '<', requestCursor.requestId); }); }); }) .orderBy('created_at', 'desc') .orderBy('request_id', 'desc') .limit(normalizedLimit); const orderedRequestRows = [...requestRows].reverse(); const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean); if (requestIds.length === 0) { return { messages: [], requests, activityLogs: [], oldestLoadedMessageId: null, hasOlderMessages: false, }; } const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('*') .where({ session_id: normalizedSessionId }) .whereIn('client_request_id', requestIds) .andWhere((builder) => { applyVisibleConversationMessageCondition(builder); }) .orderBy('message_id', 'asc') .orderBy('id', 'asc'); const messages = messageRows.map((row: Parameters[0]) => mapMessageRow(row)); const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, requestIds); const oldestLoadedMessageId = requests.reduce((oldestId, request) => { const candidateIds = [request.userMessageId, request.responseMessageId].filter( (value): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0, ); if (candidateIds.length === 0) { return oldestId; } const nextCandidateId = Math.min(...candidateIds); return oldestId == null ? nextCandidateId : Math.min(oldestId, nextCandidateId); }, null) ?? messages[0]?.id ?? null; const oldestRequest = requests[0] ?? null; const hasOlderMessages = oldestRequest ? Boolean( await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .whereNot('status', 'removed') .andWhere((builder) => { builder .where('created_at', '<', oldestRequest.createdAt) .orWhere((sameTimeBuilder) => { sameTimeBuilder.where('created_at', '=', oldestRequest.createdAt).andWhere('request_id', '<', oldestRequest.requestId); }); }) .first(), ) : false; return { messages, requests, activityLogs, oldestLoadedMessageId, hasOlderMessages, }; } export async function listChatConversationRequests(sessionId: string, limit = 200) { const normalizedSessionId = sessionId.trim(); await getChatConversation(normalizedSessionId, null); const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .orderBy('created_at', 'asc') .limit(Math.max(1, Math.min(1000, Math.round(limit)))); return rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); } export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) { await ensureChatConversationTables(); await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where({ has_source_changes: true }) .andWhere('changed_files_json', '[]') .andWhere('current_source_files_json', '[]') .andWhere('diff_blocks_json', '[]') .update({ has_source_changes: false, updated_at: db.fn.now(), }); const normalizedClientId = normalizeClientId(clientId); const normalizedLimit = Math.max(1, Math.min(500, Math.round(limit))); const buildQuery = (targetClientId?: string | null) => db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .select('*') .where({ has_source_changes: true }) .modify((builder) => { if (targetClientId) { builder.where({ client_id: targetClientId }); } }) .orderByRaw('COALESCE(source_changed_at, answered_at, updated_at) DESC NULLS LAST') .orderBy('updated_at', 'desc') .limit(normalizedLimit); let rows = await buildQuery(normalizedClientId); if (normalizedClientId && rows.length === 0) { rows = await buildQuery(null); } return rows.map((row: Parameters[0]) => mapSourceChangeSnapshotRow(row)); } async function applyVerifiedSourceChangeGrouping(args: { sessionId: string; clientId?: string | null; conversationTitle?: string | null; chatTypeId?: string | null; chatTypeLabel?: string | null; requestId: string; questionText: string; answerText: string; status: ChatConversationRequestStatus; answeredAt?: string | null; updatedAt?: string | null; }) { const metadata = parseSourceChangeVerificationMetadata(args.questionText); if (!metadata) { return; } const selectedFeatureKeys = parseSelectedPromptValues(args.answerText); const entryRefs = metadata.features.flatMap((feature) => feature.entryRefs); if (entryRefs.length === 0) { return; } const sourceRows = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .select('*') .where((builder) => { entryRefs.forEach((entryRef, index) => { const method = index === 0 ? 'where' : 'orWhere'; builder[method]((nestedBuilder: any) => { nestedBuilder.where({ session_id: entryRef.sessionId, request_id: entryRef.requestId, }); }); }); }); const sourceRowMap = new Map>(); sourceRows.forEach((row) => { const sessionId = String(row.session_id ?? '').trim(); const requestId = String(row.request_id ?? '').trim(); if (sessionId && requestId) { sourceRowMap.set(`${sessionId}:${requestId}`, row); } }); const activeSourceIds = Array.from(new Set(entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`))); await db.transaction(async (trx) => { if (activeSourceIds.length > 0) { await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where((builder) => { activeSourceIds.forEach((entryId, index) => { const dividerIndex = entryId.indexOf(':'); const sessionId = dividerIndex >= 0 ? entryId.slice(0, dividerIndex) : ''; const requestId = dividerIndex >= 0 ? entryId.slice(dividerIndex + 1) : ''; const method = index === 0 ? 'where' : 'orWhere'; builder[method]((nestedBuilder: any) => { nestedBuilder.where({ session_id: sessionId, request_id: requestId, }); }); }); }) .update({ has_source_changes: false, updated_at: db.fn.now(), }); } await Promise.all(metadata.features.map(async (feature, index) => { const featureRows = feature.entryRefs .map((entryRef) => sourceRowMap.get(`${entryRef.sessionId}:${entryRef.requestId}`) ?? null) .filter((row): row is Record => row != null); if (featureRows.length === 0) { return; } const changedFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.changed_files_json))); const currentSourceFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.current_source_files_json))); const diffBlocks = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.diff_blocks_json))); const payload = buildSourceChangeSnapshotPayload({ sessionId: args.sessionId, clientId: args.clientId ?? null, conversationTitle: args.conversationTitle ?? null, chatTypeId: args.chatTypeId ?? null, chatTypeLabel: args.chatTypeLabel ?? null, requestId: buildVerificationGroupRequestId(args.requestId, feature.key, index), status: args.status, questionText: args.questionText, answerText: args.answerText, answeredAt: args.answeredAt ?? null, updatedAt: args.updatedAt ?? null, reviewStatus: selectedFeatureKeys.has(feature.key) ? 'reviewed' : 'not-reviewed', sourceChangeKind: 'verification-group', sourceEntryIds: feature.entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`), }); await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .insert({ ...payload, request_title: `${feature.label} 검증`, feature_tags_json: stringifyStringArray([feature.label]), changed_files_json: changedFiles, current_source_files_json: currentSourceFiles, diff_blocks_json: diffBlocks, has_source_changes: hasMeaningfulChatSourceArtifacts({ changedFiles: parseStringArray(changedFiles), currentSourceFiles: parseStringArray(currentSourceFiles), diffBlocks: parseStringArray(diffBlocks), }), }) .onConflict(['session_id', 'request_id']) .merge(); })); }); } export async function syncChatSourceChangeSnapshot(sessionId: string, requestId: string) { await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); await getChatConversation(normalizedSessionId, null); if (!normalizedSessionId || !normalizedRequestId) { return null; } const [conversation, request] = await Promise.all([ db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(), db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(), ]); if (!request) { return null; } const status = String(request.status ?? 'accepted') as ChatConversationRequestStatus; const questionText = String(request.user_text ?? '').trim(); const answerText = String(request.response_text ?? '').trim(); if (status !== 'completed' || !answerText || isPreparingChatReplyText(answerText)) { return null; } const payload = buildSourceChangeSnapshotPayload({ sessionId: normalizedSessionId, clientId: conversation?.client_id == null ? null : String(conversation.client_id), conversationTitle: String(conversation?.title ?? '새 대화'), chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id), chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label), requestId: normalizedRequestId, status, questionText, answerText, answeredAt: normalizeDateTimeValue(request.answered_at), updatedAt: normalizeDateTimeValue(request.updated_at), }); await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .insert(payload) .onConflict(['session_id', 'request_id']) .merge(payload); await applyVerifiedSourceChangeGrouping({ sessionId: normalizedSessionId, clientId: conversation?.client_id == null ? null : String(conversation.client_id), conversationTitle: String(conversation?.title ?? '새 대화'), chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id), chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label), requestId: normalizedRequestId, questionText, answerText, status, answeredAt: normalizeDateTimeValue(request.answered_at), updatedAt: normalizeDateTimeValue(request.updated_at), }); const row = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); return row ? mapSourceChangeSnapshotRow(row) : null; } export async function getChatConversationRequest(sessionId: string, requestId: string) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); if (!normalizedSessionId || !normalizedRequestId) { return null; } await getChatConversation(normalizedSessionId, null); const conversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null; } async function refreshConversationPreview(sessionId: string) { const latestMessage = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: sessionId.trim() }) .whereIn('author', ['user', 'codex']) .orderBy('created_at', 'desc') .orderBy('id', 'desc') .first(); await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update({ last_message_preview: latestMessage && isPreviewableConversationMessage(latestMessage) ? createPreview(String(latestMessage.text ?? '')) : '', last_message_at: latestMessage && isPreviewableConversationMessage(latestMessage) ? latestMessage.created_at : null, updated_at: db.fn.now(), }); } export async function appendChatConversationMessage( conversationPayload: z.input, messagePayload: z.input, ) { const conversation = conversationPayloadSchema.parse(conversationPayload); const message = conversationMessagePayloadSchema.parse(messagePayload); await createChatConversation(conversation); const currentConversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: conversation.sessionId }).first(); const resolvedClientRequestId = message.clientRequestId?.trim() || (message.author === 'codex' ? String(currentConversation?.current_request_id ?? '').trim() || null : null); await db(CHAT_CONVERSATION_MESSAGE_TABLE) .insert({ session_id: message.sessionId, message_id: message.messageId, author: message.author, text: message.text, parts_json: stringifyChatMessageParts(message.parts), display_timestamp: message.timestamp, client_request_id: resolvedClientRequestId, created_at: db.fn.now(), }) .onConflict(['session_id', 'message_id']) .merge({ author: message.author, text: message.text, parts_json: stringifyChatMessageParts(message.parts), display_timestamp: message.timestamp, client_request_id: resolvedClientRequestId, }); await db(CHAT_CONVERSATION_TABLE) .where({ session_id: conversation.sessionId }) .update({ client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null, draft_text: '', chat_type_id: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null, last_chat_type_id: currentConversation?.chat_type_id || currentConversation?.last_chat_type_id || conversation.chatTypeId?.trim() || conversation.lastChatTypeId?.trim() || null, context_label: currentConversation?.chat_type_id || currentConversation?.context_label ? currentConversation?.context_label || null : conversation.contextLabel?.trim() || null, context_description: currentConversation?.chat_type_id || currentConversation?.context_description ? currentConversation?.context_description || null : conversation.contextDescription?.trim() || null, notify_offline: conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline, updated_at: db.fn.now(), }); await refreshConversationPreview(conversation.sessionId); if (normalizeClientId(conversation.clientId) && conversation.notifyOffline != null) { await upsertChatConversationClientPreference( conversation.sessionId, normalizeClientId(conversation.clientId)!, conversation.notifyOffline, ); } const requestPatch = buildChatConversationRequestPatchFromMessage({ id: message.messageId, author: message.author, text: message.text, clientRequestId: resolvedClientRequestId, }); if (requestPatch) { await upsertChatConversationRequest(conversation.sessionId, { requestId: requestPatch.requestId, requesterClientId: message.author === 'user' ? normalizeClientId(conversation.clientId) : undefined, status: requestPatch.status, userMessageId: requestPatch.userMessageId, userText: requestPatch.userText, responseMessageId: requestPatch.responseMessageId, responseText: requestPatch.responseText, }); } } export async function appendChatConversationActivityLine( sessionId: string, requestId: string, line: string, lineNo?: number, ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const normalizedLine = line.trim(); if (!normalizedSessionId || !normalizedRequestId || !normalizedLine) { return null; } const normalizedLineNo = Number.isInteger(lineNo) && Number(lineNo) > 0 ? Number(lineNo) : undefined; let nextLineNo = normalizedLineNo ?? null; if (nextLineNo == null) { const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .count<{ count?: string | number }>('id as count') .first(); nextLineNo = Number(existingLineCountRow?.count ?? 0) + 1; } await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .insert({ session_id: normalizedSessionId, request_id: normalizedRequestId, line_no: nextLineNo, text: normalizedLine, created_at: db.fn.now(), }) .onConflict(['session_id', 'request_id', 'line_no']) .merge({ text: normalizedLine, created_at: db.fn.now(), }); return nextLineNo; } export async function listChatConversationActivityLogs( sessionId: string, limitRequests = 500, ): Promise { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return []; } const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('request_id') .where({ session_id: normalizedSessionId }) .whereNot('status', 'removed') .orderBy('created_at', 'desc') .orderBy('request_id', 'desc') .limit(limitRequests); const requestIds = requestRows .map((row) => String(row.request_id ?? '').trim()) .filter(Boolean); if (requestIds.length === 0) { return []; } const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .select('session_id', 'request_id', 'text', 'line_no', 'created_at') .where({ session_id: normalizedSessionId }) .whereIn('request_id', requestIds) .orderBy('request_id', 'asc') .orderBy('line_no', 'asc') .orderBy('id', 'asc'); const activityMap = new Map(); for (const row of rows) { const requestId = String(row.request_id ?? '').trim(); if (!requestId) { continue; } const existing = activityMap.get(requestId); if (existing) { existing.lines.push(String(row.text ?? '')); existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt; continue; } activityMap.set(requestId, { sessionId: String(row.session_id ?? normalizedSessionId), requestId, lines: [String(row.text ?? '')], updatedAt: normalizeDateTimeValue(row.created_at), }); } return requestIds.map((requestId) => activityMap.get(requestId)).filter(Boolean) as ChatConversationActivityLogItem[]; } export async function listRecoverableChatConversationRequests(): Promise { await ensureChatConversationTables(); const rows = await db(`${CHAT_CONVERSATION_REQUEST_TABLE} as request`) .join(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'request.session_id') .select( 'request.session_id', 'request.request_id', 'request.request_origin', 'request.shared_resource_token_id', 'request.parent_request_id', 'request.status', 'request.user_text', 'request.created_at', 'request.updated_at', 'conversation.client_id', 'conversation.chat_type_id', 'conversation.last_chat_type_id', 'conversation.general_section_name', 'conversation.context_label', 'conversation.context_description', 'conversation.current_request_id', 'conversation.current_job_status', 'conversation.current_status_updated_at', ) .whereIn('request.status', ['accepted', 'queued', 'started']) .andWhere((builder) => { builder.whereNull('request.terminal_at'); }) .andWhere((builder) => { builder.whereNull('request.response_message_id').orWhere('request.response_message_id', 0); }) .orderByRaw( "case when request.request_id = conversation.current_request_id then 0 else 1 end asc", ) .orderBy('request.session_id', 'asc') .orderBy('request.created_at', 'asc') .orderBy('request.request_id', 'asc'); return rows .map((row) => { const sessionId = String(row.session_id ?? '').trim(); const requestId = String(row.request_id ?? '').trim(); const userText = String(row.user_text ?? '').trim(); const createdAt = normalizeDateTimeValue(row.created_at) ?? ''; if (!sessionId || !requestId || !userText || !createdAt) { return null; } return { sessionId, clientId: row.client_id == null ? null : String(row.client_id), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), contextLabel: row.context_label == null ? null : String(row.context_label), contextDescription: row.context_description == null ? null : String(row.context_description), currentRequestId: row.current_request_id == null ? null : String(row.current_request_id), currentJobStatus: row.current_job_status == null ? null : (String(row.current_job_status) as ChatConversationItem['currentJobStatus']), requestId, requestOrigin: row.request_origin == null ? null : String(row.request_origin) === 'prompt' || String(row.request_origin) === 'composer' ? (String(row.request_origin) as 'composer' | 'prompt') : null, sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id), parentRequestId: row.parent_request_id == null ? null : String(row.parent_request_id), status: String(row.status ?? 'accepted') as ChatConversationRequestStatus, userText, createdAt, updatedAt: normalizeDateTimeValue(row.updated_at) ?? createdAt, currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), } satisfies RecoverableChatConversationRequestItem; }) .filter((item): item is RecoverableChatConversationRequestItem => { if (!item) { return false; } return !shouldClearConversationJobState({ currentRequestId: item.currentRequestId, currentJobStatus: item.currentJobStatus, currentStatusUpdatedAt: item.currentStatusUpdatedAt, runtimeActive: false, request: { requestId: item.requestId, status: item.status, responseMessageId: null, responseText: '', terminalAt: null, updatedAt: item.updatedAt, }, }); }); } export async function updateChatConversationJobState( sessionId: string, payload: { requestId?: string | null; status?: 'queued' | 'started' | 'completed' | 'failed' | null; message?: string | null; queueSize?: number | null; clear?: boolean; }, ) { const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); if (!current) { return null; } const shouldClear = payload.clear === true; await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update({ current_request_id: shouldClear ? null : payload.requestId?.trim() || current.current_request_id || null, current_job_status: shouldClear ? null : payload.status ?? current.current_job_status ?? null, current_job_message: shouldClear ? null : payload.message?.trim() || null, current_queue_size: shouldClear ? 0 : Math.max(0, Math.round(payload.queueSize ?? current.current_queue_size ?? 0)), current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); const row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); return row ? mapConversationRow(row) : null; } async function clearConversationJobStateForRequest(sessionId: string, requestId: string) { await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId, current_request_id: requestId, }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); } export async function upsertChatConversationRequest( sessionId: string, payload: { requestId: string; requesterClientId?: string | null; chatTypeId?: string | null; chatTypeLabel?: string | null; requestOrigin?: 'composer' | 'prompt' | null; sharedResourceTokenId?: string | null; parentRequestId?: string | null; status?: ChatConversationRequestStatus; statusMessage?: string | null; userMessageId?: number | null; userText?: string | null; responseMessageId?: number | null; responseText?: string | null; usageSnapshot?: ChatConversationRequestUsageSnapshot | null; totalTokens?: number | null; }, ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = payload.requestId.trim(); const normalizedRequesterClientId = payload.requesterClientId?.trim() || null; const normalizedChatTypeId = payload.chatTypeId?.trim() || null; const normalizedChatTypeLabel = payload.chatTypeLabel?.trim() || null; const normalizedRequestOrigin = payload.requestOrigin === 'prompt' || payload.requestOrigin === 'composer' ? payload.requestOrigin : null; const normalizedSharedResourceTokenId = payload.sharedResourceTokenId?.trim() || null; const normalizedParentRequestId = payload.parentRequestId?.trim() || null; if (!normalizedSessionId || !normalizedRequestId) { return null; } let nextRow: | { session_id: string; request_id: string; requester_client_id: string | null; chat_type_id: string | null; chat_type_label: string | null; request_origin: 'composer' | 'prompt' | null; shared_resource_token_id: string | null; parent_request_id: string | null; status: ChatConversationRequestStatus; status_message: string | null; user_message_id: number | null; user_text: string; response_message_id: number | null; response_text: string; usage_snapshot: string | null; total_tokens: number | null; manual_prompt_completed_at: unknown; manual_verification_completed_at: unknown; answered_at: unknown; terminal_at: unknown; created_at: unknown; updated_at: unknown; } | null = null; let nextStatus: ChatConversationRequestStatus = payload.status ?? 'accepted'; for (let attempt = 0; attempt < 3; attempt += 1) { const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); nextStatus = mergeChatConversationRequestStatus( (current?.status as ChatConversationRequestStatus | undefined) ?? null, payload.status ?? null, ); const currentStatus = (current?.status as ChatConversationRequestStatus | undefined) ?? null; const defaultStatusMessage = payload.status && payload.status !== currentStatus ? getDefaultChatConversationRequestStatusMessage(nextStatus) : null; const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus) ? db.fn.now() : current?.terminal_at ?? null; const answeredAt = payload.responseMessageId != null || (payload.responseText?.trim() ?? '').length > 0 ? current?.answered_at ?? db.fn.now() : current?.answered_at ?? null; const normalizedUsageSnapshot = payload.usageSnapshot === undefined ? current?.usage_snapshot ?? null : payload.usageSnapshot ? JSON.stringify(payload.usageSnapshot) : null; const normalizedTotalTokens = payload.totalTokens === undefined ? current?.total_tokens == null ? null : Math.max(0, Math.round(Number(current.total_tokens))) : payload.totalTokens == null ? null : Math.max(0, Math.round(Number(payload.totalTokens))); nextRow = { session_id: normalizedSessionId, request_id: normalizedRequestId, requester_client_id: normalizedRequesterClientId ?? current?.requester_client_id ?? null, chat_type_id: normalizedChatTypeId ?? current?.chat_type_id ?? null, chat_type_label: normalizedChatTypeLabel ?? current?.chat_type_label ?? null, request_origin: normalizedRequestOrigin ?? current?.request_origin ?? null, shared_resource_token_id: normalizedSharedResourceTokenId ?? current?.shared_resource_token_id ?? null, parent_request_id: normalizedParentRequestId ?? current?.parent_request_id ?? null, status: nextStatus, status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null, user_message_id: payload.userMessageId ?? current?.user_message_id ?? null, user_text: payload.userText ?? current?.user_text ?? '', response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null, response_text: payload.responseText ?? current?.response_text ?? '', usage_snapshot: normalizedUsageSnapshot, total_tokens: normalizedTotalTokens, manual_prompt_completed_at: current?.manual_prompt_completed_at ?? null, manual_verification_completed_at: current?.manual_verification_completed_at ?? null, answered_at: answeredAt, terminal_at: terminalStatus, created_at: current?.created_at ?? db.fn.now(), updated_at: db.fn.now(), }; if (current) { await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .update(nextRow); break; } const insertedRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .insert(nextRow) .onConflict(['session_id', 'request_id']) .ignore() .returning(['session_id']); if (insertedRows.length > 0) { break; } } if (!nextRow) { return null; } const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); if ( shouldClearConversationJobState({ currentRequestId: normalizedRequestId, currentJobStatus: 'started', request: { requestId: normalizedRequestId, status: nextStatus, responseMessageId: nextRow.response_message_id, responseText: nextRow.response_text, terminalAt: nextRow.terminal_at == null ? null : String(nextRow.terminal_at), }, }) ) { await clearConversationJobStateForRequest(normalizedSessionId, normalizedRequestId); } if ( nextStatus === 'completed' && (nextRow.response_message_id != null || String(nextRow.response_text ?? '').trim().length > 0) ) { await syncChatSourceChangeSnapshot(normalizedSessionId, normalizedRequestId); } return row ? mapRequestRow(row) : null; } export async function markChatConversationRequestManualCompletion( sessionId: string, requestId: string, completionType: 'prompt' | 'verification', ) { await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); if (!normalizedSessionId || !normalizedRequestId) { return null; } const targetColumn = completionType === 'prompt' ? 'manual_prompt_completed_at' : 'manual_verification_completed_at'; await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .update({ [targetColumn]: db.fn.now(), updated_at: db.fn.now(), }); const conversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null; } export async function persistChatConversationPromptSelection( sessionId: string, parentRequestId: string, selection: ChatPromptSelectionPatch, ) { await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const normalizedParentRequestId = parentRequestId.trim(); if (!normalizedSessionId || !normalizedParentRequestId) { return null; } let requestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedParentRequestId, }) .first(); const resolvedAt = new Date().toISOString(); if (!requestRow) { return null; } const loadCandidateRequestRows = async () => db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, }) .select('request_id', 'parent_request_id', 'response_message_id', 'created_at') .orderBy('created_at', 'desc') .orderBy('request_id', 'desc'); let candidateRequestRows = await loadCandidateRequestRows(); let candidateRequestIds = collectPromptSelectionCandidateRequestIds(candidateRequestRows, normalizedParentRequestId); const sourceMessageId = normalizePromptSelectionSourceMessageId(selection); if (candidateRequestIds.length === 0) { candidateRequestIds = [normalizedParentRequestId]; } const candidateMessageRows: Record[] = []; const seenMessageIds = new Set(); const appendCandidateMessageRows = (rows: Record[]) => { rows.forEach((row) => { const messageId = String(row.message_id ?? row.id ?? '').trim(); if (!messageId || seenMessageIds.has(messageId)) { return; } seenMessageIds.add(messageId); candidateMessageRows.push(row); }); }; const loadResponseMessageRow = async (targetRequestRow: Record | undefined | null) => { const responseMessageId = targetRequestRow?.response_message_id == null ? null : Number(targetRequestRow.response_message_id); if (responseMessageId == null) { return null; } return db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, message_id: responseMessageId, }) .first(); }; if (sourceMessageId != null) { const sourceMessageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, message_id: sourceMessageId, }) .first(); if (sourceMessageRow) { appendCandidateMessageRows([sourceMessageRow]); } } const appendCandidateResponseMessageRows = async ( rows: Array>, preferredRequestIds: string[], ) => { const requestRowById = new Map( rows.map((row) => [String(row.request_id ?? '').trim(), row] as const).filter(([requestId]) => requestId), ); for (const requestId of preferredRequestIds) { const targetRequestRow = requestRowById.get(requestId); if (!targetRequestRow) { continue; } const responseMessageRow = await loadResponseMessageRow(targetRequestRow); if (responseMessageRow) { appendCandidateMessageRows([responseMessageRow]); } } }; await appendCandidateResponseMessageRows(candidateRequestRows, candidateRequestIds); if (candidateMessageRows.length === 0) { await repairChatConversationRequestLinks(normalizedSessionId); requestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedParentRequestId, }) .first(); if (!requestRow) { return null; } candidateRequestRows = await loadCandidateRequestRows(); candidateRequestIds = collectPromptSelectionCandidateRequestIds(candidateRequestRows, normalizedParentRequestId); if (candidateRequestIds.length === 0) { candidateRequestIds = [normalizedParentRequestId]; } await appendCandidateResponseMessageRows(candidateRequestRows, candidateRequestIds); } const linkedMessageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, }) .whereIn('client_request_id', candidateRequestIds) .orderBy('created_at', 'desc') .orderBy('message_id', 'desc') .orderBy('id', 'desc') .limit(Math.max(12, candidateRequestIds.length * 4)); appendCandidateMessageRows(linkedMessageRows); if (candidateMessageRows.length === 0) { const recentPromptMessageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, }) .andWhere('parts_json', 'like', '%"type":"prompt"%') .orderBy('created_at', 'desc') .orderBy('message_id', 'desc') .orderBy('id', 'desc') .limit(24); appendCandidateMessageRows(recentPromptMessageRows); } const matchedCandidate = selectChatPromptSelectionMessageCandidate(candidateMessageRows, selection, resolvedAt); if (!matchedCandidate) { return null; } const { messageRow, nextParts } = matchedCandidate; const resolvedMessageId = Number(messageRow.message_id ?? messageRow.id ?? 0); if (!resolvedMessageId) { return null; } await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, message_id: resolvedMessageId, }) .update({ parts_json: stringifyChatMessageParts(nextParts), }); await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedParentRequestId, }) .update({ response_message_id: requestRow.response_message_id ?? resolvedMessageId, response_text: String(requestRow.response_text ?? '').trim() || String(messageRow.text ?? ''), manual_prompt_completed_at: requestRow.manual_prompt_completed_at ?? db.fn.now(), updated_at: db.fn.now(), }); const [updatedConversation, updatedRequestRow, updatedMessageRow] = await Promise.all([ db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(), db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedParentRequestId, }) .first(), db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, message_id: resolvedMessageId, }) .first(), ]); if (!updatedRequestRow || !updatedMessageRow) { return null; } return { request: normalizeStaleRequestItem(mapRequestRow(updatedRequestRow), updatedConversation), message: mapMessageRow(updatedMessageRow), }; } export async function repairChatConversationRequestLinks(sessionId?: string | null) { await ensureChatConversationTables(); const normalizedSessionId = sessionId?.trim() || null; const sessionRows = normalizedSessionId ? [{ session_id: normalizedSessionId }] : await db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').orderBy('session_id', 'asc'); let repairedRequestCount = 0; let linkedMessageCount = 0; let completedStatusCount = 0; const touchedSessions: string[] = []; for (const sessionRow of sessionRows) { const currentSessionId = String(sessionRow.session_id ?? '').trim(); if (!currentSessionId) { continue; } const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: currentSessionId }) .orderBy('created_at', 'asc') .orderBy('request_id', 'asc'); const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: currentSessionId }) .select('id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'created_at') .orderBy('created_at', 'asc') .orderBy('message_id', 'asc') .orderBy('id', 'asc'); let sessionTouched = false; const responseMessages: ChatConversationResponseCandidate[] = messageRows.map((row) => ({ id: Number(row.id ?? 0), messageId: Number(row.message_id ?? 0), author: String(row.author ?? 'codex') as StoredChatMessage['author'], text: String(row.text ?? ''), clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), createdAt: normalizeDateTimeValue(row.created_at), })); for (let index = 0; index < requestRows.length; index += 1) { const requestRow = requestRows[index]; const nextRequestRow = requestRows[index + 1]; const requestId = String(requestRow.request_id ?? '').trim(); if (!requestId) { continue; } const candidate = selectChatConversationResponseCandidate( { requestId, createdAt: normalizeDateTimeValue(requestRow.created_at) ?? '', responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), }, nextRequestRow ? { createdAt: normalizeDateTimeValue(nextRequestRow.created_at) ?? '', } : undefined, responseMessages, ); if (!candidate) { continue; } if ((candidate.clientRequestId?.trim() || null) !== requestId) { await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: currentSessionId, message_id: candidate.messageId, }) .update({ client_request_id: requestId, }); candidate.clientRequestId = requestId; linkedMessageCount += 1; sessionTouched = true; } const shouldPromoteToCompleted = !isTerminalRequestStatus(String(requestRow.status ?? '') as ChatConversationRequestStatus) && requestRow.terminal_at != null; const nextStatus = shouldPromoteToCompleted ? 'completed' : undefined; const previousStatus = String(requestRow.status ?? '').trim(); await upsertChatConversationRequest(currentSessionId, { requestId, status: nextStatus, responseMessageId: candidate.messageId, responseText: candidate.text, }); if ( requestRow.response_message_id == null || String(requestRow.response_text ?? '') !== candidate.text || requestRow.answered_at == null ) { repairedRequestCount += 1; sessionTouched = true; } if (nextStatus === 'completed' && previousStatus !== 'completed') { completedStatusCount += 1; sessionTouched = true; } } if (sessionTouched) { touchedSessions.push(currentSessionId); } } return { sessionCount: sessionRows.length, touchedSessions, repairedRequestCount, linkedMessageCount, completedStatusCount, }; } export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); if (!current) { return { deleted: false, reason: 'not_found' as const }; } const conversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation); if (mapped.hasResponse) { return { deleted: false, reason: 'answered' as const }; } if (mapped.status === 'queued' || mapped.status === 'started') { return { deleted: false, reason: 'active' as const }; } await db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, client_request_id: normalizedRequestId, }) .del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .del(); const conversation = await trx(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); if (conversation?.current_request_id === normalizedRequestId) { await trx(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); } }); await refreshConversationPreview(normalizedSessionId); return { deleted: true, reason: null as null }; } export async function cancelUnansweredChatConversationRequest( sessionId: string, requestId: string, statusMessage = '사용자 요청으로 중단된 요청을 취소 처리했습니다.', ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); if (!current) { return { cancelled: false, reason: 'not_found' as const, item: null }; } const conversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation); if (mapped.hasResponse) { return { cancelled: false, reason: 'answered' as const, item: null }; } if (mapped.status === 'queued' || mapped.status === 'started') { return { cancelled: false, reason: 'active' as const, item: null }; } if (mapped.status === 'cancelled' || mapped.status === 'removed') { return { cancelled: false, reason: 'already_terminal' as const, item: mapped }; } const item = await upsertChatConversationRequest(normalizedSessionId, { requestId: normalizedRequestId, status: 'cancelled', statusMessage, }); await refreshConversationPreview(normalizedSessionId); return { cancelled: Boolean(item), reason: item ? null : ('not_found' as const), item }; } export async function clearAllChatConversationJobStates() { await ensureChatConversationTables(); await db(CHAT_CONVERSATION_TABLE) .whereNotNull('current_job_status') .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); } export async function deleteChatConversation(sessionId: string) { const normalizedSessionId = sessionId.trim(); return db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where({ session_id: normalizedSessionId }) .update({ conversation_deleted_at: db.fn.now(), updated_at: db.fn.now(), }); await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del(); const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).del(); return deletedCount > 0; }); } export async function clearChatConversationData(sessionId: string, clientId?: string | null) { const normalizedSessionId = sessionId.trim(); await db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: normalizedSessionId }) .update({ last_read_response_message_id: null, updated_at: db.fn.now(), }); await trx(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: null, draft_text: '', last_message_preview: '', last_message_at: null, updated_at: db.fn.now(), }); }); return getChatConversation(normalizedSessionId, clientId); } export async function getChatConversationClientPreference(sessionId: string, clientId: string) { const row = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), client_id: clientId.trim(), }) .first(); return row ? mapClientPreferenceRow(row) : null; } async function listRegisteredNotificationClientIds(clientIds: string[]) { const normalizedClientIds = [...new Set(clientIds.map((item) => String(item ?? '').trim()).filter(Boolean))]; if (!normalizedClientIds.length) { return new Set(); } const [webPushRows, tokenRows] = await Promise.all([ db(WEB_PUSH_SUBSCRIPTION_TABLE) .where({ is_enabled: true }) .andWhere((builder) => { builder.whereIn('device_id', normalizedClientIds).orWhereIn('client_id', normalizedClientIds); }) .select('device_id', 'client_id'), db(NOTIFICATION_TOKEN_TABLE) .where({ is_enabled: true }) .whereIn('device_id', normalizedClientIds) .select('device_id'), ]); return collectRegisteredNotificationClientIds([...webPushRows, ...tokenRows]); } export async function cleanupStaleChatConversationOfflineNotificationClients( sessionId: string, options?: { keepClientIds?: Iterable; }, ) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return [] as string[]; } const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: normalizedSessionId, notify_offline: true, }) .select('client_id'); const optedInClientIds = rows .map((row) => String(row.client_id ?? '').trim()) .filter(Boolean); const registeredClientIds = await listRegisteredNotificationClientIds(optedInClientIds); const staleClientIds = selectStaleOfflineNotificationClientIds( optedInClientIds.map((clientId) => ({ clientId, notifyOffline: true, hasActivePushRegistration: registeredClientIds.has(clientId), })), options, ); if (!staleClientIds.length) { return [] as string[]; } await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: normalizedSessionId }) .whereIn('client_id', staleClientIds) .update({ notify_offline: false, updated_at: db.fn.now(), }); await cleanupNotificationMessagesForStaleTargetClients({ sessionId: normalizedSessionId, staleClientIds, }); return staleClientIds; } export async function listChatConversationOfflineNotificationClientIds( sessionId: string, options?: { keepClientIds?: Iterable; }, ) { await cleanupStaleChatConversationOfflineNotificationClients(sessionId, options); const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), notify_offline: true, }) .select('client_id'); return rows .map((row) => String(row.client_id ?? '').trim()) .filter(Boolean); } export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) { const normalizedSessionId = sessionId.trim(); const normalizedClientId = clientId.trim(); await db(CHAT_CONVERSATION_CLIENT_TABLE) .insert({ session_id: normalizedSessionId, client_id: normalizedClientId, notify_offline: notifyOffline, last_read_response_message_id: null, created_at: db.fn.now(), updated_at: db.fn.now(), }) .onConflict(['session_id', 'client_id']) .merge({ notify_offline: notifyOffline, updated_at: db.fn.now(), }); if (notifyOffline) { await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, { keepClientIds: [normalizedClientId], }); } return getChatConversationClientPreference(normalizedSessionId, normalizedClientId); } export async function markChatConversationResponsesRead(sessionId: string, clientId: string) { const normalizedSessionId = sessionId.trim(); const normalizedClientId = clientId.trim(); if (!normalizedSessionId || !normalizedClientId) { return null; } const currentConversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); if (!currentConversation) { return null; } const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId); await db(CHAT_CONVERSATION_CLIENT_TABLE) .insert({ session_id: normalizedSessionId, client_id: normalizedClientId, notify_offline: Boolean(currentConversation.notify_offline), last_read_response_message_id: latestResponseMessageId, created_at: db.fn.now(), updated_at: db.fn.now(), }) .onConflict(['session_id', 'client_id']) .merge({ last_read_response_message_id: latestResponseMessageId, updated_at: db.fn.now(), }); await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, { keepClientIds: [normalizedClientId], }); return { sessionId: normalizedSessionId, lastReadResponseMessageId: latestResponseMessageId, }; } export type SharedResourceTokenRequestUsageSummary = { sharedResourceTokenId: string; requestCount: number; completedRequestCount: number; totalTokens: number; lastUsedAt: string | null; }; export function buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(tokenIds: string[]) { const normalizedTokenIds = Array.from(new Set(tokenIds.map((value) => String(value ?? '').trim()).filter(Boolean))); if (normalizedTokenIds.length === 0) { return null; } return db(CHAT_CONVERSATION_REQUEST_TABLE) .whereIn('shared_resource_token_id', normalizedTokenIds) .groupBy('shared_resource_token_id') .select( 'shared_resource_token_id', db.raw('count(*) as request_count'), db.raw('sum(COALESCE(total_tokens, 0)) as total_tokens'), db.raw(`sum(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_request_count`), db.raw('max(COALESCE(answered_at, terminal_at, updated_at, created_at)) as last_used_at'), ); } export async function listChatConversationRequestUsageBySharedResourceTokenIds(tokenIds: string[]) { const usageSummaryQuery = buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(tokenIds); if (!usageSummaryQuery) { return [] as SharedResourceTokenRequestUsageSummary[]; } await ensureChatConversationTables(); const rows = await usageSummaryQuery as Array<{ shared_resource_token_id: string | null; request_count: string | number | null; completed_request_count: string | number | null; total_tokens: string | number | null; last_used_at: string | Date | null; }>; return rows.map((row) => ({ sharedResourceTokenId: String(row.shared_resource_token_id ?? '').trim(), requestCount: Math.max(0, Number(row.request_count ?? 0) || 0), completedRequestCount: Math.max( 0, Number(row.completed_request_count ?? 0) || 0, ), totalTokens: Math.max(0, Number(row.total_tokens ?? 0) || 0), lastUsedAt: normalizeDateTimeValue(row.last_used_at), })); } export async function assignSharedResourceTokenToRequests(sessionId: string, requestIds: string[], sharedResourceTokenId: string) { const normalizedSessionId = sessionId.trim(); const normalizedTokenId = sharedResourceTokenId.trim(); const normalizedRequestIds = Array.from(new Set(requestIds.map((value) => String(value ?? '').trim()).filter(Boolean))); if (!normalizedSessionId || !normalizedTokenId || normalizedRequestIds.length === 0) { return 0; } await ensureChatConversationTables(); return db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .whereIn('request_id', normalizedRequestIds) .update({ shared_resource_token_id: normalizedTokenId, updated_at: db.fn.now(), }); } export async function clearSharedResourceTokenFromRequests(sharedResourceTokenId: string, trx: typeof db = db) { const normalizedTokenId = sharedResourceTokenId.trim(); if (!normalizedTokenId) { return 0; } await ensureChatConversationTables(); return trx(CHAT_CONVERSATION_REQUEST_TABLE) .where({ shared_resource_token_id: normalizedTokenId }) .update({ shared_resource_token_id: null, updated_at: db.fn.now(), }); }