Compare commits

..

2 Commits

Author SHA1 Message Date
gaoziman
d6dc77f63a feat(聊天): 实现 AI 消息重新生成功能
- 消息 API 新增 DELETE 方法支持删除单条消息
- useStreamChat Hook 添加 regenerateMessage 方法
- 聊天页面添加 handleRegenerate 处理逻辑
- MessageBubble 组件添加重新生成按钮(仅 AI 消息显示)
- MessageBubble 使用 Tooltip 替代原生 title 属性
- 移除未使用的 ActionButton 组件和点赞/踩按钮
2025-12-21 14:33:08 +08:00
gaoziman
f50766b742 feat(组件): 新增 Tooltip 组件并优化输入区交互体验
- 新增通用 Tooltip 组件,支持上下左右四个方向显示
- ChatInput 组件使用 Tooltip 替代原生 title 属性
- ChatInput 添加附件图标改为 Paperclip,更直观
- ToolsDropdown 工具按钮添加 Tooltip 提示
- 优化按钮 cursor 样式,提升交互体验
2025-12-21 14:32:48 +08:00
7 changed files with 244 additions and 70 deletions

View File

@ -117,3 +117,48 @@ 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,6 +53,7 @@ export default function ChatPage({ params }: PageProps) {
sendMessage, sendMessage,
stopGeneration, stopGeneration,
setInitialMessages, setInitialMessages,
regenerateMessage,
} = useStreamChat(); } = useStreamChat();
// 当前选择的模型和工具 // 当前选择的模型和工具
@ -265,6 +266,30 @@ export default function ChatPage({ params }: PageProps) {
setEnableThinking(!enableThinking); 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) => { const handleModelChange = async (modelId: string) => {
if (!conversation || modelId === selectedModelId) return; if (!conversation || modelId === selectedModelId) return;
@ -506,6 +531,7 @@ export default function ChatPage({ params }: PageProps) {
uploadedImages={message.uploadedImages} uploadedImages={message.uploadedImages}
uploadedDocuments={message.uploadedDocuments} uploadedDocuments={message.uploadedDocuments}
pyodideStatus={message.pyodideStatus} pyodideStatus={message.pyodideStatus}
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
/> />
)) ))
)} )}

View File

@ -1,10 +1,11 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Plus, ArrowUp, Upload } from 'lucide-react'; import { Paperclip, ArrowUp, Upload } from 'lucide-react';
import { ModelSelector } from './ModelSelector'; import { ModelSelector } from './ModelSelector';
import { ToolsDropdown } from './ToolsDropdown'; import { ToolsDropdown } from './ToolsDropdown';
import { FilePreviewList } from './FilePreviewList'; import { FilePreviewList } from './FilePreviewList';
import { Tooltip } from '@/components/ui/Tooltip';
import { useFileUpload } from '@/hooks/useFileUpload'; import { useFileUpload } from '@/hooks/useFileUpload';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Model, Tool } from '@/types'; import type { Model, Tool } from '@/types';
@ -136,17 +137,18 @@ export function ChatInput({
{/* 左侧按钮 */} {/* 左侧按钮 */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* 添加附件 */} {/* 添加附件 */}
<button <Tooltip content="添加附件">
onClick={openFileDialog} <button
className={cn( onClick={openFileDialog}
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors', className={cn(
'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]', 'w-8 h-8 flex items-center justify-center rounded-lg transition-colors cursor-pointer',
files.length > 0 && 'text-[var(--color-primary)]' '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} /> <Paperclip size={20} />
</button> </button>
</Tooltip>
{/* 隐藏的文件输入 */} {/* 隐藏的文件输入 */}
<input <input
@ -176,19 +178,20 @@ export function ChatInput({
/> />
{/* 发送按钮 */} {/* 发送按钮 */}
<button <Tooltip content="发送消息">
onClick={handleSend} <button
disabled={!message.trim() && files.length === 0} onClick={handleSend}
className={cn( disabled={!message.trim() && files.length === 0}
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150', className={cn(
message.trim() || files.length > 0 'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150 cursor-pointer',
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5' message.trim() || files.length > 0
: 'opacity-50 cursor-not-allowed' ? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
)} : 'opacity-50 cursor-not-allowed'
title="Send message" )}
> >
<ArrowUp size={18} /> <ArrowUp size={18} />
</button> </button>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react'; import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } 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 { Tooltip } from '@/components/ui/Tooltip';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer'; import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult'; import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
import { ImageLightbox } from '@/components/ui/ImageLightbox'; import { ImageLightbox } from '@/components/ui/ImageLightbox';
@ -30,6 +31,8 @@ interface MessageBubbleProps {
message: string; message: string;
progress?: number; progress?: number;
}; };
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
onRegenerate?: (messageId: string) => void;
} }
// 格式化文件大小 // 格式化文件大小
@ -49,7 +52,7 @@ function getDocumentIcon(type: string) {
return FileText; return FileText;
} }
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus }: MessageBubbleProps) { export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate }: MessageBubbleProps) {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -158,8 +161,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
{/* 悬停显示复制按钮 */} {/* 悬停显示复制按钮 */}
<button <button
onClick={handleCopy} 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" 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"
title={copied ? '已复制' : '复制'}
> >
{copied ? ( {copied ? (
<> <>
@ -307,16 +309,25 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
{!isStreaming && message.content && ( {!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">
<button <Tooltip content={copied ? '已复制' : '复制'}>
onClick={handleCopy} <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" onClick={handleCopy}
title={copied ? '已复制' : '复制'} 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} />} {copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button> </button>
<ActionButton icon={ThumbsUp} title="好的回答" /> </Tooltip>
<ActionButton icon={ThumbsDown} title="不好的回答" /> {/* 重新生成按钮 */}
<ActionButton icon={RefreshCw} title="重新生成" /> {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>
)}
</div> </div>
{/* 免责声明 */} {/* 免责声明 */}
@ -346,24 +357,6 @@ 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,6 +3,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Wrench, Search, Terminal, Globe, Check } from 'lucide-react'; import { Wrench, Search, Terminal, Globe, Check } from 'lucide-react';
import { Toggle } from '@/components/ui/Toggle'; import { Toggle } from '@/components/ui/Toggle';
import { Tooltip } from '@/components/ui/Tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Tool } from '@/types'; import type { Tool } from '@/types';
@ -41,20 +42,21 @@ export function ToolsDropdown({ tools, onToolToggle, onEnableAllToggle }: ToolsD
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
{/* 触发按钮 */} {/* 触发按钮 */}
<div className="relative"> <div className="relative">
<button <Tooltip content="工具" position="top">
onClick={() => setIsOpen(!isOpen)} <button
className={cn( onClick={() => setIsOpen(!isOpen)}
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors', className={cn(
enabledCount > 0 'w-8 h-8 flex items-center justify-center rounded-lg transition-colors cursor-pointer',
? 'text-[var(--color-primary)] bg-[var(--color-primary-light)]' enabledCount > 0
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]' ? '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} /> <Wrench size={20} />
</button> </button>
</Tooltip>
{enabledCount > 0 && ( {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"> <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">
{enabledCount} {enabledCount}
</span> </span>
)} )}

View File

@ -0,0 +1,55 @@
'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,6 +527,55 @@ export function useStreamChat() {
setMessages(initialMessages); 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 { return {
messages, messages,
isStreaming, isStreaming,
@ -535,5 +584,6 @@ export function useStreamChat() {
stopGeneration, stopGeneration,
clearMessages, clearMessages,
setInitialMessages, setInitialMessages,
regenerateMessage,
}; };
} }