Files
memind/src/components/ChatPanel.tsx
T
john 71e536eceb feat(mindspace): 悬浮聊天勾选资料联动、UI 与交互优化
勾选单个文件时 Agent 获知上下文并主动问候;打开悬浮窗使用新会话。
统一弹窗浅色主题、固定高度与输入区布局,支持点击外部收起。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

770 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import {
CHAT_IMAGE_UPLOAD_MAX_COUNT,
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES,
} from '../utils/imageUpload';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { DesignSkillPanel } from './DesignSkillPanel';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
import type { MindSpaceSaveCategory } from '../types';
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
'帮我看看今天的天气和出门建议',
'规划一份周末城市漫游攻略',
'把这组数据整理成一段分析结论',
'帮我总结一份会议纪要或文档',
'写一个有记忆点的产品介绍',
'设计一个简单好玩的小游戏',
'帮我做一份旅行行程和预算',
'把一段想法改成更专业的表达',
'给我起几个品牌名和一句 slogan',
'帮我写一封礼貌但坚定的邮件',
'整理一份学习计划和每日任务',
'分析一个产品页面哪里可以优化',
'把复杂概念讲得像聊天一样简单',
'帮我生成一份短视频脚本',
'写一段适合发朋友圈的文案',
'帮我拆解一个商业想法是否可行',
'把长文压缩成三条重点',
'做一个活动策划和执行清单',
'帮我准备一次面试的回答思路',
'写一个网页或小工具的需求说明',
'帮我把表格数据变成洞察报告',
'给孩子讲一个睡前故事',
'帮我规划一周健康饮食',
'把这段话翻译得自然一点',
'帮我做一份竞品对比',
'给一个新功能设计使用流程',
'帮我写一段代码并解释思路',
'整理一个今天就能开始的行动计划',
];
type PendingChatImage = {
id: string;
file: File;
previewUrl: string;
sizeBytes: number;
uploadedUrl: string | null;
uploadProgress: number | null;
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
};
function formatUploadMegabytes(bytes: number) {
const megabytes = bytes / (1024 * 1024);
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7zm0 0v5h5"
/>
<path fill="currentColor" d="M9 13h6v1.5H9zm0 3h6v1.5H9zm0-6h3v1.5H9z" opacity=".72" />
</svg>
);
}
export function ChatPanel({
variant,
user,
messages,
historyLoadingMore = false,
historyHasMore = false,
historyTotal = 0,
chatState,
pendingTool,
session,
capabilities,
grantedSkills,
onSubmit,
onUploadImage,
onLoadOlderMessages,
onStop,
onApproveTool,
onPageSaved,
onClose,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
messages: Message[];
historyLoadingMore?: boolean;
historyHasMore?: boolean;
historyTotal?: number;
chatState: ChatState;
pendingTool: ToolConfirmation | null;
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
onUploadImage?: (file: File, onProgress?: (progress: number) => void) => Promise<string>;
onLoadOlderMessages?: () => void | Promise<void>;
onStop: () => void | Promise<void>;
onApproveTool: (allow: boolean) => void | Promise<void>;
onPageSaved?: (result: {
kind: 'page' | 'asset' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
onClose?: () => void;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
const [designPanelOpen, setDesignPanelOpen] = useState(false);
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [showHistoryFullyLoadedNotice, setShowHistoryFullyLoadedNotice] = useState(false);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
const imageInputRef = useRef<HTMLInputElement>(null);
const mainRef = useRef<HTMLElement>(null);
const inputRef = useRef(input);
const voiceBaseRef = useRef('');
const pendingImagesRef = useRef(pendingImages);
const suppressVoiceUpdateRef = useRef(false);
const nearBottomRef = useRef(true);
const initializedSessionRef = useRef<string | null>(null);
const pendingOlderLoadRef = useRef<null | {
anchorId: string | null;
anchorTop: number;
remainingHeight: number;
}>(null);
const prevMessageCountRef = useRef(messages.length);
inputRef.current = input;
pendingImagesRef.current = pendingImages;
useEffect(() => {
return () => {
pendingImagesRef.current.forEach((item) => {
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
});
};
}, []);
const requestOlderMessages = useCallback(() => {
const container = mainRef.current;
if (!container || !onLoadOlderMessages || historyLoadingMore || !historyHasMore) return;
const blocks = Array.from(container.querySelectorAll<HTMLElement>('[data-message-anchor]'));
const anchor =
blocks.find((block) => {
const rect = block.getBoundingClientRect();
return rect.bottom >= container.getBoundingClientRect().top + 8;
}) ?? null;
pendingOlderLoadRef.current = {
anchorId: anchor?.dataset.messageAnchor ?? null,
anchorTop: anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
remainingHeight: Math.max(container.clientHeight * 2, 1),
};
void onLoadOlderMessages();
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages]);
const handleMainScroll = useCallback(() => {
const container = mainRef.current;
if (!container) return;
nearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
if (
container.scrollTop < 120 &&
historyHasMore &&
!historyLoadingMore &&
onLoadOlderMessages &&
!pendingOlderLoadRef.current
) {
requestOlderMessages();
}
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages, requestOlderMessages]);
useEffect(() => {
initializedSessionRef.current = null;
pendingOlderLoadRef.current = null;
nearBottomRef.current = true;
setShowHistoryFullyLoadedNotice(false);
}, [session?.id]);
useEffect(() => {
const isHomeWelcome = variant !== 'compact' && messages.length === 0;
if (isHomeWelcome || historyTotal <= 0 || historyHasMore || historyLoadingMore) {
setShowHistoryFullyLoadedNotice(false);
return;
}
setShowHistoryFullyLoadedNotice(true);
const timer = window.setTimeout(() => {
setShowHistoryFullyLoadedNotice(false);
}, 5000);
return () => window.clearTimeout(timer);
}, [historyHasMore, historyLoadingMore, historyTotal, messages.length, variant]);
useLayoutEffect(() => {
const container = mainRef.current;
if (!container) return;
const pendingOlderLoad = pendingOlderLoadRef.current;
if (pendingOlderLoad) {
const anchor = pendingOlderLoad.anchorId
? container.querySelector<HTMLElement>(`[data-message-anchor="${pendingOlderLoad.anchorId}"]`)
: null;
const anchorTop = anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top;
const addedHeight = Math.max(0, anchorTop - pendingOlderLoad.anchorTop);
container.scrollTop += addedHeight;
if (pendingOlderLoad.remainingHeight - addedHeight > 0 && historyHasMore && !historyLoadingMore) {
const nextAnchor =
anchor ??
container.querySelector<HTMLElement>('[data-message-anchor]');
pendingOlderLoadRef.current = {
anchorId: nextAnchor?.dataset.messageAnchor ?? null,
anchorTop: nextAnchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
remainingHeight: pendingOlderLoad.remainingHeight - addedHeight,
};
requestOlderMessages();
return;
}
pendingOlderLoadRef.current = null;
nearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
return;
}
if (session?.id && initializedSessionRef.current !== session.id) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
if (
container.scrollHeight < container.clientHeight * 2 &&
historyHasMore &&
!historyLoadingMore &&
onLoadOlderMessages
) {
requestOlderMessages();
return;
}
initializedSessionRef.current = session.id;
prevMessageCountRef.current = messages.length;
return;
}
const messageCountIncreased = messages.length > prevMessageCountRef.current;
if ((nearBottomRef.current || chatState === 'streaming') && messageCountIncreased) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
}
prevMessageCountRef.current = messages.length;
}, [
chatState,
historyHasMore,
historyLoadingMore,
messages.length,
onLoadOlderMessages,
requestOlderMessages,
session?.id,
]);
const busy =
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: chatState === 'connecting'
? '正在连接会话…'
: chatState === 'waiting'
? '消息已收到,正在连接后台…'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
const chunk = spoken.trim();
if (!chunk) return base;
return base ? `${base} ${chunk}` : chunk;
};
const handleVoiceStart = () => {
suppressVoiceUpdateRef.current = false;
voiceBaseRef.current = inputRef.current;
setVoiceNotice(null);
};
const handleVoiceLiveTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setInput(mergeVoiceText(text));
};
const handleVoiceTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setInput(mergeVoiceText(text));
setVoiceNotice('已识别,可编辑后发送');
};
const handleVoiceComplete = () => {
setVoiceNotice('已识别,可编辑后发送');
};
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
const canSubmit = input.trim() || pendingImages.length > 0;
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
const revokePendingImage = (item: PendingChatImage) => {
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
};
const pendingImageBytes = pendingImages.reduce((sum, item) => sum + item.sizeBytes, 0);
const planImageUploads = useCallback((files: File[]) => {
const remainingCount = CHAT_IMAGE_UPLOAD_MAX_COUNT - pendingImages.length;
if (remainingCount <= 0) {
return {
accepted: [],
message: `最多支持一次性附加 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片`,
};
}
const accepted: File[] = [];
let nextTotalBytes = pendingImageBytes;
let oversizedRejected = false;
let totalRejected = false;
let countRejected = false;
for (const file of files) {
if (accepted.length >= remainingCount) {
countRejected = true;
continue;
}
if (file.size > CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES) {
oversizedRejected = true;
continue;
}
if (nextTotalBytes + file.size > CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES) {
totalRejected = true;
continue;
}
accepted.push(file);
nextTotalBytes += file.size;
}
if (accepted.length === 0) {
if (oversizedRejected) {
return {
accepted,
message: `单张图片不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}`,
};
}
return {
accepted,
message: `图片总大小不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)}`,
};
}
let message = null;
if (countRejected) {
message = `最多支持 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片,本次仅保留前 ${accepted.length}`;
} else if (oversizedRejected || totalRejected) {
message = `已按单张 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}、总计 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)} 的限制筛选图片`;
}
return { accepted, message };
}, [pendingImageBytes, pendingImages.length]);
const handleSubmit = async () => {
const trimmed = input.trim();
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
return;
}
const sentImages = [...pendingImages];
let uploadedImages = sentImages;
suppressVoiceUpdateRef.current = true;
if (voiceRecording) {
setVoiceStopSignal((value) => value + 1);
}
setInput('');
voiceBaseRef.current = '';
setVoiceNotice(null);
setImageError(null);
setUploadingImage(true);
try {
const uploadedUrls = await Promise.all(
sentImages.map(async (item) => {
if (item.uploadedUrl) return item.uploadedUrl;
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? { ...candidate, uploadStatus: 'uploading', uploadProgress: 0 }
: candidate,
),
);
const uploadedUrl = await onUploadImage!(item.file, (progress) => {
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
uploadStatus: 'uploading',
uploadProgress: Math.round(progress * 100),
}
: candidate,
),
);
});
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
uploadedUrl,
uploadStatus: 'uploaded',
uploadProgress: 100,
}
: candidate,
),
);
return uploadedUrl;
}),
);
uploadedImages = sentImages.map((item, index) => ({
...item,
uploadedUrl: uploadedUrls[index] ?? item.uploadedUrl,
uploadProgress: uploadedUrls[index] ? 100 : item.uploadProgress,
uploadStatus: uploadedUrls[index] ? 'uploaded' : item.uploadStatus,
}));
const imagesToSend = uploadedUrls.filter(Boolean);
const previewImagesToSend = imagesToSend;
await onSubmit(trimmed, imagesToSend, previewImagesToSend);
uploadedImages.forEach(revokePendingImage);
setPendingImages([]);
} catch (err) {
suppressVoiceUpdateRef.current = false;
setInput(trimmed);
setPendingImages(uploadedImages.map((item) => ({
...item,
uploadStatus: item.uploadedUrl ? 'uploaded' : 'error',
})));
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
setUploadingImage(false);
return;
}
setUploadingImage(false);
suppressVoiceUpdateRef.current = false;
};
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (!onUploadImage) return;
const selected = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (selected.length === 0) return;
const imageFiles = selected.filter((file) => file.type.startsWith('image/'));
if (imageFiles.length === 0) {
setImageError('请上传图片文件');
return;
}
const { accepted: toQueue, message } = planImageUploads(imageFiles);
if (toQueue.length === 0) {
setImageError(message);
return;
}
setImageError(message);
const placeholders = toQueue.map((file, index) => ({
id: `${Date.now()}-${index}-${file.name}`,
file,
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
};
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
if (!onUploadImage || imageAttachmentDisabled) return;
const items = Array.from(event.clipboardData.items ?? []);
const imageItems = items.filter((item) => item.kind === 'file' && item.type.startsWith('image/'));
if (imageItems.length === 0) return;
event.preventDefault();
const files = imageItems
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (files.length === 0) return;
const { accepted: toQueue, message } = planImageUploads(files);
if (toQueue.length === 0) {
setImageError(message);
return;
}
setImageError(message);
const placeholders = toQueue.map((file, index) => ({
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
file,
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
};
const handleRemoveImage = (idToRemove: string) => {
setPendingImages((prev) => {
const target = prev.find((item) => item.id === idToRemove);
if (target) revokePendingImage(target);
return prev.filter((item) => item.id !== idToRemove);
});
};
return (
<>
<main
ref={mainRef}
className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}
onScroll={handleMainScroll}
>
{!showHomeWelcome && (historyLoadingMore || historyHasMore || showHistoryFullyLoadedNotice) && (
<div className="chat-history-loader" role="status">
{historyLoadingMore
? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}`
: historyHasMore
? `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}`
: `历史消息已全部加载${historyTotal > 0 ? ` · ${messages.length} / ${historyTotal}` : ''}`}
</div>
)}
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={(message) => setPageSource(message)}
publishUserId={user?.id}
publishUsername={user?.username}
sessionId={session?.id}
compact={compact}
/>
{!compact && (
<div className="user-avatar-input-host" aria-hidden="true">
<AvatarPicker variant="compact" />
</div>
)}
</main>
{pendingTool && (
<div className={`tool-confirm${compact ? ' tool-confirm-compact' : ''}`}>
<div className="tool-confirm-text">
<strong>{pendingTool.toolName}</strong>
{pendingTool.prompt && <p>{pendingTool.prompt}</p>}
</div>
<div className="tool-confirm-actions">
<button type="button" onClick={() => void onApproveTool(true)}>
</button>
<button type="button" className="danger" onClick={() => void onApproveTool(false)}>
</button>
</div>
</div>
)}
{pageSource?.id && session?.id && (
<PageSaveDialog
sessionId={session.id}
messageId={pageSource.id}
compact={compact}
onClose={() => setPageSource(null)}
onSaved={(result) => {
setPageSource(null);
onPageSaved?.(result);
}}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
</div>
)}
{(pendingImages.length > 0 || imageError) && (
<div className="chat-image-attachments">
{imageError && <div className="chat-image-upload-error">{imageError}</div>}
{pendingImages.length > 0 && (
<div className="chat-image-attachment-grid">
{pendingImages.map((item, index) => (
<div
key={item.id}
className={`chat-image-attachment-item chat-image-attachment-item-${item.uploadStatus}`}
>
<a href={item.uploadedUrl || item.previewUrl} target="_blank" rel="noreferrer">
<img src={item.previewUrl} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
</a>
{item.uploadStatus === 'uploading' && (
<div className="chat-image-upload-progress" aria-label={`上传进度 ${item.uploadProgress ?? 0}%`}>
<div className="chat-image-upload-progress-fill" style={{ width: `${item.uploadProgress ?? 0}%` }} />
<span>{item.uploadProgress ?? 0}%</span>
</div>
)}
{item.uploadStatus === 'uploaded' && (
<div className="chat-image-upload-complete" aria-label="上传完成">
</div>
)}
<button
type="button"
className="chat-image-attachment-remove"
aria-label="移除图片"
disabled={item.uploadStatus === 'uploading'}
onClick={() => handleRemoveImage(item.id)}
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{onUploadImage && (
<>
<button
type="button"
className="chat-image-upload-trigger"
title="上传图片"
disabled={imageAttachmentDisabled}
onClick={() => imageInputRef.current?.click()}
>
<FileIcon />
</button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={handleImageInputChange}
/>
</>
)}
<textarea
className={`input chat-input-field${onUploadImage ? ' chat-input-field-with-image-upload' : ''}`}
rows={1}
placeholder={placeholder}
value={input}
disabled={voiceDisabled}
readOnly={voiceRecording}
onChange={(e) => setInput(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
/>
<VoiceInputButton
disabled={voiceDisabled}
onVoiceStart={handleVoiceStart}
onLiveTranscript={handleVoiceLiveTranscript}
onTranscript={handleVoiceTranscript}
onVoiceComplete={handleVoiceComplete}
onRecordingChange={setVoiceRecording}
onError={setVoiceNotice}
stopSignal={voiceStopSignal}
/>
</div>
{!showHomeWelcome && chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
onSelect={onSubmit}
onPrefill={setInput}
/>
)}
{!showHomeWelcome && (
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
)}
{designPanelOpen && (
<DesignSkillPanel
onClose={() => setDesignPanelOpen(false)}
onResult={(md) => {
setInput(md);
setDesignPanelOpen(false);
}}
/>
)}
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
</button>
) : (
<button
type="button"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage}
onClick={() => void handleSubmit()}
>
</button>
)}
</div>
{compact && onClose && (
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
</button>
)}
</footer>
</>
);
}
function DesignBtnIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 1.1 0 2-.9 2-2 0-.52-.2-1-.52-1.36-.3-.35-.48-.83-.48-1.36 0-1.1.9-2 2-2h2.36c3.1 0 5.64-2.54 5.64-5.64C22 6.02 17.52 2 12 2zM6.5 13C5.67 13 5 12.33 5 11.5S5.67 10 6.5 10 8 10.67 8 11.5 7.33 13 6.5 13zm3-4C8.67 9 8 8.33 8 7.5S8.67 6 9.5 6s1.5.67 1.5 1.5S10.33 9 9.5 9zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 6 14.5 6s1.5.67 1.5 1.5S15.33 9 14.5 9zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13z"
/>
</svg>
);
}