Compare commits
No commits in common. "d6dc77f63a737d419b76556a1ca1b47e9737aff4" and "3b0683faf9b763a7e2b54af2e04446eb94beb66f" have entirely different histories.
d6dc77f63a
...
3b0683faf9
@ -117,48 +117,3 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/messages/[messageId] - 删除消息
|
||||
* 用于重新生成功能:删除 AI 消息后重新生成
|
||||
*/
|
||||
export async function DELETE(request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const { messageId } = await params;
|
||||
|
||||
if (!messageId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 查找消息
|
||||
const existingMessage = await db.query.messages.findFirst({
|
||||
where: eq(messages.messageId, messageId),
|
||||
});
|
||||
|
||||
if (!existingMessage) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 删除消息
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(eq(messages.messageId, messageId));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Message deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete message error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete message' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,7 +53,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
setInitialMessages,
|
||||
regenerateMessage,
|
||||
} = useStreamChat();
|
||||
|
||||
// 当前选择的模型和工具
|
||||
@ -266,30 +265,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
setEnableThinking(!enableThinking);
|
||||
};
|
||||
|
||||
// 重新生成 AI 消息
|
||||
const handleRegenerate = async (messageId: string) => {
|
||||
if (isStreaming) return; // 正在生成时不允许重新生成
|
||||
|
||||
const result = await regenerateMessage(messageId);
|
||||
if (!result) {
|
||||
console.error('Failed to regenerate message');
|
||||
return;
|
||||
}
|
||||
|
||||
const { userMessage } = result;
|
||||
|
||||
// 重新发送用户消息
|
||||
// 注意:历史消息中的图片已经是 base64 格式,保存在 uploadedImages 中
|
||||
// sendMessage 会重新添加用户消息和 AI 消息占位
|
||||
await sendMessage({
|
||||
conversationId: chatId,
|
||||
message: userMessage.content,
|
||||
model: selectedModelId,
|
||||
tools: enabledTools,
|
||||
enableThinking,
|
||||
});
|
||||
};
|
||||
|
||||
// 切换模型(持久化到数据库)
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
if (!conversation || modelId === selectedModelId) return;
|
||||
@ -531,7 +506,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
uploadedImages={message.uploadedImages}
|
||||
uploadedDocuments={message.uploadedDocuments}
|
||||
pyodideStatus={message.pyodideStatus}
|
||||
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Paperclip, ArrowUp, Upload } from 'lucide-react';
|
||||
import { Plus, ArrowUp, Upload } from 'lucide-react';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { ToolsDropdown } from './ToolsDropdown';
|
||||
import { FilePreviewList } from './FilePreviewList';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Model, Tool } from '@/types';
|
||||
@ -137,18 +136,17 @@ export function ChatInput({
|
||||
{/* 左侧按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 添加附件 */}
|
||||
<Tooltip content="添加附件">
|
||||
<button
|
||||
onClick={openFileDialog}
|
||||
className={cn(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors cursor-pointer',
|
||||
'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]',
|
||||
files.length > 0 && 'text-[var(--color-primary)]'
|
||||
)}
|
||||
>
|
||||
<Paperclip size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={openFileDialog}
|
||||
className={cn(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors',
|
||||
'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]',
|
||||
files.length > 0 && 'text-[var(--color-primary)]'
|
||||
)}
|
||||
title="添加附件"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
@ -178,20 +176,19 @@ export function ChatInput({
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<Tooltip content="发送消息">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() && files.length === 0}
|
||||
className={cn(
|
||||
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150 cursor-pointer',
|
||||
message.trim() || files.length > 0
|
||||
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() && files.length === 0}
|
||||
className={cn(
|
||||
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150',
|
||||
message.trim() || files.length > 0
|
||||
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
title="Send message"
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react';
|
||||
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { AILogo } from '@/components/ui/AILogo';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
||||
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||
@ -31,8 +30,6 @@ interface MessageBubbleProps {
|
||||
message: string;
|
||||
progress?: number;
|
||||
};
|
||||
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
@ -52,7 +49,7 @@ function getDocumentIcon(type: string) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -161,7 +158,8 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
{/* 悬停显示复制按钮 */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -bottom-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-md cursor-pointer"
|
||||
className="absolute -bottom-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-md"
|
||||
title={copied ? '已复制' : '复制'}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
@ -309,25 +307,16 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
{!isStreaming && message.content && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 mt-4 pt-3">
|
||||
<Tooltip content={copied ? '已复制' : '复制'}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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 cursor-pointer"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* 重新生成按钮 */}
|
||||
{onRegenerate && (
|
||||
<Tooltip content="重新生成">
|
||||
<button
|
||||
onClick={() => onRegenerate(message.id)}
|
||||
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 cursor-pointer"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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={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>
|
||||
|
||||
{/* 免责声明 */}
|
||||
@ -357,6 +346,24 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: React.ComponentType<{ size?: number }>;
|
||||
title: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function ActionButton({ icon: Icon, title, onClick }: ActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
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={title}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用结果显示组件
|
||||
* 专门处理代码执行结果和图片显示
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Wrench, Search, Terminal, Globe, Check } from 'lucide-react';
|
||||
import { Toggle } from '@/components/ui/Toggle';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Tool } from '@/types';
|
||||
|
||||
@ -42,21 +41,20 @@ export function ToolsDropdown({ tools, onToolToggle, onEnableAllToggle }: ToolsD
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* 触发按钮 */}
|
||||
<div className="relative">
|
||||
<Tooltip content="工具" position="top">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors cursor-pointer',
|
||||
enabledCount > 0
|
||||
? 'text-[var(--color-primary)] bg-[var(--color-primary-light)]'
|
||||
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
<Wrench size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors',
|
||||
enabledCount > 0
|
||||
? 'text-[var(--color-primary)] bg-[var(--color-primary-light)]'
|
||||
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
title="Tools"
|
||||
>
|
||||
<Wrench size={20} />
|
||||
</button>
|
||||
{enabledCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 bg-[var(--color-primary)] text-white text-[10px] font-semibold rounded-full flex items-center justify-center px-1 pointer-events-none">
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 bg-[var(--color-primary)] text-white text-[10px] font-semibold rounded-full flex items-center justify-center px-1">
|
||||
{enabledCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip 组件
|
||||
* 鼠标悬停时显示提示文字
|
||||
*/
|
||||
export function Tooltip({ children, content, position = 'top', className }: TooltipProps) {
|
||||
const positionStyles = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
};
|
||||
|
||||
const arrowStyles = {
|
||||
top: 'top-full left-1/2 -translate-x-1/2 border-t-gray-800 border-x-transparent border-b-transparent',
|
||||
bottom: 'bottom-full left-1/2 -translate-x-1/2 border-b-gray-800 border-x-transparent border-t-transparent',
|
||||
left: 'left-full top-1/2 -translate-y-1/2 border-l-gray-800 border-y-transparent border-r-transparent',
|
||||
right: 'right-full top-1/2 -translate-y-1/2 border-r-gray-800 border-y-transparent border-l-transparent',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative group/tooltip inline-flex', className)}>
|
||||
{children}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-800 rounded-md',
|
||||
'opacity-0 invisible group-hover/tooltip:opacity-100 group-hover/tooltip:visible',
|
||||
'transition-all duration-200 whitespace-nowrap pointer-events-none',
|
||||
'shadow-lg',
|
||||
positionStyles[position]
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
{/* 小三角箭头 */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-0 h-0 border-4',
|
||||
arrowStyles[position]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -527,55 +527,6 @@ export function useStreamChat() {
|
||||
setMessages(initialMessages);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 重新生成消息
|
||||
* @param assistantMessageId 要重新生成的 AI 消息 ID
|
||||
* @returns 对应的用户消息信息,用于重新发送;如果找不到则返回 null
|
||||
*/
|
||||
const regenerateMessage = useCallback(async (assistantMessageId: string): Promise<{
|
||||
userMessage: ChatMessage;
|
||||
} | null> => {
|
||||
// 1. 在消息列表中找到该 AI 消息的索引
|
||||
const assistantIndex = messages.findIndex(m => m.id === assistantMessageId);
|
||||
if (assistantIndex === -1) {
|
||||
console.error('[regenerateMessage] AI message not found:', assistantMessageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 找到对应的用户消息(AI 消息的前一条)
|
||||
const userIndex = assistantIndex - 1;
|
||||
if (userIndex < 0 || messages[userIndex].role !== 'user') {
|
||||
console.error('[regenerateMessage] User message not found for AI message:', assistantMessageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const userMessage = { ...messages[userIndex] };
|
||||
|
||||
// 3. 尝试从数据库删除 AI 消息(只有保存到数据库的消息才需要删除)
|
||||
// 临时消息 ID 格式为 'assistant-timestamp',数据库消息 ID 是 nanoid 格式
|
||||
if (!assistantMessageId.startsWith('assistant-')) {
|
||||
try {
|
||||
const response = await fetch(`/api/messages/${assistantMessageId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('[regenerateMessage] Failed to delete message from database:', await response.text());
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[regenerateMessage] Error deleting message from database:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 从前端状态中移除用户消息、AI 消息及其后的所有消息
|
||||
// 因为 sendMessage 会重新添加用户消息和 AI 消息占位
|
||||
setMessages(prev => prev.slice(0, userIndex));
|
||||
|
||||
// 5. 返回用户消息信息
|
||||
return {
|
||||
userMessage,
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
@ -584,6 +535,5 @@ export function useStreamChat() {
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
setInitialMessages,
|
||||
regenerateMessage,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user