feat: expand live chat and work server tools
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DisconnectOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
@@ -33,11 +34,16 @@ import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
||||
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
|
||||
import { triggerResourceDownload } from './downloadUtils';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { copyPreviewContent, copyText } from './chatUtils';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
|
||||
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types';
|
||||
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
@@ -80,6 +86,16 @@ type InlinePreviewTarget = {
|
||||
kind: InlinePreviewKind;
|
||||
};
|
||||
|
||||
type OpenPreviewTarget =
|
||||
| string
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: InlinePreviewKind;
|
||||
source?: 'message' | 'context';
|
||||
};
|
||||
|
||||
type PendingComposerUpload = {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -102,8 +118,14 @@ type MessageRenderPayload = {
|
||||
previewSourceText: string;
|
||||
visibleText: string;
|
||||
diffBlocks: string[];
|
||||
rankedLinkTargets: RankedLinkPreviewTarget[];
|
||||
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||||
};
|
||||
|
||||
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
||||
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
|
||||
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;
|
||||
|
||||
function normalizeInlinePreviewUrl(value: string) {
|
||||
return normalizeChatResourceUrl(value);
|
||||
}
|
||||
@@ -167,7 +189,7 @@ function buildInlinePreviewLabel(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewFileName(item: PreviewOption) {
|
||||
function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
|
||||
try {
|
||||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
||||
@@ -177,10 +199,203 @@ function buildPreviewFileName(item: PreviewOption) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRankedLinkTitle(value: string) {
|
||||
return value
|
||||
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractRankedLinkTargets(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushRankedLink = (title: string, url: string) => {
|
||||
const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
|
||||
const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
|
||||
const key = `${normalizedTitle}::${normalizedUrl}`;
|
||||
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
rankedLinkTargets.push({
|
||||
title: normalizedTitle,
|
||||
url: normalizedUrl,
|
||||
});
|
||||
};
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? '';
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
|
||||
if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
|
||||
markdownMatches.forEach((match) => {
|
||||
const [, label, href] = match;
|
||||
if (href?.trim()) {
|
||||
pushRankedLink(label?.trim() || href.trim(), href);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
|
||||
if (!titleMatch) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectedLines = [line];
|
||||
const title = titleMatch[1]?.trim() ?? '';
|
||||
let url = '';
|
||||
let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
|
||||
let cursor = index + 1;
|
||||
|
||||
while (cursor < lines.length) {
|
||||
const candidate = lines[cursor] ?? '';
|
||||
const trimmedCandidate = candidate.trim();
|
||||
|
||||
if (!trimmedCandidate) {
|
||||
collectedLines.push(candidate);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
|
||||
if (linkMatch) {
|
||||
url = linkMatch[1]?.trim() ?? url;
|
||||
collectedLines.push(candidate);
|
||||
hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
|
||||
hasRank = true;
|
||||
collectedLines.push(candidate);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (title && url && hasRank) {
|
||||
pushRankedLink(title, url);
|
||||
index = cursor - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
keptLines.push(...collectedLines);
|
||||
index = cursor - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||
rankedLinkTargets,
|
||||
};
|
||||
}
|
||||
|
||||
function buildComposerFilePickKey(file: File) {
|
||||
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
function isClipboardImageFile(file: File) {
|
||||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||||
|
||||
if (normalizedType.startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
|
||||
}
|
||||
|
||||
function isGeneratedClipboardImageName(file: File) {
|
||||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||||
|
||||
if (!normalizedName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName);
|
||||
}
|
||||
|
||||
function getClipboardImageMimeRank(file: File) {
|
||||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'image/png':
|
||||
return 0;
|
||||
case 'image/jpeg':
|
||||
return 1;
|
||||
case 'image/webp':
|
||||
return 2;
|
||||
case 'image/gif':
|
||||
return 3;
|
||||
case 'image/bmp':
|
||||
return 4;
|
||||
case 'image/heic':
|
||||
case 'image/heif':
|
||||
return 5;
|
||||
case 'image/tiff':
|
||||
case 'image/tif':
|
||||
return 6;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePreferredClipboardImageFiles(files: File[]) {
|
||||
if (files.length <= 1) {
|
||||
return files;
|
||||
}
|
||||
const sortedFiles = [...files]
|
||||
.sort((left, right) => {
|
||||
const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);
|
||||
|
||||
if (rankDifference !== 0) {
|
||||
return rankDifference;
|
||||
}
|
||||
|
||||
return right.size - left.size;
|
||||
})
|
||||
.slice(0, 1);
|
||||
|
||||
if (files.every(isGeneratedClipboardImageName)) {
|
||||
return sortedFiles;
|
||||
}
|
||||
|
||||
return sortedFiles;
|
||||
}
|
||||
|
||||
function resolveComposerPasteFiles(clipboardData: DataTransfer) {
|
||||
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file instanceof File)
|
||||
.filter((file) => file.size > 0);
|
||||
const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
|
||||
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
|
||||
const imageFiles = candidateFiles.filter(isClipboardImageFile);
|
||||
const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;
|
||||
|
||||
return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
|
||||
}
|
||||
|
||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
let responseMessage = '';
|
||||
@@ -252,7 +467,15 @@ function renderMessageInlineParts(line: string): ReactNode[] {
|
||||
|
||||
const href = normalizeInlinePreviewUrl(rawHref.trim());
|
||||
renderedParts.push(
|
||||
<a key={`${href}-${start}`} href={href} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
key={`${href}-${start}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
openChatExternalLink(href, event);
|
||||
}}
|
||||
>
|
||||
{label.trim() || href}
|
||||
</a>,
|
||||
);
|
||||
@@ -300,18 +523,28 @@ function renderMessageBody(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function extractMessageRenderPayload(text: string): MessageRenderPayload {
|
||||
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
|
||||
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
|
||||
const extractedMessageParts = extractChatMessageParts(message.text);
|
||||
const text = extractedMessageParts.strippedText;
|
||||
const linkCardTargets = [
|
||||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
||||
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||
const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||||
const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
|
||||
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
||||
|
||||
return {
|
||||
previewSourceText,
|
||||
visibleText,
|
||||
diffBlocks,
|
||||
rankedLinkTargets,
|
||||
linkCardTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,6 +553,10 @@ function summarizeQueuedText(text: string) {
|
||||
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function normalizeAttachmentName(value: string) {
|
||||
return String(value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isActivityLogMessage(message: ChatMessage) {
|
||||
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
||||
}
|
||||
@@ -552,8 +789,11 @@ function InlineMessagePreview({
|
||||
className="app-chat-preview-card__action"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label="preview 다운로드"
|
||||
href={target.url}
|
||||
download
|
||||
onClick={() => {
|
||||
void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -699,6 +939,7 @@ type ChatConversationViewProps = {
|
||||
isMobileViewport: boolean;
|
||||
isChatTypeSelectionLocked: boolean;
|
||||
isComposerAttachmentUploading: boolean;
|
||||
isSendWithoutContextEnabled: boolean;
|
||||
onViewportScroll: () => void;
|
||||
onViewportTouchEnd: () => void;
|
||||
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
||||
@@ -709,10 +950,11 @@ type ChatConversationViewProps = {
|
||||
onSelectChatType: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onSendImmediate: () => void;
|
||||
onToggleSendWithoutContext: () => void;
|
||||
onClearDraft: () => void;
|
||||
onScrollToBottom: () => void;
|
||||
onToggleResourceStrip: () => void;
|
||||
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
|
||||
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
|
||||
onCopyMessage: (message: ChatMessage) => void;
|
||||
onRetryMessage: (message: ChatMessage) => void;
|
||||
onCancelMessage: (message: ChatMessage) => void;
|
||||
@@ -746,6 +988,7 @@ export function ChatConversationView({
|
||||
isMobileViewport,
|
||||
isChatTypeSelectionLocked,
|
||||
isComposerAttachmentUploading,
|
||||
isSendWithoutContextEnabled,
|
||||
onViewportScroll,
|
||||
onViewportTouchEnd,
|
||||
onViewportTouchMove,
|
||||
@@ -756,6 +999,7 @@ export function ChatConversationView({
|
||||
onSelectChatType,
|
||||
onSend,
|
||||
onSendImmediate,
|
||||
onToggleSendWithoutContext,
|
||||
onClearDraft,
|
||||
onScrollToBottom,
|
||||
onToggleResourceStrip,
|
||||
@@ -1056,11 +1300,17 @@ export function ChatConversationView({
|
||||
}
|
||||
|
||||
const uploadedAttachmentNames = new Set(
|
||||
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
|
||||
);
|
||||
const resolvedUploads = pendingComposerUploads.filter(
|
||||
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
|
||||
composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
|
||||
);
|
||||
const resolvedUploads = pendingComposerUploads.filter((item) => {
|
||||
const normalizedName = normalizeAttachmentName(item.name);
|
||||
|
||||
if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.status === 'uploaded' || item.status === 'failed';
|
||||
});
|
||||
|
||||
if (resolvedUploads.length > 0) {
|
||||
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
|
||||
@@ -1071,6 +1321,7 @@ export function ChatConversationView({
|
||||
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
||||
|
||||
const syncPendingComposerUploads = async (files: File[]) => {
|
||||
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
|
||||
const nextPendingUploads = files.map((file) => ({
|
||||
key: buildComposerFilePickKey(file),
|
||||
name: file.name,
|
||||
@@ -1079,7 +1330,7 @@ export function ChatConversationView({
|
||||
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
||||
|
||||
setPendingComposerUploads((current) => [
|
||||
...current.filter((item) => !pendingKeys.has(item.key)),
|
||||
...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
|
||||
...nextPendingUploads,
|
||||
]);
|
||||
|
||||
@@ -1135,24 +1386,14 @@ export function ChatConversationView({
|
||||
if (!clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemFiles = Array.from(clipboardData.items ?? [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []);
|
||||
const files = resolveComposerPasteFiles(clipboardData);
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const uniqueFiles = Array.from(
|
||||
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
|
||||
);
|
||||
|
||||
void syncPendingComposerUploads(uniqueFiles);
|
||||
void syncPendingComposerUploads(files);
|
||||
};
|
||||
|
||||
const dismissPendingComposerUpload = (key: string) => {
|
||||
@@ -1398,14 +1639,15 @@ export function ChatConversationView({
|
||||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||||
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
||||
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
|
||||
|
||||
if (isActivityLogMessage(message)) {
|
||||
return renderActivityCard(message);
|
||||
}
|
||||
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
|
||||
const hasPreviewCards =
|
||||
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
|
||||
const shouldRenderStandalonePreview =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
const stackClassName = [
|
||||
@@ -1534,6 +1776,12 @@ export function ChatConversationView({
|
||||
)}
|
||||
{hasPreviewCards ? (
|
||||
<div className="app-chat-message-stack__previews">
|
||||
{linkCardTargets.map((target) => (
|
||||
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
{rankedLinkTargets.map((target) => (
|
||||
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
{diffBlocks.map((diffText, index) => {
|
||||
const previewKey = `${message.id}-diff-${index}`;
|
||||
|
||||
@@ -1577,12 +1825,20 @@ export function ChatConversationView({
|
||||
key={previewKey}
|
||||
target={target}
|
||||
isExpanded={expandedPreviewKey === previewKey}
|
||||
hasModalPreview={Boolean(matchedPreview)}
|
||||
hasModalPreview
|
||||
onOpenModalPreview={() => {
|
||||
if (matchedPreview) {
|
||||
onOpenPreview(matchedPreview.id, { fullscreen: true });
|
||||
return;
|
||||
}
|
||||
onOpenPreview(
|
||||
matchedPreview
|
||||
? matchedPreview.id
|
||||
: {
|
||||
id: previewKey,
|
||||
label: target.label,
|
||||
url: target.url,
|
||||
kind: target.kind,
|
||||
source: 'message',
|
||||
},
|
||||
{ fullscreen: true },
|
||||
);
|
||||
}}
|
||||
onToggle={() => {
|
||||
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
|
||||
@@ -1595,7 +1851,6 @@ export function ChatConversationView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
{activeSystemStatus ? (
|
||||
@@ -1656,6 +1911,17 @@ export function ChatConversationView({
|
||||
</div>
|
||||
<div className="app-chat-panel__composer-actions">
|
||||
<div className="app-chat-panel__composer-action-buttons">
|
||||
<Button
|
||||
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
|
||||
className={`app-chat-panel__composer-contextless-toggle${
|
||||
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
|
||||
}`}
|
||||
icon={<DisconnectOutlined />}
|
||||
aria-label={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
|
||||
title={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
|
||||
onClick={onToggleSendWithoutContext}
|
||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||
/>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label="즉시 요청"
|
||||
|
||||
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal file
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
import type { ChatMessagePart } from './types';
|
||||
|
||||
export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePart, { type: 'link_card' }> }) {
|
||||
return (
|
||||
<section className="app-chat-preview-card app-chat-preview-card--link-card">
|
||||
<div className="app-chat-preview-card__header">
|
||||
<div className="app-chat-preview-card__meta">
|
||||
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
|
||||
<LinkOutlined />
|
||||
</span>
|
||||
<div className="app-chat-preview-card__titles">
|
||||
<span className="app-chat-preview-card__label">{target.title}</span>
|
||||
<span className="app-chat-preview-card__kind">link card</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="app-chat-preview-card__open-link"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={(event) => {
|
||||
void openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
{target.actionLabel?.trim() || '열기'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
|
||||
<a
|
||||
className="app-chat-preview-card__ranked-link-anchor"
|
||||
href={target.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
{target.url}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
|
||||
import { Alert, Button, Empty, Space, Spin, Typography, message } from 'antd';
|
||||
import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
@@ -326,6 +326,13 @@ export function ChatPreviewBody({
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
const handleDownloadResource = () => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
void triggerResourceDownload(target.url, fileName).catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
||||
});
|
||||
};
|
||||
|
||||
if (target.kind === 'file') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-file">
|
||||
@@ -334,15 +341,7 @@ export function ChatPreviewBody({
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
@@ -414,15 +413,7 @@ export function ChatPreviewBody({
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal file
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
|
||||
export type RankedLinkPreviewTarget = {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTarget }) {
|
||||
return (
|
||||
<section className="app-chat-preview-card app-chat-preview-card--ranked-link">
|
||||
<div className="app-chat-preview-card__header">
|
||||
<div className="app-chat-preview-card__meta">
|
||||
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
|
||||
<LinkOutlined />
|
||||
</span>
|
||||
<div className="app-chat-preview-card__titles">
|
||||
<span className="app-chat-preview-card__label">{target.title}</span>
|
||||
<span className="app-chat-preview-card__kind">link preview</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="app-chat-preview-card__open-link"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={(event) => {
|
||||
void openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
|
||||
<a
|
||||
className="app-chat-preview-card__ranked-link-anchor"
|
||||
href={target.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
{target.url}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,74 @@ export function shouldOpenDownloadInNewWindow() {
|
||||
return isStandaloneDisplayMode() && isMobileLikeViewport();
|
||||
}
|
||||
|
||||
export function triggerResourceDownload(url: string, fileName?: string) {
|
||||
function decodeDownloadFileName(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileNameFromUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : 'https://local.invalid');
|
||||
return decodeDownloadFileName(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDispositionFileName(headerValue: string | null) {
|
||||
const normalized = String(headerValue ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const utf8Match = normalized.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeDownloadFileName(utf8Match[1]);
|
||||
}
|
||||
|
||||
const quotedMatch = normalized.match(/filename="([^"]+)"/i);
|
||||
|
||||
if (quotedMatch?.[1]) {
|
||||
return decodeDownloadFileName(quotedMatch[1]);
|
||||
}
|
||||
|
||||
const plainMatch = normalized.match(/filename=([^;]+)/i);
|
||||
return plainMatch?.[1] ? decodeDownloadFileName(plainMatch[1].replace(/^["']|["']$/g, '')) : '';
|
||||
}
|
||||
|
||||
function isHtmlFileName(fileName: string) {
|
||||
return /\.html?$/i.test(fileName.trim());
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function triggerAnchorDownload(url: string, fileName?: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
@@ -45,3 +112,57 @@ export function triggerResourceDownload(url: string, fileName?: string) {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function buildDownloadErrorMessage(response: Response) {
|
||||
if (response.status === 401) {
|
||||
return '인증이 없어 파일을 내려받지 못했습니다.';
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
return '권한이 없어 파일을 내려받지 못했습니다.';
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return '파일을 찾지 못했습니다.';
|
||||
}
|
||||
|
||||
return `다운로드에 실패했습니다. (${response.status})`;
|
||||
}
|
||||
|
||||
export async function triggerResourceDownload(url: string, fileName?: string) {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url, window.location.href);
|
||||
const preferredFileName = fileName?.trim() || resolveFileNameFromUrl(parsedUrl.toString()) || 'resource';
|
||||
|
||||
if (parsedUrl.origin !== window.location.origin) {
|
||||
triggerAnchorDownload(parsedUrl.toString(), preferredFileName);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(parsedUrl.toString(), {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(buildDownloadErrorMessage(response));
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const responseFileName = parseContentDispositionFileName(contentDisposition);
|
||||
const resolvedFileName = responseFileName || preferredFileName;
|
||||
const blob = await response.blob();
|
||||
|
||||
if (contentType.includes('text/html') && !isHtmlFileName(resolvedFileName)) {
|
||||
const htmlPreview = (await blob.text()).trimStart().toLowerCase();
|
||||
|
||||
if (htmlPreview.startsWith('<!doctype html') || htmlPreview.startsWith('<html') || htmlPreview.includes('<head')) {
|
||||
throw new Error('실제 파일 대신 앱 HTML이 반환되어 다운로드를 중단했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
downloadBlob(blob, resolvedFileName);
|
||||
}
|
||||
|
||||
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal file
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at';
|
||||
const CHAT_EXTERNAL_LINK_TTL_MS = 15_000;
|
||||
|
||||
type LinkNavigationEvent = {
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
};
|
||||
|
||||
function canUseSessionStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function persistExternalLinkOpenTimestamp(openedAt: number) {
|
||||
if (!canUseSessionStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY, String(openedAt));
|
||||
}
|
||||
|
||||
function clearExternalLinkOpenTimestamp() {
|
||||
if (!canUseSessionStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
|
||||
}
|
||||
|
||||
export function shouldSkipForegroundResyncAfterExternalLink() {
|
||||
if (!canUseSessionStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawOpenedAt = window.sessionStorage.getItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
|
||||
clearExternalLinkOpenTimestamp();
|
||||
|
||||
if (!rawOpenedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const openedAt = Number(rawOpenedAt);
|
||||
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
|
||||
}
|
||||
|
||||
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
persistExternalLinkOpenTimestamp(Date.now());
|
||||
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (openedWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.click();
|
||||
}
|
||||
164
src/app/main/mainChatPanel/messageParts.ts
Normal file
164
src/app/main/mainChatPanel/messageParts.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
import { extractHiddenPreviewUrls } from './previewMarkers';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
@@ -106,7 +107,21 @@ export function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
|
||||
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 = [
|
||||
...extractAutoDetectedPreviewUrls(message.text),
|
||||
...extractHiddenPreviewUrls(message.text),
|
||||
...structuredLinkUrls,
|
||||
];
|
||||
|
||||
matches.forEach((matchedUrl) => {
|
||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatMessagePart =
|
||||
| {
|
||||
type: 'link_card';
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
author: 'codex' | 'system' | 'user';
|
||||
@@ -8,6 +16,7 @@ export type ChatMessage = {
|
||||
clientRequestId?: string | null;
|
||||
deliveryStatus?: 'retrying' | 'failed' | null;
|
||||
retryCount?: number;
|
||||
parts?: ChatMessagePart[];
|
||||
};
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
|
||||
Reference in New Issue
Block a user