494 lines
15 KiB
TypeScript
Executable File
494 lines
15 KiB
TypeScript
Executable File
import { randomUUID } from 'node:crypto';
|
|
import { createReadStream } from 'node:fs';
|
|
import { access, mkdir, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { env } from '../config/env.js';
|
|
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
|
import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
|
|
import {
|
|
createChatConversation,
|
|
deleteUnansweredChatConversationRequest,
|
|
deleteChatConversation,
|
|
ensureChatConversationTables,
|
|
getChatConversation,
|
|
listChatConversationDetailPage,
|
|
listChatConversations,
|
|
markChatConversationResponsesRead,
|
|
updateChatConversationContext,
|
|
} from '../services/chat-room-service.js';
|
|
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
|
|
|
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
|
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
|
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
|
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
|
|
|
function resolveStaticContentType(filePath: string) {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
|
|
switch (extension) {
|
|
case '.ts':
|
|
case '.tsx':
|
|
case '.js':
|
|
case '.jsx':
|
|
case '.mjs':
|
|
case '.cjs':
|
|
case '.json':
|
|
case '.css':
|
|
case '.html':
|
|
case '.md':
|
|
case '.txt':
|
|
case '.diff':
|
|
return 'text/plain; charset=utf-8';
|
|
case '.svg':
|
|
return 'image/svg+xml';
|
|
case '.png':
|
|
return 'image/png';
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
return 'image/jpeg';
|
|
case '.gif':
|
|
return 'image/gif';
|
|
case '.webp':
|
|
return 'image/webp';
|
|
case '.ico':
|
|
return 'image/x-icon';
|
|
case '.pdf':
|
|
return 'application/pdf';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
function buildChatResourcePublicUrl(relativePath: string) {
|
|
const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
|
|
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join('/')}`;
|
|
}
|
|
|
|
function normalizeChatResourceWildcard(wildcard: string) {
|
|
const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, '');
|
|
|
|
if (!cleaned) {
|
|
return '';
|
|
}
|
|
|
|
if (cleaned.startsWith('.codex_chat/')) {
|
|
return cleaned;
|
|
}
|
|
|
|
return path.posix.join('.codex_chat', cleaned);
|
|
}
|
|
|
|
async function serveChatPublicResource(
|
|
repoPath: string,
|
|
wildcard: string,
|
|
reply: FastifyReply,
|
|
) {
|
|
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
|
|
|
|
if (!requestedRelativePath) {
|
|
return reply.code(404).send({
|
|
message: '채팅 리소스를 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
const publicRoot = path.join(repoPath, 'public');
|
|
const absolutePath = path.resolve(publicRoot, requestedRelativePath);
|
|
|
|
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
|
|
return reply.code(403).send({
|
|
message: '허용되지 않은 경로입니다.',
|
|
});
|
|
}
|
|
|
|
try {
|
|
await access(absolutePath);
|
|
const fileStat = await stat(absolutePath);
|
|
|
|
if (!fileStat.isFile()) {
|
|
return reply.code(404).send({
|
|
message: '채팅 리소스를 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
} catch {
|
|
return reply.code(404).send({
|
|
message: '채팅 리소스를 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
reply.header('Cache-Control', 'no-store');
|
|
reply.type(resolveStaticContentType(absolutePath));
|
|
return reply.send(createReadStream(absolutePath));
|
|
}
|
|
|
|
function sanitizeChatAttachmentFileName(fileName: string) {
|
|
const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' ');
|
|
const compact = normalized || 'attachment';
|
|
return compact.length > 120 ? compact.slice(-120) : compact;
|
|
}
|
|
|
|
function resolveChatAttachmentRepoPath() {
|
|
return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH);
|
|
}
|
|
|
|
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
|
const raw = request.headers['x-client-id'];
|
|
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
|
}
|
|
|
|
function canViewAllConversations(request: { headers: Record<string, unknown> }) {
|
|
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
|
|
}
|
|
|
|
export async function registerChatRoutes(app: FastifyInstance) {
|
|
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
|
|
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
|
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
|
});
|
|
|
|
app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => {
|
|
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
|
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
|
});
|
|
|
|
app.get('/api/chat/setup', async () => {
|
|
await ensureChatConversationTables();
|
|
|
|
return {
|
|
ok: true,
|
|
tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'],
|
|
};
|
|
});
|
|
|
|
app.get('/api/chat/conversations', async (request) => {
|
|
const query = z.object({
|
|
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
}).parse(request.query ?? {});
|
|
|
|
const viewerClientId = getClientIdHeader(request);
|
|
const clientId = canViewAllConversations(request) ? null : viewerClientId;
|
|
const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null);
|
|
|
|
return {
|
|
ok: true,
|
|
items,
|
|
};
|
|
});
|
|
|
|
app.get('/api/chat/runtime', async () => {
|
|
return {
|
|
ok: true,
|
|
item: chatRuntimeService.getSnapshot(),
|
|
};
|
|
});
|
|
|
|
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
|
|
const payload = z.object({
|
|
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
|
fileName: z.string().trim().min(1).max(255),
|
|
mimeType: z.string().trim().max(200).optional(),
|
|
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
|
}).parse(request.body ?? {});
|
|
|
|
const buffer = Buffer.from(payload.contentBase64, 'base64');
|
|
|
|
if (buffer.byteLength === 0) {
|
|
return reply.code(400).send({
|
|
message: '업로드할 파일 내용을 찾지 못했습니다.',
|
|
});
|
|
}
|
|
|
|
if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) {
|
|
return reply.code(413).send({
|
|
message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const safeFileName = sanitizeChatAttachmentFileName(payload.fileName);
|
|
const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
const relativePath = path.posix.join(
|
|
'public',
|
|
'.codex_chat',
|
|
payload.sessionId,
|
|
'resource',
|
|
'uploads',
|
|
`${fileToken}-${safeFileName}`,
|
|
);
|
|
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
|
|
|
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
await writeFile(absolutePath, buffer);
|
|
|
|
return {
|
|
ok: true,
|
|
item: {
|
|
id: randomUUID(),
|
|
name: payload.fileName,
|
|
path: relativePath,
|
|
publicUrl: buildChatResourcePublicUrl(relativePath),
|
|
size: buffer.byteLength,
|
|
mimeType: payload.mimeType?.trim() || 'application/octet-stream',
|
|
},
|
|
};
|
|
});
|
|
|
|
app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => {
|
|
const params = z.object({
|
|
requestId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
const controller = getChatRuntimeController();
|
|
|
|
if (!controller) {
|
|
return reply.code(503).send({
|
|
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
item: controller.getJobDetail(params.requestId),
|
|
};
|
|
});
|
|
|
|
app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => {
|
|
const params = z.object({
|
|
requestId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
const controller = getChatRuntimeController();
|
|
|
|
if (!controller) {
|
|
return reply.code(503).send({
|
|
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
|
});
|
|
}
|
|
|
|
const cancelled = await controller.cancelJob(params.requestId);
|
|
|
|
if (!cancelled) {
|
|
return reply.code(404).send({
|
|
message: '취소할 실행 중 요청을 찾지 못했습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
cancelled: true,
|
|
};
|
|
});
|
|
|
|
app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => {
|
|
const params = z.object({
|
|
requestId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
const controller = getChatRuntimeController();
|
|
|
|
if (!controller) {
|
|
return reply.code(503).send({
|
|
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
|
});
|
|
}
|
|
|
|
const removed = await controller.removeQueuedJob(params.requestId);
|
|
|
|
if (!removed) {
|
|
return reply.code(404).send({
|
|
message: '제거할 대기 요청을 찾지 못했습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
removed: true,
|
|
};
|
|
});
|
|
|
|
app.post('/api/chat/conversations', async (request) => {
|
|
const payload = z.object({
|
|
sessionId: z.string().trim().min(1).max(120),
|
|
title: z.string().trim().max(200).optional(),
|
|
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
|
contextLabel: z.string().trim().max(200).optional(),
|
|
contextDescription: z.string().trim().max(2000).optional(),
|
|
notifyOffline: z.boolean().optional(),
|
|
}).parse(request.body ?? {});
|
|
|
|
const clientId = getClientIdHeader(request);
|
|
const item = await createChatConversation({
|
|
sessionId: payload.sessionId,
|
|
clientId: clientId || null,
|
|
title: payload.title ?? '새 대화',
|
|
chatTypeId: payload.chatTypeId ?? null,
|
|
contextLabel: payload.contextLabel ?? null,
|
|
contextDescription: payload.contextDescription ?? null,
|
|
notifyOffline: payload.notifyOffline ?? true,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
item,
|
|
};
|
|
});
|
|
|
|
app.get('/api/chat/conversations/:sessionId', async (request, reply) => {
|
|
const params = z.object({
|
|
sessionId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
const query = z.object({
|
|
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
beforeMessageId: z.coerce.number().int().positive().optional(),
|
|
}).parse(request.query ?? {});
|
|
|
|
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
|
const item = await getChatConversation(params.sessionId, clientId || null);
|
|
|
|
if (!item) {
|
|
return reply.code(404).send({
|
|
message: '채팅방을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
const messageLimit = query.limit ?? 6;
|
|
const detailPage = await listChatConversationDetailPage(params.sessionId, {
|
|
limit: messageLimit,
|
|
beforeMessageId: query.beforeMessageId ?? null,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
item,
|
|
messages: detailPage.messages,
|
|
requests: detailPage.requests,
|
|
activityLogs: detailPage.activityLogs,
|
|
oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
|
|
hasOlderMessages: detailPage.hasOlderMessages,
|
|
};
|
|
});
|
|
|
|
app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => {
|
|
const params = z.object({
|
|
sessionId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
|
|
const clientId = getClientIdHeader(request);
|
|
|
|
if (!clientId) {
|
|
return reply.code(400).send({
|
|
message: '읽음 처리를 위한 clientId가 필요합니다.',
|
|
});
|
|
}
|
|
|
|
const result = await markChatConversationResponsesRead(params.sessionId, clientId);
|
|
|
|
if (!result) {
|
|
return reply.code(404).send({
|
|
message: '읽음 처리할 채팅방을 찾지 못했습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
};
|
|
});
|
|
|
|
app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => {
|
|
const params = z.object({
|
|
sessionId: z.string().trim().min(1).max(120),
|
|
requestId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
|
|
const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId);
|
|
|
|
if (!result.deleted) {
|
|
if (result.reason === 'not_found') {
|
|
return reply.code(404).send({
|
|
message: '삭제할 요청을 찾지 못했습니다.',
|
|
});
|
|
}
|
|
|
|
if (result.reason === 'answered') {
|
|
return reply.code(409).send({
|
|
message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return reply.code(409).send({
|
|
message: '현재 처리 중인 요청은 삭제할 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
deleted: true,
|
|
sessionId: params.sessionId,
|
|
requestId: params.requestId,
|
|
};
|
|
});
|
|
|
|
app.patch('/api/chat/conversations/:sessionId', async (request, reply) => {
|
|
const params = z.object({
|
|
sessionId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
const payload = z.object({
|
|
title: z.string().trim().min(1).max(200).optional(),
|
|
chatTypeId: z.string().trim().max(120).optional().nullable(),
|
|
contextLabel: z.string().trim().max(200).optional().nullable(),
|
|
contextDescription: z.string().trim().max(2000).optional().nullable(),
|
|
notifyOffline: z.boolean().optional(),
|
|
}).parse(request.body ?? {});
|
|
|
|
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
|
const current = await getChatConversation(params.sessionId, clientId || null);
|
|
|
|
if (!current) {
|
|
return reply.code(404).send({
|
|
message: '수정할 채팅방을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
const item = await updateChatConversationContext(params.sessionId, {
|
|
title: payload.title ?? current.title,
|
|
clientId: current.clientId,
|
|
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
|
|
contextLabel: payload.contextLabel ?? current.contextLabel,
|
|
contextDescription: payload.contextDescription ?? current.contextDescription,
|
|
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
item,
|
|
};
|
|
});
|
|
|
|
app.delete('/api/chat/conversations/:sessionId', async (request, reply) => {
|
|
const params = z.object({
|
|
sessionId: z.string().trim().min(1).max(120),
|
|
}).parse(request.params ?? {});
|
|
|
|
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
|
const current = await getChatConversation(params.sessionId, clientId || null);
|
|
|
|
if (!current) {
|
|
return reply.code(404).send({
|
|
message: '삭제할 채팅방을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
await getActiveChatService()?.forgetSession(params.sessionId);
|
|
const deleted = await deleteChatConversation(params.sessionId);
|
|
|
|
return {
|
|
ok: true,
|
|
deleted,
|
|
sessionId: params.sessionId,
|
|
};
|
|
});
|
|
}
|