feat(web): 重构前端UI并支持OpenAI协议
- 添加账号管理详情页(基本信息、提示词、CapCut、参考图标签页) - 重构资产页面,按项目组分开展示图片/视频 - 聊天界面支持深度思考内容折叠展示、复制、删除消息 - 设置页面支持Agent配置(Anthropic/OpenAI协议)和工具配置 - 后端支持OpenAI兼容协议流式输出和DeepSeek思考模式 - 添加对话置顶/删除功能、数据库迁移、资产清单API - 添加账号参考图上传/删除、技能配置持久化、连接测试API
This commit is contained in:
@@ -1,69 +1,146 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { renderMarkdown } from '@/lib/markdown';
|
||||
import { RefreshCw, ArrowRight, Quote, Bot, User } from 'lucide-react';
|
||||
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Check, Brain } from 'lucide-react';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
type MessageState = 'streaming' | 'thinking' | 'done';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
isLast?: boolean;
|
||||
isThinking?: boolean;
|
||||
onRegenerate?: (msgId: string) => void;
|
||||
onContinue?: (msgId: string) => void;
|
||||
onContinue?: () => void;
|
||||
onQuote?: (content: string) => void;
|
||||
onDelete?: (msgId: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, onRegenerate, onContinue, onQuote }: Props) {
|
||||
function getState(msg: Message, isLast: boolean, isThinking: boolean): MessageState {
|
||||
if (msg.role === 'user') return 'done';
|
||||
if (isThinking && isLast) return 'thinking';
|
||||
if (!msg.content && !msg.reasoningContent) return 'streaming';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isLast, isThinking, onRegenerate, onContinue, onQuote, onDelete }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
const isEmpty = !message.content;
|
||||
const isTool = message.role === 'tool';
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [reasoningOpen, setReasoningOpen] = useState(false);
|
||||
const state = getState(message, !!isLast, !!isThinking);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [message.content]);
|
||||
|
||||
// Tool messages
|
||||
if (isTool) {
|
||||
let toolName = '';
|
||||
try { const p = JSON.parse(message.content); toolName = p.tool || ''; } catch {}
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1 px-3">
|
||||
<Bot size={13} className="text-zinc-400" />
|
||||
<span className="text-xs text-zinc-400">工具调用: {toolName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showActions = state === 'done' && !isUser && message.content;
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-3 mb-6', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||
<div className={cn('flex gap-3 mb-5 group/msg', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-xs',
|
||||
isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-100 text-zinc-500'
|
||||
)}>
|
||||
{isUser ? <User size={15} /> : <Bot size={15} />}
|
||||
{isUser ? <User size={13} /> : <Bot size={13} />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('flex-1 max-w-[75%]', isUser && 'flex flex-col items-end')}>
|
||||
{/* Role label */}
|
||||
<div className={cn('text-[10px] font-medium mb-1 px-1', isUser ? 'text-right text-indigo-500' : 'text-zinc-400')}>
|
||||
{isUser ? '你' : '美图 Agent'}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div className={cn(
|
||||
'rounded-2xl px-4 py-3 text-sm leading-relaxed',
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white rounded-tr-md'
|
||||
: 'bg-white text-zinc-700 border border-zinc-200/60 shadow-sm rounded-tl-md'
|
||||
)}>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse delay-100" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse delay-200" />
|
||||
{/* Body */}
|
||||
<div className={cn('flex-1 max-w-[78%]', isUser && 'flex flex-col items-end')}>
|
||||
{/* Thinking indicator */}
|
||||
{state === 'thinking' && !message.content && !message.reasoningContent && (
|
||||
<div className="rounded-2xl px-3.5 py-2.5 bg-zinc-50 border border-zinc-100 rounded-tl-md">
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm">
|
||||
<Brain size={14} className="animate-pulse" />
|
||||
<span>深度思考中...</span>
|
||||
</div>
|
||||
) : isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions - only for assistant messages */}
|
||||
{!isUser && !isEmpty && (
|
||||
<div className="flex gap-0.5 mt-1 opacity-0 hover:opacity-100 transition-opacity px-1">
|
||||
{/* Reasoning block (collapsible) */}
|
||||
{message.reasoningContent && (
|
||||
<div className="mb-1.5 w-full">
|
||||
<button
|
||||
onClick={() => setReasoningOpen(!reasoningOpen)}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-600 transition-colors py-1"
|
||||
>
|
||||
{state === 'streaming' || state === 'thinking' ? (
|
||||
<Brain size={12} className="animate-pulse text-indigo-400" />
|
||||
) : (
|
||||
<Brain size={12} />
|
||||
)}
|
||||
<span>{state === 'done' ? '深度思考' : '思考中...'}</span>
|
||||
{state === 'done' && (
|
||||
reasoningOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
)}
|
||||
</button>
|
||||
{(reasoningOpen || state !== 'done') && (
|
||||
<div className="mt-1 px-3 py-2 rounded-lg bg-indigo-50/50 border border-indigo-100 text-xs text-zinc-500 leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap">
|
||||
{message.reasoningContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content bubble */}
|
||||
{!(message.content === '(调用工具)' && state === 'done' && message.reasoningContent) && (
|
||||
<div className={cn(
|
||||
'rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed',
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white rounded-tr-md'
|
||||
: 'bg-zinc-50 text-zinc-700 border border-zinc-100 rounded-tl-md'
|
||||
)}>
|
||||
{state === 'streaming' && !message.content ? (
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:100ms]" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:200ms]" />
|
||||
</div>
|
||||
) : isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="markdown-body prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions - visible on hover or when done */}
|
||||
{showActions && (
|
||||
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity px-1">
|
||||
<button onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
{copied ? <><Check size={10} />已复制</> : <><Copy size={10} />复制</>}
|
||||
</button>
|
||||
<button onClick={() => onQuote?.(message.content)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<Quote size={10} />引用
|
||||
</button>
|
||||
<button onClick={() => onDelete?.(message.id)}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
|
||||
<Trash2 size={10} />删除
|
||||
</button>
|
||||
<button onClick={() => onRegenerate?.(message.id)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<RefreshCw size={10} />重新生成
|
||||
</button>
|
||||
<button onClick={() => onContinue?.(message.id)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<button onClick={() => onContinue?.()}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||
<ArrowRight size={10} />继续
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useChat } from '@/hooks/useChat';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { PipelineProgress } from './PipelineProgress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Loader2, StopCircle, X } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -14,18 +13,18 @@ import type { Account, Message } from '@/types';
|
||||
export function ChatView() {
|
||||
const { conversationId } = useParams<{ conversationId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { setConversations, selectedAccountId, setSelectedAccountId } = useAppStore();
|
||||
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation } = useChat(conversationId || null);
|
||||
const { setConversations, selectedAccountId } = useAppStore();
|
||||
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation, removeMessage } = useChat(conversationId || null);
|
||||
const [manifestPath, setManifestPath] = useState<string | null>(null);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [quote, setQuote] = useState<string | null>(null);
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
const creatingRef = useRef(false);
|
||||
|
||||
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/pipeline/conversations')
|
||||
.then((r) => r.json()).then(setConversations).catch(() => {});
|
||||
api.listConversations().then(setConversations).catch(() => {});
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,27 +37,13 @@ export function ChatView() {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// After navigating to a new conversation, send the pending message
|
||||
useEffect(() => {
|
||||
if (selectedAccountId && conversationId && connected && messages.length === 0) {
|
||||
const account = accounts.find((a) => a.id === selectedAccountId);
|
||||
if (account) {
|
||||
send(`已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`);
|
||||
}
|
||||
if (conversationId && connected && pendingMessage && messages.length === 0) {
|
||||
send(pendingMessage);
|
||||
setPendingMessage(null);
|
||||
}
|
||||
}, [selectedAccountId, conversationId, connected]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (creatingRef.current) return;
|
||||
creatingRef.current = true;
|
||||
createConversation('新对话', selectedAccountId || undefined);
|
||||
setTimeout(() => {
|
||||
fetch('/api/pipeline/conversations').then((r) => r.json()).then((list) => {
|
||||
setConversations(list);
|
||||
if (list.length > 0) navigate(`/chat/${list[0].id}`);
|
||||
creatingRef.current = false;
|
||||
});
|
||||
}, 500);
|
||||
}, [createConversation, selectedAccountId]);
|
||||
}, [conversationId, connected, pendingMessage]);
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!manifestPath) return;
|
||||
@@ -84,59 +69,77 @@ export function ChatView() {
|
||||
|
||||
const handleContinue = useCallback(() => { send('请继续'); }, [send]);
|
||||
const handleQuote = useCallback((content: string) => { setQuote(content.slice(0, 200)); }, []);
|
||||
const handleDeleteMsg = useCallback((msgId: string) => {
|
||||
removeMessage(msgId);
|
||||
}, [removeMessage]);
|
||||
|
||||
// Delayed conversation creation
|
||||
const handleSendNew = useCallback(async (content: string) => {
|
||||
if (creatingRef.current) return;
|
||||
creatingRef.current = true;
|
||||
setPendingMessage(content);
|
||||
|
||||
try {
|
||||
createConversation(content.slice(0, 30), selectedAccountId || undefined);
|
||||
setTimeout(async () => {
|
||||
const list = await api.listConversations();
|
||||
setConversations(list);
|
||||
if (list.length > 0) {
|
||||
navigate(`/chat/${list[0].id}`);
|
||||
}
|
||||
creatingRef.current = false;
|
||||
}, 600);
|
||||
} catch {
|
||||
creatingRef.current = false;
|
||||
}
|
||||
}, [createConversation, selectedAccountId]);
|
||||
|
||||
const handleSend = useCallback((content: string) => {
|
||||
if (quote) { send(`> ${quote}\n\n${content}`); setQuote(null); }
|
||||
else send(content);
|
||||
}, [send, quote]);
|
||||
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
|
||||
if (conversationId) {
|
||||
send(content);
|
||||
} else {
|
||||
handleSendNew(content);
|
||||
}
|
||||
}, [send, quote, conversationId]);
|
||||
|
||||
const handleStop = useCallback(() => { stop(); }, [stop]);
|
||||
|
||||
// Empty state - no conversation selected
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 bg-white">
|
||||
<div className="text-4xl mb-2">💬</div>
|
||||
<h2 className="text-lg font-semibold text-zinc-800">开始新对话</h2>
|
||||
<p className="text-sm text-zinc-500">选择左侧对话或创建新的创作会话</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{accounts.length > 0 && (
|
||||
<p className="text-sm text-zinc-500">输入消息开始创作,对话将自动创建</p>
|
||||
<div className="w-full max-w-xl px-4 mt-4">
|
||||
<ChatInput onSend={handleSend} disabled={thinking || creatingRef.current} />
|
||||
</div>
|
||||
{accounts.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className="text-xs text-zinc-400">当前账号:</span>
|
||||
<select
|
||||
value={selectedAccountId || ''}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value || null)}
|
||||
className="h-9 rounded-md border border-zinc-200 bg-white px-3 text-sm text-zinc-700"
|
||||
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
|
||||
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
|
||||
>
|
||||
<option value="">不指定账号</option>
|
||||
<option value="">不指定</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
disabled={creatingRef.current}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{creatingRef.current ? '创建中...' : '开始对话'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active conversation
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
|
||||
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
||||
</div>
|
||||
<select
|
||||
value={selectedAccountId || ''}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value || null)}
|
||||
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
|
||||
>
|
||||
<option value="">切换账号</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<div className="flex-1 flex flex-col bg-white h-full">
|
||||
{/* Header bar */}
|
||||
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
|
||||
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{manifestPath && (
|
||||
@@ -152,43 +155,56 @@ export function ChatView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-4 py-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
||||
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
||||
onQuote={handleQuote}
|
||||
/>
|
||||
))}
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{messages.map((msg, i) => (
|
||||
msg.role !== 'tool' && (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLast={i === messages.length - 1}
|
||||
isThinking={thinking}
|
||||
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
|
||||
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
|
||||
onQuote={handleQuote}
|
||||
onDelete={handleDeleteMsg}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
{pipeline && (
|
||||
<PipelineProgress
|
||||
phase={pipeline.phase}
|
||||
progress={pipeline.progress}
|
||||
currentItem={pipeline.currentItem}
|
||||
totalItems={pipeline.totalItems}
|
||||
status={pipeline.status}
|
||||
/>
|
||||
)}
|
||||
{pipeline && (
|
||||
<PipelineProgress
|
||||
phase={pipeline.phase}
|
||||
progress={pipeline.progress}
|
||||
currentItem={pipeline.currentItem}
|
||||
totalItems={pipeline.totalItems}
|
||||
status={pipeline.status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{thinking && !pipeline && (
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{toolStatus || '思考中...'}
|
||||
{thinking && !pipeline && (
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{toolStatus || '思考中...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{quote && (
|
||||
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span className="flex-1 truncate">引用: {quote}</span>
|
||||
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
||||
{/* Quote bar */}
|
||||
{quote && (
|
||||
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500 flex-shrink-0">
|
||||
<span className="flex-1 truncate">引用: {quote}</span>
|
||||
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput onSend={handleSend} disabled={thinking} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput onSend={handleSend} disabled={thinking} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user