chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -9,6 +9,7 @@ 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({
@@ -17,8 +18,9 @@ const conversationPayloadSchema = z.object({
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(2000).nullable().optional(),
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).nullable().optional(),
notifyOffline: z.boolean().optional(),
});
@@ -38,6 +40,7 @@ export type ChatConversationItem = {
title: string;
chatTypeId: string | null;
lastChatTypeId: string | null;
generalSectionName: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
@@ -47,7 +50,9 @@ export type ChatConversationItem = {
currentJobMessage: string | null;
currentQueueSize: number;
currentStatusUpdatedAt: string | null;
lastRequestPreview: string;
lastMessagePreview: string;
lastResponsePreview: string;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
@@ -95,6 +100,22 @@ export type ChatConversationActivityLogItem = {
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[];
@@ -152,6 +173,75 @@ function createPreview(text: string) {
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();
@@ -179,6 +269,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
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),
@@ -188,7 +279,9 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
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),
@@ -322,6 +415,30 @@ function isConversationRequestActive(
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: {
@@ -626,24 +743,81 @@ async function getLatestRequestPreviewMap(sessionIds: string[]) {
.orderBy('request_id', 'desc');
const requestMap = new Map<string, { text: string; createdAt: string | null }>();
const requestRowsBySession = new Map<string, Array<{ text: string; createdAt: string | null }>>();
const completedSessionIds = new Set<string>();
for (const row of rows) {
const sessionId = String(row.session_id ?? '').trim();
const userText = String(row.user_text ?? '').trim();
if (!sessionId || requestMap.has(sessionId) || !userText) {
if (!sessionId || completedSessionIds.has(sessionId) || !userText) {
continue;
}
requestMap.set(sessionId, {
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<string, { text: string; createdAt: string | null }>();
}
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<string, { text: string; createdAt: string | null }>();
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,
@@ -717,6 +891,7 @@ export async function ensureChatConversationTables() {
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);
@@ -737,6 +912,7 @@ export async function ensureChatConversationTables() {
['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)],
@@ -1024,6 +1200,7 @@ export async function createChatConversation(payload: z.input<typeof conversatio
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,
@@ -1064,6 +1241,7 @@ export async function updateChatConversationContext(
clientId?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean | null;
@@ -1089,6 +1267,7 @@ export async function updateChatConversationContext(
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:
@@ -1235,12 +1414,33 @@ export async function listChatConversations(
}
}
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 ?? '')),
);
@@ -1255,6 +1455,8 @@ export async function listChatConversations(
latestPreviewMessageMap.get(mapped.sessionId),
latestRequestPreviewMap.get(mapped.sessionId),
),
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
hasUnreadResponse: false,
};
})
@@ -1294,6 +1496,8 @@ export async function listChatConversations(
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:
@@ -1781,6 +1985,71 @@ export async function listChatConversationActivityLogs(
return requestIds.map((requestId) => activityMap.get(requestId)).filter(Boolean) as ChatConversationActivityLogItem[];
}
export async function listRecoverableChatConversationRequests(): Promise<RecoverableChatConversationRequestItem[]> {
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: {