Compare commits

..

No commits in common. "d6dc77f63a737d419b76556a1ca1b47e9737aff4" and "3b0683faf9b763a7e2b54af2e04446eb94beb66f" have entirely different histories.

7 changed files with 70 additions and 244 deletions

View File

@ -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 }
);
}
}

View File

@ -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}
/>
))
)}

View File

@ -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>

View File

@ -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>
);
}
/**
*
*

View File

@ -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>
)}

View File

@ -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>
);
}

View File

@ -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,
};
}