262 lines
7.3 KiB
TypeScript
262 lines
7.3 KiB
TypeScript
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<PreviewKind>([
|
|
'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<string>();
|
|
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);
|
|
}
|