import { normalizeChatResourceUrl } from './chatResourceUrl'; import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls'; import { extractChatMessageParts } from './messageParts'; import { extractHiddenPreviewUrls } from './previewMarkers'; import type { ChatMessage } from './types'; export type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file'; export type PreviewItem = { id: string; label: string; url: string; kind: PreviewKind; source: 'message' | 'context'; }; const CHAT_RESOURCE_INTERNAL_SEGMENTS = new Set(['resource', 'uploads', 'source', 'src']); const CHAT_RESOURCE_HIDDEN_FILE_NAMES = new Set(['.env']); const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/'; const RESOURCE_STRIP_ALLOWED_KINDS = new Set([ 'image', 'video', 'markdown', 'code', 'diff', 'document', 'pdf', 'file', ]); function normalizePreviewUrl(value: string) { return normalizeChatResourceUrl(value); } function parsePreviewUrl(url: string) { try { return new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); } catch { return null; } } function extractInternalChatResourcePath(pathname: string) { const normalizedPathname = String(pathname ?? '').trim(); if (!normalizedPathname) { return ''; } if (normalizedPathname.includes('/.codex_chat/')) { const markerIndex = normalizedPathname.lastIndexOf('/.codex_chat/'); return normalizedPathname.slice(markerIndex + 1); } const apiMarkerIndex = normalizedPathname.lastIndexOf(CHAT_API_RESOURCE_MARKER); if (apiMarkerIndex >= 0) { return normalizedPathname.slice(apiMarkerIndex + CHAT_API_RESOURCE_MARKER.length).replace(/^\/+/, ''); } return ''; } function hasVisibleFileExtension(fileName: string) { return /\.[a-z0-9]{1,16}$/i.test(fileName); } function hasSupportedPreviewFileExtension(fileName: string) { return /\.(png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|md|markdown|diff|patch|ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml|txt|log|csv|pdf)$/i.test( fileName, ); } function isMarkdownResourceFile(fileName: string) { return /\.(md|markdown)$/i.test(fileName); } function shouldHideInternalChatResource(url: string) { const parsed = parsePreviewUrl(url); const pathname = parsed?.pathname ?? ''; const internalResourcePath = extractInternalChatResourcePath(pathname); const normalizedInternalResourcePath = internalResourcePath.toLowerCase(); if (!normalizedInternalResourcePath.startsWith('.codex_chat/')) { return false; } const segments = internalResourcePath.split('/').filter(Boolean); const lastSegment = segments.at(-1)?.trim() ?? ''; const normalizedLastSegment = lastSegment.toLowerCase(); const resourceSegmentIndex = segments.findIndex((segment) => segment === 'resource'); const nextSegment = resourceSegmentIndex >= 0 ? segments[resourceSegmentIndex + 1]?.toLowerCase() ?? '' : ''; if (!lastSegment || pathname.endsWith('/')) { return true; } if (CHAT_RESOURCE_INTERNAL_SEGMENTS.has(normalizedLastSegment)) { return true; } if (!hasVisibleFileExtension(lastSegment)) { return true; } if (CHAT_RESOURCE_INTERNAL_SEGMENTS.has(nextSegment) && !hasVisibleFileExtension(lastSegment)) { return true; } if (normalizedLastSegment.startsWith('.')) { return true; } if (CHAT_RESOURCE_HIDDEN_FILE_NAMES.has(normalizedLastSegment)) { return true; } if (resourceSegmentIndex >= 0 && nextSegment === 'src' && !isMarkdownResourceFile(lastSegment)) { return true; } if (!hasSupportedPreviewFileExtension(lastSegment)) { return true; } return false; } function isPreviewRouteUrl(url: string) { if (typeof window === 'undefined') { return false; } try { const parsed = new URL(url, window.location.origin); const pathname = parsed.pathname.toLowerCase(); const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname); return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol); } catch { return false; } } export function classifyPreviewKind(url: string): PreviewKind { const pathname = url.toLowerCase().split('?')[0] ?? ''; if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) { return 'image'; } if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) { return 'video'; } if (/\.(md|markdown)$/i.test(pathname)) { return 'markdown'; } if (/\.(diff|patch)$/i.test(pathname)) { return 'diff'; } if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { return 'code'; } if (/\.(txt|log|csv)$/i.test(pathname)) { return 'document'; } if (/\.pdf$/i.test(pathname)) { return 'pdf'; } if (isPreviewRouteUrl(url)) { return 'document'; } return 'file'; } export function buildPreviewLabel(url: string, source: PreviewItem['source']) { try { const parsed = new URL(url); const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1); if (lastSegment) { return source === 'context' ? `현재 화면 · ${lastSegment}` : lastSegment; } return source === 'context' ? '현재 화면 미리보기' : parsed.hostname; } catch { return source === 'context' ? '현재 화면 미리보기' : url; } } export function isHtmlPreviewItem(item: PreviewItem | null | undefined) { if (!item || item.kind !== 'code') { return false; } try { const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); const pathname = parsed.pathname.toLowerCase(); return pathname.endsWith('.html') || pathname.endsWith('.htm'); } catch { const pathname = item.url.toLowerCase().split('?')[0] ?? ''; return pathname.endsWith('.html') || pathname.endsWith('.htm'); } } export function extractPreviewItems(messages: ChatMessage[]) { const seen = new Set(); const items: PreviewItem[] = []; const orderedMessages = [...messages].reverse(); orderedMessages.forEach((message) => { const extractedMessageParts = extractChatMessageParts(message.text); const structuredLinkUrls = [ ...(Array.isArray(message.parts) ? message.parts : []), ...extractedMessageParts.parts, ] .filter( (part): part is Extract<(typeof extractedMessageParts.parts)[number], { type: 'link_card' }> => part.type === 'link_card' && Boolean(part.url), ) .map((part) => part.url); const matches = [ ...extractHiddenPreviewUrls(message.text), ...structuredLinkUrls, ...extractAutoDetectedPreviewUrls(message.text), ]; matches.forEach((matchedUrl) => { const normalizedUrl = normalizePreviewUrl(matchedUrl); const kind = classifyPreviewKind(normalizedUrl); if (shouldHideInternalChatResource(normalizedUrl)) { return; } if (!RESOURCE_STRIP_ALLOWED_KINDS.has(kind)) { return; } if (seen.has(normalizedUrl)) { return; } seen.add(normalizedUrl); items.push({ id: `${message.id}-${normalizedUrl}`, label: buildPreviewLabel(normalizedUrl, 'message'), url: normalizedUrl, kind, source: 'message', }); }); }); return items.slice(0, 12); }