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,
|
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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
<Tooltip content={copied ? '已复制' : '复制'}>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
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"
|
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"
|
||||||
title={copied ? '已复制' : '复制'}
|
|
||||||
>
|
>
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具调用结果显示组件
|
* 工具调用结果显示组件
|
||||||
* 专门处理代码执行结果和图片显示
|
* 专门处理代码执行结果和图片显示
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user