import type { ChatMessagePart } from './types'; const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i; const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i; const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\((.+)\)\s*$/; const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i; const PROMPT_BLOCK_START_PATTERN = /^\s*\[\[prompt:\s*$/i; const PROMPT_BLOCK_END_PATTERN = /^\s*\]\]\s*$/; const PROMPT_CODE_BLOCK_START_PATTERN = /^\s*```(?:json|prompt)(?:\s+prompt)?\s*$/i; const CODE_BLOCK_END_PATTERN = /^\s*```\s*$/; const CODE_FENCE_TOGGLE_PATTERN = /^\s*```/; const ATTACHMENT_SECTION_TITLE_PATTERN = /^\s*첨부\s*파일\s*:?\s*$/i; const ATTACHMENT_ENTRY_PATTERN = /^\s*-\s+(.+?)\s*:\s*(.+?)\s*$/; const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const; const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/'; const CHAT_DOT_CODEX_MARKER = '/.codex_chat/'; const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/'; const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/'; const RESOURCE_MANAGER_ROOT_MARKER = 'resource/'; type PromptPart = Extract; type PromptOption = PromptPart['options'][number]; type PromptPreview = NonNullable; type PromptStep = NonNullable[number]; function normalizeText(value: unknown) { return String(value ?? '').trim(); } function unwrapMarkdownLinkTarget(value: string) { const normalized = normalizeText(value); if (!normalized) { return ''; } const matched = normalized.match(/^<([\s\S]+)>$/); return matched?.[1]?.trim() ?? normalized; } function normalizeResourceManagerPathSegment(segment: string) { const normalized = normalizeText(segment); if (!normalized) { return ''; } try { return encodeURIComponent(decodeURIComponent(normalized)); } catch { return encodeURIComponent(normalized); } } function buildResourceManagerPreviewUrl(value: string) { const normalized = normalizeText(value).replace(/\\/g, '/'); const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1]; const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, ''); if (!resourcePath) { return ''; } const relativePath = resourcePath.slice(RESOURCE_MANAGER_ROOT_MARKER.length).replace(/^\/+/, ''); if (!relativePath) { return ''; } const encodedPath = relativePath .split('/') .filter(Boolean) .map((segment) => normalizeResourceManagerPathSegment(segment)) .join('/'); return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : ''; } function extractKnownPreviewPath(value: string) { const normalized = normalizeText(value); if (!normalized) { return ''; } try { const parsed = new URL(normalized); const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`; if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) { return pathname; } return normalized; } catch { return ''; } } function normalizeUrl(value: string) { const normalized = unwrapMarkdownLinkTarget(value); if (!normalized) { return ''; } const knownPreviewPath = extractKnownPreviewPath(normalized); if (knownPreviewPath) { return knownPreviewPath; } const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i); if (malformedResourceMatch?.[1]) { return `/${malformedResourceMatch[1]}`; } const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER); if (apiMarkerIndex >= 0) { const apiPath = normalized.slice(apiMarkerIndex); const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER); return dotCodexIndex >= 0 ? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}` : apiPath; } const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER); if (publicDotCodexIndex >= 0) { return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`; } const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER); if (dotCodexIndex >= 0) { return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`; } if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) { const resourceManagerPreviewUrl = buildResourceManagerPreviewUrl(normalized); if (resourceManagerPreviewUrl) { return resourceManagerPreviewUrl; } } if (/^(?:https?:\/\/|\/)/i.test(normalized)) { return normalized; } return ''; } function hasKnownFileExtension(url: string) { const pathname = url.split('?')[0] ?? ''; return /\.[a-z0-9]{1,8}$/i.test(pathname); } function isStructuredLinkCardCandidate(url: string) { const normalized = normalizeUrl(url); if (!normalized) { return false; } if (isInternalResourceUrl(normalized)) { return false; } return /^https?:\/\//i.test(normalized) && !hasKnownFileExtension(normalized); } function buildFallbackLinkTitle(url: string) { try { const parsed = new URL(url, 'https://local.invalid'); const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim(); return lastSegment || parsed.hostname || normalizeText(url); } catch { return normalizeText(url); } } function normalizeStandaloneTitle(value: string) { return value .replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '') .replace(/[`'"]+/g, '') .replace(/\s+/g, ' ') .trim(); } function resolveStandaloneLinkTitle(keptLines: string[], url: string) { for (let index = keptLines.length - 1; index >= 0; index -= 1) { const candidate = normalizeStandaloneTitle(keptLines[index] ?? ''); if (candidate) { return candidate; } } return buildFallbackLinkTitle(url); } function normalizePromptPreview(value: unknown): PromptPreview | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const record = value as Record; const type = record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource' ? record.type : null; const url = normalizeUrl(normalizeText(record.url)); const content = String(record.content ?? '').trim() || null; const alt = normalizeText(record.alt) || null; const title = normalizeText(record.title) || null; if (!type) { return null; } if (type === 'image' || type === 'resource') { if (!url) { return null; } } else if (!content && !url) { return null; } return { type, url: url || null, content, alt, title, }; } function isPromptOption(value: PromptOption | null): value is PromptOption { return value != null; } function normalizePromptOption(value: unknown): PromptOption | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const record = value as Record; const optionValue = normalizeText(record.value); const label = normalizeText(record.label); if (!optionValue || !label) { return null; } return { value: optionValue, label, description: normalizeText(record.description) || null, preview: normalizePromptPreview(record.preview), }; } function normalizePromptSelectedValues(value: unknown) { return [ ...(Array.isArray(value) ? value : []), ] .map((item) => normalizeText(item)) .filter(Boolean) .filter((item, index, array) => array.indexOf(item) === index); } function normalizePromptAttachment(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const record = value as Record; const id = normalizeText(record.id); const name = normalizeText(record.name); const path = normalizeText(record.path); const publicUrl = normalizeText(record.publicUrl); const size = Number(record.size); const mimeType = normalizeText(record.mimeType); if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) { return null; } return { id, name, path, publicUrl, size, mimeType, }; } function normalizePromptAttachments(value: unknown) { if (!Array.isArray(value)) { return []; } const seen = new Set(); return value .map((item) => normalizePromptAttachment(item)) .filter((item): item is NonNullable[number] => Boolean(item)) .filter((item) => { if (seen.has(item.id)) { return false; } seen.add(item.id); return true; }); } function normalizePromptSteps(value: unknown): PromptStep[] { if (!Array.isArray(value)) { return []; } return value.flatMap((item, index) => { if (!item || typeof item !== 'object' || Array.isArray(item)) { return []; } const record = item as Record; const key = normalizeText(record.key) || `step-${index + 1}`; const title = normalizeText(record.title); const options = Array.isArray(record.options) ? record.options.map((option) => normalizePromptOption(option)).filter(isPromptOption) : []; if (!title || options.length === 0) { return []; } return [ { key, title, description: normalizeText(record.description) || null, submitLabel: normalizeText(record.submitLabel) || null, mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null, multiple: record.multiple === true, optional: record.optional === true, responseTemplate: normalizeText(record.responseTemplate) || null, freeTextLabel: normalizeText(record.freeTextLabel) || null, freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null, selectedValues: normalizePromptSelectedValues(record.selectedValues), options, }, ]; }); } function decodeUrlComponentSafely(value: string) { try { return decodeURIComponent(value); } catch { return value; } } function resolveLinkCardUrlAndActionLabel(rawUrl: string, rawActionLabel?: string) { let resolvedUrl = normalizeText(rawUrl); let resolvedActionLabel = normalizeText(rawActionLabel); if (!resolvedActionLabel) { const decodedUrl = decodeUrlComponentSafely(resolvedUrl); const dividerIndex = decodedUrl.lastIndexOf('|'); if (dividerIndex > 0 && dividerIndex < decodedUrl.length - 1) { resolvedUrl = decodedUrl.slice(0, dividerIndex).trim(); resolvedActionLabel = decodedUrl.slice(dividerIndex + 1).trim(); } } return { url: normalizeUrl(resolvedUrl), actionLabel: resolvedActionLabel || null, }; } function isInternalResourceUrl(url: string) { return RESOURCE_PATH_PREFIXES.some((prefix) => url.startsWith(prefix)); } function buildLinkCardPart(rawBody: string): ChatMessagePart | null { const segments = rawBody .split('|') .map((segment) => segment.trim()) .filter(Boolean); if (segments.length < 2) { return null; } const [rawTitle, rawUrl, rawActionLabel] = segments; const title = normalizeText(rawTitle); const { url, actionLabel } = resolveLinkCardUrlAndActionLabel(rawUrl, rawActionLabel); if (!title || !url) { return null; } return { type: 'link_card', title, url, actionLabel, }; } function buildPromptPart(rawBody: string): ChatMessagePart | null { let parsed: unknown; try { parsed = JSON.parse(rawBody); } catch { return null; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null; } const record = parsed as Record; const title = normalizeText(record.title); const options = Array.isArray(record.options) ? record.options.map((item) => normalizePromptOption(item)).filter(isPromptOption) : []; const steps = normalizePromptSteps(record.steps); if (!title || (options.length === 0 && steps.length === 0)) { return null; } const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null; const selectedValues = [ ...normalizePromptSelectedValues(record.selectedValues), ...(record.selectedValue != null ? [record.selectedValue] : []), ...steps.flatMap((step) => step.selectedValues ?? []), ] .map((item) => normalizeText(item)) .filter(Boolean) .filter((value, index, values) => values.indexOf(value) === index); const resolvedBy = record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system' ? record.resolvedBy : null; return { type: 'prompt', title, description: normalizeText(record.description) || null, submitLabel: normalizeText(record.submitLabel) || null, mode, multiple: record.multiple === true, responseTemplate: normalizeText(record.responseTemplate) || null, freeTextLabel: normalizeText(record.freeTextLabel) || null, freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null, currentStepKey: normalizeText(record.currentStepKey) || null, steps: steps.length > 0 ? steps : undefined, readOnly: record.readOnly === true || resolvedBy != null, selectedValues, resolvedBy, resolvedAt: normalizeText(record.resolvedAt) || null, resultText: normalizeText(record.resultText) || null, attachments: normalizePromptAttachments(record.attachments), options, }; } function buildPromptPartFromBlock(rawBody: string) { const trimmed = rawBody.trim(); if (!trimmed) { return null; } const promptWrapperMatched = trimmed.match(/^\[\[prompt:\s*([\s\S]*?)\s*\]\]$/i); return buildPromptPart(promptWrapperMatched?.[1] ?? trimmed); } function extractAttachmentEntryUrl(rawLine: string) { const matched = rawLine.match(ATTACHMENT_ENTRY_PATTERN); if (!matched) { return ''; } const resolvedUrl = normalizeUrl(matched[2] ?? ''); return resolvedUrl || ''; } export function extractAttachmentPreviewUrls(text: string) { const lines = String(text ?? '').split('\n'); const keptLines: string[] = []; const urls: string[] = []; const seen = new Set(); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ''; if (!ATTACHMENT_SECTION_TITLE_PATTERN.test(line)) { keptLines.push(line); continue; } const attachmentUrls: string[] = []; let cursor = index + 1; while (cursor < lines.length) { const nextLine = lines[cursor] ?? ''; if (!nextLine.trim()) { cursor += 1; continue; } const attachmentUrl = extractAttachmentEntryUrl(nextLine); if (!attachmentUrl) { break; } attachmentUrls.push(attachmentUrl); cursor += 1; } if (attachmentUrls.length === 0) { keptLines.push(line); continue; } attachmentUrls.forEach((url) => { if (seen.has(url)) { return; } seen.add(url); urls.push(url); }); index = cursor - 1; } return { strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), urls, }; } export function extractChatMessageParts(text: string) { const attachmentExtraction = extractAttachmentPreviewUrls(text); const lines = String(attachmentExtraction.strippedText ?? '').split('\n'); const keptLines: string[] = []; const parts: ChatMessagePart[] = []; const seenLinkKeys = new Set(); let isInsideCodeFence = false; const pushPart = (nextPart: ChatMessagePart | null) => { if (!nextPart) { return false; } const dedupeKey = nextPart.type === 'link_card' ? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}` : [ nextPart.type, nextPart.title, nextPart.options .map((option) => [ option.value, option.label, option.preview?.type ?? '', option.preview?.url ?? '', option.preview?.content ?? '', option.preview?.title ?? '', ].join('|'), ) .join(','), (nextPart.steps ?? []) .map((step) => [ step.key, step.title, step.options.map((option) => `${option.value}:${option.label}`).join(','), ].join('|'), ) .join(','), nextPart.selectedValues?.join(',') ?? '', nextPart.resolvedBy ?? '', nextPart.resultText ?? '', nextPart.readOnly === true ? 'readonly' : '', ].join(':'); if (seenLinkKeys.has(dedupeKey)) { return true; } seenLinkKeys.add(dedupeKey); parts.push(nextPart); return true; }; for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const line = lines[lineIndex] ?? ''; if (PROMPT_CODE_BLOCK_START_PATTERN.test(line)) { const fencedLines = [line]; const jsonBodyLines: string[] = []; let cursor = lineIndex + 1; let foundFenceEnd = false; for (; cursor < lines.length; cursor += 1) { const nextLine = lines[cursor] ?? ''; fencedLines.push(nextLine); if (CODE_BLOCK_END_PATTERN.test(nextLine)) { foundFenceEnd = true; break; } jsonBodyLines.push(nextLine); } if (foundFenceEnd && pushPart(buildPromptPartFromBlock(jsonBodyLines.join('\n')))) { lineIndex = cursor; continue; } keptLines.push(...fencedLines); lineIndex = foundFenceEnd ? cursor : lines.length; continue; } if (CODE_FENCE_TOGGLE_PATTERN.test(line)) { keptLines.push(line); isInsideCodeFence = !isInsideCodeFence; continue; } if (isInsideCodeFence) { keptLines.push(line); continue; } const promptMatched = line.match(PROMPT_LINE_PATTERN); if (promptMatched) { if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) { keptLines.push(line); } continue; } if (PROMPT_BLOCK_START_PATTERN.test(line)) { const wrappedLines = [line]; const promptBodyLines: string[] = []; let cursor = lineIndex + 1; let foundBlockEnd = false; for (; cursor < lines.length; cursor += 1) { const nextLine = lines[cursor] ?? ''; wrappedLines.push(nextLine); if (PROMPT_BLOCK_END_PATTERN.test(nextLine)) { foundBlockEnd = true; break; } promptBodyLines.push(nextLine); } if (foundBlockEnd && pushPart(buildPromptPartFromBlock(promptBodyLines.join('\n')))) { lineIndex = cursor; continue; } keptLines.push(...wrappedLines); lineIndex = foundBlockEnd ? cursor : lines.length; continue; } const matched = line.match(LINK_CARD_LINE_PATTERN); if (!matched) { const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN); if (markdownLinkMatch) { const [, rawTitle, rawUrl] = markdownLinkMatch; if (isStructuredLinkCardCandidate(rawUrl ?? '')) { if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) { continue; } } } const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN); if (standaloneUrlMatch) { const rawUrl = standaloneUrlMatch[1] ?? ''; if (isStructuredLinkCardCandidate(rawUrl)) { if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) { continue; } } } keptLines.push(line); continue; } if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) { keptLines.push(line); continue; } const latestPart = parts.at(-1); if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) { parts.pop(); seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`); keptLines.push(latestPart.url); } } const strippedWithEmbeddedPrompts = (() => { const nextLines: string[] = []; let isInsideCodeFence = false; keptLines.forEach((line) => { if (CODE_FENCE_TOGGLE_PATTERN.test(line)) { nextLines.push(line); isInsideCodeFence = !isInsideCodeFence; return; } if (isInsideCodeFence) { nextLines.push(line); return; } nextLines.push( line.replace(/\[\[prompt:(.+?)\]\]/gi, (fullMatch, rawBody) => (pushPart(buildPromptPart(rawBody)) ? '' : fullMatch)), ); }); return nextLines.join('\n'); })(); return { strippedText: strippedWithEmbeddedPrompts.replace(/\n{3,}/g, '\n\n').trim(), parts, }; }