feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

View File

@@ -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();