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="즉시 요청"
|
||||
|
||||
Reference in New Issue
Block a user