fix: 登录后自动加载对话历史;新增生产部署指南 DEPLOY.md
- 修复 useChat useEffect 只在挂载时执行导致的登录后无历史问题 - 暴露 reloadConversations 函数,App.tsx 登录成功后主动调用 - 添加 DEPLOY.md 生产部署文档(Nginx + PHP-FPM) 2026-05-15
This commit is contained in:
+72
-17
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
Check,
|
||||
Menu,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import type { Conversation, Message } from '@/types';
|
||||
|
||||
@@ -18,6 +20,7 @@ interface ChatAreaProps {
|
||||
conversation: Conversation | null;
|
||||
isStreaming: boolean;
|
||||
onSendMessage: (content: string) => void;
|
||||
onRegenerateMessage: (messageId: string) => void;
|
||||
onStopStreaming: () => void;
|
||||
sidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
@@ -60,8 +63,9 @@ function CodeBlock({ language, value }: { language: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message, isStreaming }: { message: Message; isStreaming: boolean }) {
|
||||
function MessageBubble({ message, isStreaming, onRegenerate }: { message: Message; isStreaming: boolean; onRegenerate?: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
@@ -101,9 +105,30 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
|
||||
</span>
|
||||
</div>
|
||||
<div className="group relative">
|
||||
{/* 思考过程(可折叠) */}
|
||||
{message.thinking && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setThinkingOpen(!thinkingOpen)}
|
||||
className="flex items-center gap-1.5 text-xs text-[#888] hover:text-[#16a34a] transition-colors"
|
||||
>
|
||||
<svg className={`w-3 h-3 transition-transform ${thinkingOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>思考过程</span>
|
||||
<span className="text-[#bbb]">({message.thinking.length} 字)</span>
|
||||
</button>
|
||||
{thinkingOpen && (
|
||||
<div className="mt-2 p-3 bg-[#f8f8f8] border border-[#e5e5e5] rounded-lg text-xs text-[#888] leading-relaxed whitespace-pre-wrap font-mono">
|
||||
{message.thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.content || isStreaming ? (
|
||||
<div className="text-[#d4d4d4] text-sm leading-relaxed">
|
||||
<div className="text-[#333] text-sm leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
@@ -176,7 +201,7 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
|
||||
{message.content || (isStreaming ? '' : '')}
|
||||
</ReactMarkdown>
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{/* 流式输出光标 */}
|
||||
{isStreaming && !message.content && (
|
||||
<span className="inline-block w-2 h-4 bg-[#0f0] animate-pulse ml-0.5" />
|
||||
)}
|
||||
@@ -189,15 +214,37 @@ function MessageBubble({ message, isStreaming }: { message: Message; isStreaming
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy button for assistant messages */}
|
||||
{message.content && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -right-8 top-0 p-1 text-[#ccc] hover:text-[#16a34a] opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{/* Token 用量 */}
|
||||
{(message.promptTokens || message.completionTokens) && (
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-[#bbb]">
|
||||
{message.promptTokens ? <span>输入: {message.promptTokens} tokens</span> : null}
|
||||
{message.completionTokens ? <span>输出: {message.completionTokens} tokens</span> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮:复制 + 重新生成 */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{message.content && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-[10px] text-[#bbb] hover:text-[#16a34a] transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<span>{copied ? '已复制' : '复制'}</span>
|
||||
</button>
|
||||
)}
|
||||
{onRegenerate && message.content && !isStreaming && (
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="flex items-center gap-1 text-[10px] text-[#bbb] hover:text-[#16a34a] transition-colors"
|
||||
title="重新生成"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
<span>重新生成</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,6 +255,7 @@ export default function ChatArea({
|
||||
conversation,
|
||||
isStreaming,
|
||||
onSendMessage,
|
||||
onRegenerateMessage,
|
||||
onStopStreaming,
|
||||
sidebarOpen,
|
||||
onToggleSidebar,
|
||||
@@ -216,7 +264,7 @@ export default function ChatArea({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
// 输入框自动调整高度
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
@@ -225,7 +273,7 @@ export default function ChatArea({
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [conversation?.messages]);
|
||||
@@ -249,7 +297,7 @@ export default function ChatArea({
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white relative">
|
||||
{/* Header */}
|
||||
{/* 顶部标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1a1a1a]">
|
||||
<div className="flex items-center gap-3">
|
||||
{!sidebarOpen && (
|
||||
@@ -279,7 +327,7 @@ export default function ChatArea({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
{/* 消息列表区域 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-8 py-6 custom-scrollbar">
|
||||
{!conversation || conversation.messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-[#bbb]">
|
||||
@@ -310,11 +358,18 @@ export default function ChatArea({
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{conversation.messages.map(message => (
|
||||
{conversation.messages.map((message, idx) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming && message.role === 'assistant' && message.id === conversation.messages[conversation.messages.length - 1]?.id}
|
||||
onRegenerate={message.role === 'user' ? undefined : () => {
|
||||
// 找到该助手消息对应的用户消息
|
||||
const userMsg = conversation.messages[idx - 1];
|
||||
if (userMsg && userMsg.role === 'user') {
|
||||
onRegenerateMessage(userMsg.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
@@ -322,7 +377,7 @@ export default function ChatArea({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
{/* 输入区域 */}
|
||||
<div className="px-4 md:px-8 pb-6 pt-4">
|
||||
<div className="max-w-3xl mx-auto relative">
|
||||
<div className="flex items-end gap-3 bg-white border border-[#d0d0d0] rounded-xl p-4 shadow-md transition-all">
|
||||
|
||||
Reference in New Issue
Block a user