feat(聊天): 实现 AI 消息重新生成功能
- 消息 API 新增 DELETE 方法支持删除单条消息 - useStreamChat Hook 添加 regenerateMessage 方法 - 聊天页面添加 handleRegenerate 处理逻辑 - MessageBubble 组件添加重新生成按钮(仅 AI 消息显示) - MessageBubble 使用 Tooltip 替代原生 title 属性 - 移除未使用的 ActionButton 组件和点赞/踩按钮
This commit is contained in:
parent
f50766b742
commit
d6dc77f63a
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,7 @@ export default function ChatPage({ params }: PageProps) {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
setInitialMessages,
|
||||
regenerateMessage,
|
||||
} = useStreamChat();
|
||||
|
||||
// 当前选择的模型和工具
|
||||
@ -265,6 +266,30 @@ 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;
|
||||
@ -506,6 +531,7 @@ export default function ChatPage({ params }: PageProps) {
|
||||
uploadedImages={message.uploadedImages}
|
||||
uploadedDocuments={message.uploadedDocuments}
|
||||
pyodideStatus={message.pyodideStatus}
|
||||
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
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 { 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';
|
||||
@ -30,6 +31,8 @@ interface MessageBubbleProps {
|
||||
message: string;
|
||||
progress?: number;
|
||||
};
|
||||
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
@ -49,7 +52,7 @@ function getDocumentIcon(type: string) {
|
||||
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 [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -158,8 +161,7 @@ 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"
|
||||
title={copied ? '已复制' : '复制'}
|
||||
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"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
@ -307,16 +309,25 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
{!isStreaming && message.content && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 mt-4 pt-3">
|
||||
<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="重新生成" />
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用结果显示组件
|
||||
* 专门处理代码执行结果和图片显示
|
||||
|
||||
@ -527,6 +527,55 @@ 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,
|
||||
@ -535,5 +584,6 @@ export function useStreamChat() {
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
setInitialMessages,
|
||||
regenerateMessage,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user