refactor(features): 优化聊天输入和消息气泡组件
ChatInput: - 修复中文输入法回车误发送问题 - 移除未使用的历史记录按钮 MessageBubble: - 集成 MarkdownRenderer 实现富文本渲染 - 添加 AI 思考内容折叠展示 - 添加流式输出状态和错误提示 - 优化复制功能添加成功反馈
This commit is contained in:
parent
227a96b232
commit
a213cddf55
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Clock, ArrowUp } from 'lucide-react';
|
import { Plus, ArrowUp } from 'lucide-react';
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { ToolsDropdown } from './ToolsDropdown';
|
import { ToolsDropdown } from './ToolsDropdown';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -40,7 +40,9 @@ export function ChatInput({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
// 检查是否正在使用输入法组合(如中文输入法选词时按回车)
|
||||||
|
// isComposing 为 true 时,表示用户正在用输入法选词,此时不应发送消息
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
@ -85,14 +87,6 @@ export function ChatInput({
|
|||||||
onToolToggle={onToolToggle}
|
onToolToggle={onToolToggle}
|
||||||
onEnableAllToggle={onEnableAllTools}
|
onEnableAllToggle={onEnableAllTools}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 历史记录 */}
|
|
||||||
<button
|
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
|
||||||
title="History"
|
|
||||||
>
|
|
||||||
<Clock size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧按钮 */}
|
{/* 右侧按钮 */}
|
||||||
|
|||||||
@ -1,47 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Copy, ThumbsUp, ThumbsDown, RefreshCw } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check } from 'lucide-react';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { AILogo } from '@/components/ui/AILogo';
|
import { AILogo } from '@/components/ui/AILogo';
|
||||||
|
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Message, User } from '@/types';
|
import type { Message, User } from '@/types';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
user?: User;
|
user?: User;
|
||||||
|
thinkingContent?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message, user }: MessageBubbleProps) {
|
export function MessageBubble({ message, user, thinkingContent, isStreaming, error }: MessageBubbleProps) {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// 简单的 Markdown 渲染
|
// 复制消息内容
|
||||||
const renderContent = (content: string) => {
|
const handleCopy = async () => {
|
||||||
// 处理代码块
|
try {
|
||||||
const parts = content.split(/(`[^`]+`)/g);
|
await navigator.clipboard.writeText(message.content);
|
||||||
return parts.map((part, index) => {
|
setCopied(true);
|
||||||
if (part.startsWith('`') && part.endsWith('`')) {
|
setTimeout(() => setCopied(false), 2000);
|
||||||
return (
|
} catch (error) {
|
||||||
<code
|
console.error('Failed to copy:', error);
|
||||||
key={index}
|
|
||||||
className="bg-red-50 text-red-700 px-1.5 py-0.5 rounded text-sm font-mono"
|
|
||||||
>
|
|
||||||
{part.slice(1, -1)}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// 处理粗体
|
|
||||||
const boldParts = part.split(/(\*\*[^*]+\*\*)/g);
|
|
||||||
return boldParts.map((boldPart, boldIndex) => {
|
|
||||||
if (boldPart.startsWith('**') && boldPart.endsWith('**')) {
|
|
||||||
return (
|
|
||||||
<strong key={`${index}-${boldIndex}`} className="font-semibold">
|
|
||||||
{boldPart.slice(2, -2)}
|
|
||||||
</strong>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span key={`${index}-${boldIndex}`}>{boldPart}</span>;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
@ -66,55 +54,78 @@ export function MessageBubble({ message, user }: MessageBubbleProps) {
|
|||||||
|
|
||||||
{/* 消息内容 */}
|
{/* 消息内容 */}
|
||||||
<div className="flex-1 max-w-full">
|
<div className="flex-1 max-w-full">
|
||||||
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-2xl px-6 py-5 shadow-sm">
|
{/* 思考内容 */}
|
||||||
<div className="text-base text-[var(--color-text-primary)] leading-[1.8] whitespace-pre-wrap">
|
{thinkingContent && (
|
||||||
{message.content.split('\n\n').map((paragraph, index) => {
|
<div className="mb-3">
|
||||||
// 处理列表
|
<button
|
||||||
if (paragraph.startsWith('- ') || paragraph.startsWith('* ')) {
|
onClick={() => setThinkingExpanded(!thinkingExpanded)}
|
||||||
const items = paragraph.split('\n');
|
className="inline-flex items-center gap-2 px-3 py-2 bg-purple-50 text-purple-700 rounded-lg text-sm hover:bg-purple-100 transition-colors"
|
||||||
return (
|
>
|
||||||
<ul key={index} className="list-disc pl-5 my-3 space-y-2">
|
<Brain size={16} />
|
||||||
{items.map((item, i) => (
|
<span>思考过程</span>
|
||||||
<li key={i} className="leading-relaxed">
|
{thinkingExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
{renderContent(item.replace(/^[-*]\s/, ''))}
|
</button>
|
||||||
</li>
|
{thinkingExpanded && (
|
||||||
))}
|
<div className="mt-2 p-4 bg-purple-50 border border-purple-200 rounded-lg">
|
||||||
</ul>
|
<pre className="text-sm text-purple-800 whitespace-pre-wrap font-mono">
|
||||||
);
|
{thinkingContent}
|
||||||
}
|
</pre>
|
||||||
// 处理有序列表
|
</div>
|
||||||
if (/^\d+\.\s/.test(paragraph)) {
|
)}
|
||||||
const items = paragraph.split('\n');
|
</div>
|
||||||
return (
|
)}
|
||||||
<ol key={index} className="list-decimal pl-5 my-3 space-y-2">
|
|
||||||
{items.map((item, i) => (
|
{/* 错误提示 */}
|
||||||
<li key={i} className="leading-relaxed marker:text-[var(--color-primary)] marker:font-medium">
|
{error && (
|
||||||
{renderContent(item.replace(/^\d+\.\s/, ''))}
|
<div className="mb-3 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||||
</li>
|
<AlertCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
))}
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
</ol>
|
</div>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
return (
|
{/* 主要内容 */}
|
||||||
<p key={index} className="mb-4 last:mb-0">
|
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-2xl px-5 py-4 shadow-sm">
|
||||||
{renderContent(paragraph)}
|
<div className="text-sm text-[var(--color-text-primary)] leading-[1.75]">
|
||||||
</p>
|
{message.content ? (
|
||||||
);
|
<MarkdownRenderer content={message.content} />
|
||||||
})}
|
) : isStreaming ? (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
<span>正在思考...</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 流式状态指示器 */}
|
||||||
|
{isStreaming && message.content && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>生成中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮(非流式状态下显示) */}
|
||||||
|
{!isStreaming && message.content && (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-1 mt-4 pt-3">
|
<div className="flex items-center gap-1 mt-4 pt-3">
|
||||||
<ActionButton icon={Copy} title="Copy" />
|
<button
|
||||||
<ActionButton icon={ThumbsUp} title="Good response" />
|
onClick={handleCopy}
|
||||||
<ActionButton icon={ThumbsDown} title="Bad response" />
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
<ActionButton icon={RefreshCw} title="Regenerate" />
|
title={copied ? '已复制' : '复制'}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
<ActionButton icon={ThumbsUp} title="好的回答" />
|
||||||
|
<ActionButton icon={ThumbsDown} title="不好的回答" />
|
||||||
|
<ActionButton icon={RefreshCw} title="重新生成" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 免责声明 */}
|
{/* 免责声明 */}
|
||||||
<div className="text-xs text-[var(--color-text-tertiary)] text-right mt-2">
|
<div className="text-xs text-[var(--color-text-tertiary)] text-right mt-2">
|
||||||
cchcode can make mistakes
|
cchcode can make mistakes
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user