Files
ai-code-app/src/app/main/mainChatPanel/messageParts.ts

757 lines
21 KiB
TypeScript

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<ChatMessagePart, { type: 'prompt' }>;
type PromptOption = PromptPart['options'][number];
type PromptPreview = NonNullable<PromptOption['preview']>;
type PromptStep = NonNullable<PromptPart['steps']>[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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string>();
return value
.map((item) => normalizePromptAttachment(item))
.filter((item): item is NonNullable<PromptPart['attachments']>[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<string, unknown>;
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<string, unknown>;
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<string>();
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<string>();
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,
};
}