165 lines
4.2 KiB
TypeScript
165 lines
4.2 KiB
TypeScript
import type { ChatMessagePart } from './types';
|
|
|
|
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\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 RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
|
|
|
function normalizeText(value: unknown) {
|
|
return String(value ?? '').trim();
|
|
}
|
|
|
|
function normalizeUrl(value: string) {
|
|
const normalized = normalizeText(value);
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
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 (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
|
return false;
|
|
}
|
|
|
|
if (/^https?:\/\//i.test(normalized)) {
|
|
return !hasKnownFileExtension(normalized);
|
|
}
|
|
|
|
return !hasKnownFileExtension(normalized);
|
|
}
|
|
|
|
function buildFallbackLinkTitle(url: string) {
|
|
try {
|
|
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : '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 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 = normalizeUrl(rawUrl);
|
|
const actionLabel = normalizeText(rawActionLabel) || null;
|
|
|
|
if (!title || !url) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: 'link_card',
|
|
title,
|
|
url,
|
|
actionLabel,
|
|
};
|
|
}
|
|
|
|
export function extractChatMessageParts(text: string) {
|
|
const lines = String(text ?? '').split('\n');
|
|
const keptLines: string[] = [];
|
|
const parts: ChatMessagePart[] = [];
|
|
const seenLinkKeys = new Set<string>();
|
|
const pushPart = (nextPart: ChatMessagePart | null) => {
|
|
if (!nextPart) {
|
|
return false;
|
|
}
|
|
|
|
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
|
|
|
if (seenLinkKeys.has(dedupeKey)) {
|
|
return true;
|
|
}
|
|
|
|
seenLinkKeys.add(dedupeKey);
|
|
parts.push(nextPart);
|
|
return true;
|
|
};
|
|
|
|
for (const line of lines) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
|
parts,
|
|
};
|
|
}
|