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

@@ -23,9 +23,11 @@ import {
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
assignSharedResourceTokenToRequests,
appendChatConversationActivityLine,
appendChatConversationMessage,
buildChatPromptTargetSignature,
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
cancelUnansweredChatConversationRequest,
clearChatConversationData,
createChatConversation,
deleteUnansweredChatConversationRequest,
@@ -90,6 +92,15 @@ const chatPromptContextRefSchema = z
.optional()
.nullable();
const chatComposerAttachmentSchema = z.object({
id: z.string().trim().min(1).max(240),
name: z.string().trim().min(1).max(500),
path: z.string().trim().min(1).max(4000),
publicUrl: z.string().trim().min(1).max(4000),
size: z.number().finite().min(0).max(CHAT_ATTACHMENT_FILE_SIZE_LIMIT),
mimeType: z.string().trim().min(1).max(255),
});
async function findExistingActivePromptFollowupRequest(
sessionId: string,
parentRequestId: string,
@@ -244,6 +255,127 @@ function createManagedChatShareMessageIds() {
};
}
function sortShareMessages(messages: ListedChatConversationMessage[]) {
return [...messages].sort((left, right) => {
if (left.id !== right.id) {
return left.id - right.id;
}
return left.timestamp.localeCompare(right.timestamp);
});
}
function sortShareRequests(requests: ListedChatConversationRequest[]) {
return [...requests].sort((left, right) => {
const createdAtDiff = left.createdAt.localeCompare(right.createdAt);
if (createdAtDiff !== 0) {
return createdAtDiff;
}
return left.requestId.localeCompare(right.requestId);
});
}
async function materializeManagedShareConversation(args: {
shareSnapshot: NonNullable<Awaited<ReturnType<typeof buildChatShareSnapshot>>>;
managedResourceTokenId: string;
ownerClientId: string | null;
shareTitle: string;
}) {
const { shareSnapshot, managedResourceTokenId, ownerClientId, shareTitle } = args;
const sessionId = createManagedChatShareSessionId();
const requestIdSet = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean));
const sortedRequests = sortShareRequests(shareSnapshot.requests);
const sortedMessages = sortShareMessages(shareSnapshot.messages);
const sourceConversation = shareSnapshot.conversation;
await createChatConversation({
sessionId,
clientId: ownerClientId,
title: shareTitle,
draftText: '',
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
codexModel: sourceConversation?.codexModel ?? null,
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
generalSectionName: sourceConversation?.generalSectionName ?? null,
contextLabel: sourceConversation?.contextLabel ?? null,
contextDescription: sourceConversation?.contextDescription ?? null,
notifyOffline: true,
});
for (const message of sortedMessages) {
await appendChatConversationMessage(
{
sessionId,
clientId: ownerClientId,
title: shareTitle,
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
codexModel: sourceConversation?.codexModel ?? null,
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
generalSectionName: sourceConversation?.generalSectionName ?? null,
contextLabel: sourceConversation?.contextLabel ?? null,
contextDescription: sourceConversation?.contextDescription ?? null,
notifyOffline: true,
},
{
sessionId,
messageId: message.id,
author: message.author,
text: message.text,
timestamp: message.timestamp,
clientRequestId: message.clientRequestId?.trim() || null,
parts: message.parts ?? [],
},
);
}
for (const request of sortedRequests) {
const normalizedParentRequestId = request.parentRequestId?.trim() || '';
await upsertChatConversationRequest(sessionId, {
requestId: request.requestId,
requesterClientId: request.requesterClientId ?? ownerClientId,
chatTypeId: request.chatTypeId ?? sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
chatTypeLabel: request.chatTypeLabel ?? sourceConversation?.contextLabel ?? null,
requestOrigin: request.requestOrigin,
sharedResourceTokenId: managedResourceTokenId,
parentRequestId: normalizedParentRequestId && requestIdSet.has(normalizedParentRequestId) ? normalizedParentRequestId : null,
status: request.status,
statusMessage: request.statusMessage,
userMessageId: request.userMessageId,
userText: request.userText,
responseMessageId: request.responseMessageId,
responseText: request.responseText,
usageSnapshot: request.usageSnapshot,
totalTokens: request.totalTokens,
});
}
for (const activityLog of shareSnapshot.activityLogs) {
let lineNo = 1;
for (const line of activityLog.lines) {
await appendChatConversationActivityLine(sessionId, activityLog.requestId, line, lineNo);
lineNo += 1;
}
}
await assignSharedResourceTokenToRequests(
sessionId,
sortedRequests.map((item) => item.requestId),
managedResourceTokenId,
);
return {
sessionId,
requestId:
sortedRequests.find((item) => item.requestId.trim() === shareSnapshot.targetRequest.requestId.trim())?.requestId
?? shareSnapshot.targetRequest.requestId,
};
}
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
@@ -1546,6 +1678,30 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const shareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유할 채팅 범위를 찾을 수 없습니다.',
});
}
if (payload.kind === 'prompt') {
if (payload.promptIndex == null || !payload.promptSignature) {
return reply.code(400).send({
@@ -1553,25 +1709,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const shareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (!shareSnapshot?.promptTarget) {
if (!shareSnapshot.promptTarget) {
return reply.code(404).send({
message: '공유할 prompt 대상을 찾을 수 없습니다.',
});
@@ -1581,6 +1719,12 @@ export async function registerChatRoutes(app: FastifyInstance) {
const managedResourceTokenId = createManagedChatShareTokenId();
const token = randomUUID().replace(/-/g, '').slice(0, 24);
const sharePath = resolveChatSharePath(token);
const managedShareConversation = await materializeManagedShareConversation({
shareSnapshot,
managedResourceTokenId,
ownerClientId: clientId || null,
shareTitle: payload.name,
});
await upsertSharedResourceToken({
id: managedResourceTokenId,
@@ -1599,8 +1743,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
},
resourceContext: {
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
sessionId: managedShareConversation.sessionId,
requestId: managedShareConversation.requestId,
sourceMessageId: payload.sourceMessageId ?? null,
promptIndex: payload.promptIndex ?? null,
promptSignature: payload.promptSignature ?? null,
@@ -1620,33 +1764,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
usageLimit: 0,
});
const createdShareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
managedResourceTokenId,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (createdShareSnapshot?.requests.length) {
await assignSharedResourceTokenToRequests(
payload.sessionId,
createdShareSnapshot.requests.map((item) => item.requestId),
managedResourceTokenId,
);
}
return {
ok: true,
token,
@@ -2060,6 +2177,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
followupText: z.string().trim().min(1).max(20000),
contextRef: chatPromptContextRefSchema,
}).parse(request.body ?? {});
@@ -2265,6 +2383,237 @@ export async function registerChatRoutes(app: FastifyInstance) {
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-cancel`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
parentRequestId: z.string().trim().min(1).max(120),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 요청 취소 처리 권한이 없습니다.',
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
if (
!targetRequest
|| targetRequest.hasResponse
|| targetRequest.status !== 'failed'
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
) {
return reply.code(409).send({
message: '지금은 이 요청을 취소 처리할 수 없습니다.',
});
}
const result = await cancelUnansweredChatConversationRequest(
tokenPayload.sessionId,
normalizedParentRequestId,
'사용자 요청으로 중단된 요청을 취소 처리했습니다.',
);
if (!result.cancelled || !result.item) {
if (result.reason === 'answered') {
return reply.code(409).send({
message: '이미 답변이 연결된 요청은 취소 처리할 수 없습니다.',
});
}
if (result.reason === 'active') {
return reply.code(409).send({
message: '현재 처리 중인 요청은 여기서 취소 처리할 수 없습니다.',
});
}
if (result.reason === 'already_terminal') {
return reply.code(409).send({
message: '이미 취소 처리된 요청입니다.',
});
}
return reply.code(404).send({
message: '취소 처리할 요청을 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, result.item);
return {
ok: true,
item: result.item,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-retry`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
parentRequestId: z.string().trim().min(1).max(120),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 요청 재처리 권한이 없습니다.',
});
}
const blockedReason = resolveShareBlockedReason(
shareSnapshot.requests,
resolveChatShareTokenSettingSnapshot(tokenPayload),
);
if (blockedReason) {
return reply.code(409).send({
message: blockedReason,
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
if (
!targetRequest
|| targetRequest.hasResponse
|| targetRequest.status !== 'failed'
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
) {
return reply.code(409).send({
message: '지금은 이 요청을 재처리할 수 없습니다.',
});
}
const normalizedUserText = targetRequest.userText.trim();
if (!normalizedUserText) {
return reply.code(409).send({
message: '재처리할 요청 본문이 없습니다.',
});
}
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, {
mode: 'direct',
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null,
clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
});
if (!queuedRequestId) {
return reply.code(503).send({
message: '중단된 요청 재처리를 시작하지 못했습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅에서 중단 요청 재처리를 시작했습니다.',
detail: normalizedParentRequestId,
});
}
return {
ok: true,
queuedRequestId,
};
});
app.get('/api/chat/conversations', async (request) => {
const query = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
@@ -2622,6 +2971,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
}).parse(request.body ?? {});
if (params.requestId !== payload.parentRequestId) {
@@ -2675,6 +3025,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
followupText: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
contextRef: chatPromptContextRefSchema,