feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

View File

@@ -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="즉시 요청"

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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);
}

View 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();
}

View 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,
};
}

View File

@@ -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);

View File

@@ -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 = {