feat: refresh shared chat and server workflows
This commit is contained in:
@@ -80,6 +80,7 @@ export type ChatConversationItem = {
|
||||
roomScope: Record<string, unknown> | null;
|
||||
notifyOffline: boolean;
|
||||
hasUnreadResponse: boolean;
|
||||
hasPendingAttention: boolean;
|
||||
currentRequestId: string | null;
|
||||
currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null;
|
||||
currentJobMessage: string | null;
|
||||
@@ -175,6 +176,14 @@ type ChatPromptSelectionPatch = {
|
||||
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 = {
|
||||
@@ -625,6 +634,7 @@ export function applyChatPromptSelectionPatch(
|
||||
resolvedBy: 'user',
|
||||
resolvedAt,
|
||||
resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null,
|
||||
attachments: Array.isArray(selection.attachments) ? selection.attachments : [],
|
||||
};
|
||||
|
||||
return nextParts;
|
||||
@@ -1184,6 +1194,161 @@ function resolvePendingWorkState(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function isPendingAttentionPromptPart(
|
||||
part: NonNullable<ChatMessagePart>,
|
||||
): part is Extract<ChatMessagePart, { type: 'prompt' }> {
|
||||
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<string, number>;
|
||||
}) {
|
||||
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<string, boolean>();
|
||||
}
|
||||
|
||||
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<string, ChatConversationRequestItem[]>();
|
||||
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<string, StoredChatMessage[]>();
|
||||
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<Map<string, boolean>>((result, sessionId) => {
|
||||
const requests = requestRowsBySession.get(sessionId) ?? [];
|
||||
const messages = messageRowsBySession.get(sessionId) ?? [];
|
||||
const childRequestCountByParentId = requests.reduce<Map<string, number>>((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<string, StoredChatMessage[]>>((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,
|
||||
@@ -1363,6 +1528,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
|
||||
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),
|
||||
@@ -2912,6 +3078,9 @@ export async function listChatConversations(
|
||||
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 ?? '')),
|
||||
);
|
||||
@@ -2942,6 +3111,7 @@ export async function listChatConversations(
|
||||
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) =>
|
||||
@@ -2993,6 +3163,7 @@ export async function listChatConversations(
|
||||
hasUnreadResponse:
|
||||
(latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) >
|
||||
(preference?.lastReadResponseMessageId ?? 0),
|
||||
hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
|
||||
};
|
||||
})
|
||||
.sort((left, right) =>
|
||||
@@ -4510,6 +4681,51 @@ export async function deleteUnansweredChatConversationRequest(sessionId: string,
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user