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'; 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'; 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 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(), 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; title: string; chatTypeId: string | null; lastChatTypeId: string | null; generalSectionName: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; hasUnreadResponse: boolean; currentRequestId: string | null; currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null; currentJobMessage: string | null; currentQueueSize: number; currentStatusUpdatedAt: string | 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 ChatConversationRequestItem = { sessionId: string; requestId: string; status: ChatConversationRequestStatus; statusMessage: string | null; userMessageId: number | null; userText: string; responseMessageId: number | null; responseText: string; hasResponse: boolean; canDelete: boolean; createdAt: string; updatedAt: string; answeredAt: string | null; terminalAt: string | null; }; export type ChatConversationActivityLogItem = { sessionId: string; requestId: string; lines: string[]; updatedAt: 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; status: ChatConversationRequestStatus; userText: string; createdAt: string; }; 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; }; 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 createPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } 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'; } function buildConversationTitle(text: string) { const preview = createPreview(text); return preview || '새 대화'; } function mapConversationRow(row: Record): ChatConversationItem { return { sessionId: String(row.session_id ?? ''), clientId: row.client_id == null ? null : String(row.client_id), title: String(row.title ?? '새 대화'), 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), notifyOffline: Boolean(row.notify_offline), hasUnreadResponse: Boolean(row.has_unread_response), 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), 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); return { sessionId: String(row.session_id ?? ''), requestId: String(row.request_id ?? ''), 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 ?? ''), hasResponse, canDelete, createdAt: normalizeDateTimeValue(row.created_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', answeredAt: normalizeDateTimeValue(row.answered_at), terminalAt: normalizeDateTimeValue(row.terminal_at), }; } 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; 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?.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); } 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); if ( (item.status === 'queued' || item.status === 'started') && !item.hasResponse && !isConversationRequestActive(conversation, item.requestId) ) { return { ...item, status: 'failed' as const, statusMessage: item.statusMessage ?? '중단된 오래된 요청', canDelete: true, terminalAt: item.terminalAt ?? item.updatedAt, }; } 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, }) ) { 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; } } 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 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.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('새 대화')], ['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('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.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()], ['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('')], ['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); }); } } } 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() || '새 대화', 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; clientId?: 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 currentChatTypeId = String(current.chat_type_id ?? '').trim() || null; const requestedChatTypeId = payload.chatTypeId?.trim() || null; const nextChatTypeId = resolveNextConversationChatTypeId(currentChatTypeId, requestedChatTypeId); const requestedContextLabel = payload.contextLabel?.trim() || null; const requestedContextDescription = payload.contextDescription?.trim() || null; await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update({ title: payload.title?.trim() || current.title || '새 대화', client_id: normalizedClientId || current.client_id || null, chat_type_id: nextChatTypeId, last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null, general_section_name: resolveNextConversationContextValue(current.general_section_name, payload.generalSectionName), context_label: resolveNextConversationContextValue(current.context_label, requestedContextLabel), context_description: resolveNextConversationContextValue(current.context_description, requestedContextDescription), notify_offline: normalizedClientId == null && payload.notifyOffline != null ? payload.notifyOffline : Boolean(current.notify_offline), updated_at: db.fn.now(), }); 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 resolveNextConversationContextValue(currentValue?: string | null, requestedValue?: string | null) { const normalizedRequestedValue = String(requestedValue ?? '').trim() || null; const normalizedCurrentValue = String(currentValue ?? '').trim() || null; return normalizedRequestedValue ?? normalizedCurrentValue ?? 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); } } 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 latestResponseMessageIdMap = await getLatestResponseMessageIdMap( rows.map((row) => String(row.session_id ?? '')), ); if (!normalizedUnreadStateClientId) { return rows .map((row) => { const mapped = mapConversationRow(row); return { ...resolveConversationPreviewOverride( mapped, latestPreviewMessageMap.get(mapped.sessionId), latestRequestPreviewMap.get(mapped.sessionId), ), lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), hasUnreadResponse: false, }; }) .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 = mapConversationRow(row); const preference = preferenceMap.get(mapped.sessionId); const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId); return { ...resolveConversationPreviewOverride( mapped, latestPreviewMessage, latestRequestPreviewMap.get(mapped.sessionId), ), 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), }; }) .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)); } 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(); const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 6))); 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(); 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 getChatConversationRequest(sessionId: string, requestId: string) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); if (!normalizedSessionId || !normalizedRequestId) { return 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, }); const currentTitle = String(currentConversation?.title ?? '새 대화').trim() || '새 대화'; const nextTitle = message.author === 'user' && (!currentTitle || currentTitle === '새 대화') ? buildConversationTitle(message.text) : currentTitle; await db(CHAT_CONVERSATION_TABLE) .where({ session_id: conversation.sessionId }) .update({ client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null, title: nextTitle, 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, 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) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const normalizedLine = line.trim(); if (!normalizedSessionId || !normalizedRequestId || !normalizedLine) { return null; } const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .count<{ count?: string | number }>('id as count') .first(); const 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']) .ignore(); 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.status', 'request.user_text', 'request.created_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', ) .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, status: String(row.status ?? 'accepted') as ChatConversationRequestStatus, userText, createdAt, } satisfies RecoverableChatConversationRequestItem; }) .filter(Boolean) as RecoverableChatConversationRequestItem[]; } 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; status?: ChatConversationRequestStatus; statusMessage?: string | null; userMessageId?: number | null; userText?: string | null; responseMessageId?: number | null; responseText?: string | null; }, ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = payload.requestId.trim(); if (!normalizedSessionId || !normalizedRequestId) { return null; } let nextRow: | { session_id: string; request_id: string; status: ChatConversationRequestStatus; status_message: string | null; user_message_id: number | null; user_text: string; response_message_id: number | null; response_text: string; 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 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; nextRow = { session_id: normalizedSessionId, request_id: normalizedRequestId, status: nextStatus, status_message: payload.statusMessage?.trim() || 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 ?? '', 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); } return row ? mapRequestRow(row) : null; } 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 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) { return db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: sessionId.trim() }).del(); await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: sessionId.trim() }).del(); const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).del(); return deletedCount > 0; }); } 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; } export async function listChatConversationOfflineNotificationClientIds(sessionId: string) { 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(), }); 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(), }); return { sessionId: normalizedSessionId, lastReadResponseMessageId: latestResponseMessageId, }; }