Files
ai-code-app/etc/servers/work-server/src/routes/chat.ts

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,
};
});
}