Compare commits
No commits in common. "56b7ffa68dd9d179a5da64ba795f5eadf519b2a5" and "6047af071cb2ae624823ad79046070e2853ec60b" have entirely different histories.
56b7ffa68d
...
6047af071c
@ -27,7 +27,6 @@
|
||||
"nodemailer": "^7.0.11",
|
||||
"pg": "^8.16.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@ -50,9 +50,6 @@ importers:
|
||||
prismjs:
|
||||
specifier: ^1.30.0
|
||||
version: 1.30.0
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@19.2.1)
|
||||
react:
|
||||
specifier: 19.2.1
|
||||
version: 19.2.1
|
||||
@ -3040,11 +3037,6 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode.react@4.2.0:
|
||||
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@ -6898,10 +6890,6 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode.react@4.2.0(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
raf@3.4.1:
|
||||
|
||||
@ -1,203 +0,0 @@
|
||||
/**
|
||||
* 对话分享 API
|
||||
* POST /api/conversations/[id]/share - 创建分享
|
||||
* GET /api/conversations/[id]/share - 获取对话的分享列表
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { conversations, sharedConversations } from '@/drizzle/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* 生成短链接码(8位字母数字)
|
||||
*/
|
||||
function generateShareCode(): string {
|
||||
return nanoid(8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分享
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// 验证用户身份
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: conversationId } = await params;
|
||||
|
||||
// 获取请求体
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
includeThinking = true,
|
||||
includeToolCalls = false,
|
||||
includeImages = true,
|
||||
selectedMessageIds = null, // 新增:选择的消息ID,null 表示全部
|
||||
} = body;
|
||||
|
||||
// 验证对话存在且属于当前用户
|
||||
const conversation = await db.query.conversations.findFirst({
|
||||
where: eq(conversations.conversationId, conversationId),
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (conversation.userId !== user.userId) {
|
||||
return NextResponse.json({ error: '无权分享此对话' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 生成分享信息
|
||||
const shareId = nanoid();
|
||||
const shareCode = generateShareCode();
|
||||
|
||||
// 创建分享记录
|
||||
await db.insert(sharedConversations).values({
|
||||
shareId,
|
||||
conversationId,
|
||||
userId: user.userId,
|
||||
shareCode,
|
||||
title: title || conversation.title,
|
||||
description,
|
||||
includeThinking,
|
||||
includeToolCalls,
|
||||
includeImages,
|
||||
snapshotAt: new Date(), // 记录快照时间点
|
||||
selectedMessageIds, // 新增:存储选择的消息ID
|
||||
});
|
||||
|
||||
// 构建分享 URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||
const shareUrl = `${baseUrl}/share/${shareCode}`;
|
||||
|
||||
return NextResponse.json({
|
||||
shareId,
|
||||
shareCode,
|
||||
shareUrl,
|
||||
title: title || conversation.title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create share error:', error);
|
||||
return NextResponse.json({ error: '创建分享失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话的分享列表
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// 验证用户身份
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: conversationId } = await params;
|
||||
|
||||
// 验证对话存在且属于当前用户
|
||||
const conversation = await db.query.conversations.findFirst({
|
||||
where: eq(conversations.conversationId, conversationId),
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (conversation.userId !== user.userId) {
|
||||
return NextResponse.json({ error: '无权访问' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 获取分享列表
|
||||
const shares = await db.query.sharedConversations.findMany({
|
||||
where: and(
|
||||
eq(sharedConversations.conversationId, conversationId),
|
||||
eq(sharedConversations.isActive, true)
|
||||
),
|
||||
orderBy: (shares, { desc }) => [desc(shares.createdAt)],
|
||||
});
|
||||
|
||||
// 构建分享 URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||
|
||||
return NextResponse.json({
|
||||
shares: shares.map((share) => ({
|
||||
shareId: share.shareId,
|
||||
shareCode: share.shareCode,
|
||||
shareUrl: `${baseUrl}/share/${share.shareCode}`,
|
||||
title: share.title,
|
||||
description: share.description,
|
||||
viewCount: share.viewCount,
|
||||
includeThinking: share.includeThinking,
|
||||
includeToolCalls: share.includeToolCalls,
|
||||
includeImages: share.includeImages,
|
||||
createdAt: share.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get shares error:', error);
|
||||
return NextResponse.json({ error: '获取分享列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分享
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// 验证用户身份
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 从查询参数获取 shareId
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shareId = searchParams.get('shareId');
|
||||
|
||||
if (!shareId) {
|
||||
return NextResponse.json({ error: '缺少 shareId 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证分享存在且属于当前用户
|
||||
const share = await db.query.sharedConversations.findFirst({
|
||||
where: eq(sharedConversations.shareId, shareId),
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json({ error: '分享不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (share.userId !== user.userId) {
|
||||
return NextResponse.json({ error: '无权删除此分享' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 软删除(设置 isActive 为 false)
|
||||
await db
|
||||
.update(sharedConversations)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(sharedConversations.shareId, shareId));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete share error:', error);
|
||||
return NextResponse.json({ error: '删除分享失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 公开分享内容 API
|
||||
* GET /api/share/[code] - 获取分享的对话内容(无需登录)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { sharedConversations, conversations, messages } from '@/drizzle/schema';
|
||||
import { eq, asc, and, lte, inArray } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* 获取分享的对话内容
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
try {
|
||||
const { code: shareCode } = await params;
|
||||
|
||||
// 查找分享记录
|
||||
const share = await db.query.sharedConversations.findFirst({
|
||||
where: eq(sharedConversations.shareCode, shareCode),
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json({ error: '分享不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查分享是否激活
|
||||
if (!share.isActive) {
|
||||
return NextResponse.json({ error: '分享已失效' }, { status: 410 });
|
||||
}
|
||||
|
||||
// 获取对话信息
|
||||
const conversation = await db.query.conversations.findFirst({
|
||||
where: eq(conversations.conversationId, share.conversationId),
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 获取消息列表
|
||||
// 优先级:selectedMessageIds > snapshotAt > 全部
|
||||
let messageList;
|
||||
|
||||
if (share.selectedMessageIds && share.selectedMessageIds.length > 0) {
|
||||
// 如果指定了消息ID,按消息ID过滤
|
||||
messageList = await db.query.messages.findMany({
|
||||
where: and(
|
||||
eq(messages.conversationId, share.conversationId),
|
||||
inArray(messages.messageId, share.selectedMessageIds)
|
||||
),
|
||||
orderBy: [asc(messages.createdAt)],
|
||||
});
|
||||
} else if (share.snapshotAt) {
|
||||
// 否则按快照时间过滤
|
||||
messageList = await db.query.messages.findMany({
|
||||
where: and(
|
||||
eq(messages.conversationId, share.conversationId),
|
||||
lte(messages.createdAt, share.snapshotAt)
|
||||
),
|
||||
orderBy: [asc(messages.createdAt)],
|
||||
});
|
||||
} else {
|
||||
// 获取全部消息
|
||||
messageList = await db.query.messages.findMany({
|
||||
where: eq(messages.conversationId, share.conversationId),
|
||||
orderBy: [asc(messages.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// 根据分享设置过滤消息内容
|
||||
const filteredMessages = messageList.map((msg) => ({
|
||||
id: msg.messageId,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
// 根据设置决定是否包含思考内容
|
||||
thinkingContent: share.includeThinking ? msg.thinkingContent : null,
|
||||
// 根据设置决定是否包含工具调用
|
||||
toolCalls: share.includeToolCalls ? msg.toolCalls : null,
|
||||
// 根据设置决定是否包含图片
|
||||
images: share.includeImages ? msg.images : null,
|
||||
searchImages: share.includeImages ? msg.searchImages : null,
|
||||
uploadedImages: share.includeImages ? msg.uploadedImages : null,
|
||||
// 使用的工具(简化显示)
|
||||
usedTools: msg.usedTools,
|
||||
// Token 统计
|
||||
inputTokens: msg.inputTokens,
|
||||
outputTokens: msg.outputTokens,
|
||||
// 时间
|
||||
createdAt: msg.createdAt,
|
||||
}));
|
||||
|
||||
// 更新查看次数(异步,不阻塞响应)
|
||||
db.update(sharedConversations)
|
||||
.set({
|
||||
viewCount: (share.viewCount || 0) + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(sharedConversations.shareId, share.shareId))
|
||||
.then(() => {})
|
||||
.catch((err) => console.error('Update view count error:', err));
|
||||
|
||||
return NextResponse.json({
|
||||
// 分享信息
|
||||
share: {
|
||||
title: share.title,
|
||||
description: share.description,
|
||||
viewCount: (share.viewCount || 0) + 1,
|
||||
createdAt: share.createdAt,
|
||||
},
|
||||
// 对话信息
|
||||
conversation: {
|
||||
id: conversation.conversationId,
|
||||
title: conversation.title,
|
||||
model: conversation.model,
|
||||
messageCount: filteredMessages.length, // 使用快照中的实际消息数量
|
||||
totalTokens: conversation.totalTokens,
|
||||
createdAt: conversation.createdAt,
|
||||
},
|
||||
// 消息列表
|
||||
messages: filteredMessages,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get shared content error:', error);
|
||||
return NextResponse.json({ error: '获取分享内容失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,6 @@ import { SaveToNoteModal } from '@/components/features/SaveToNoteModal';
|
||||
import { PromptOptimizer } from '@/components/features/PromptOptimizer';
|
||||
import { LinkPreviewModal } from '@/components/features/LinkPreviewModal';
|
||||
import { ExportDropdown } from '@/components/features/ExportDropdown';
|
||||
import { ShareModal } from '@/components/features/ShareModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConversation, useConversations } from '@/hooks/useConversations';
|
||||
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
|
||||
@ -54,9 +53,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
const [linkPreviewOpen, setLinkPreviewOpen] = useState(false);
|
||||
const [linkPreviewUrl, setLinkPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// 分享弹窗状态
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
|
||||
// 获取数据
|
||||
const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId);
|
||||
const { createConversation, updateConversation, deleteConversation } = useConversations();
|
||||
@ -502,7 +498,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShareModalOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||
title="分享对话"
|
||||
>
|
||||
@ -651,16 +646,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
onClose={() => setLinkPreviewOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 分享弹窗 */}
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
conversationId={chatId}
|
||||
conversationTitle={conversation?.title || '新对话'}
|
||||
messages={messages.map(m => ({ id: m.id, role: m.role, content: m.content }))}
|
||||
onPreview={handleLinkClick}
|
||||
/>
|
||||
|
||||
{/* 提示词优化工具浮动按钮 */}
|
||||
<PromptOptimizer onUsePrompt={setOptimizedPrompt} />
|
||||
</div>
|
||||
|
||||
@ -1,394 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, use, useCallback, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Sparkles,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||
import { ShareNavigator } from '@/components/features/ShareNavigator';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ code: string }>;
|
||||
}
|
||||
|
||||
interface ShareData {
|
||||
share: {
|
||||
title: string;
|
||||
description?: string;
|
||||
viewCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
conversation: {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
totalTokens: number;
|
||||
createdAt: string;
|
||||
};
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
thinkingContent?: string | null;
|
||||
toolCalls?: unknown[] | null;
|
||||
images?: string[] | null;
|
||||
searchImages?: Array<{
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}> | null;
|
||||
uploadedImages?: string[] | null;
|
||||
usedTools?: string[] | null;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 工具名称中文映射
|
||||
const TOOL_NAMES: Record<string, string> = {
|
||||
web_search: '网络搜索',
|
||||
web_fetch: '网页读取',
|
||||
mita_search: '秘塔搜索',
|
||||
mita_reader: '秘塔阅读',
|
||||
code_execution: '代码执行',
|
||||
youdao_translate: '有道翻译',
|
||||
image_search: '图片搜索',
|
||||
video_search: '视频搜索',
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default function SharePage({ params }: PageProps) {
|
||||
const { code } = use(params);
|
||||
const [data, setData] = useState<ShareData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchShare = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/share/${code}`);
|
||||
if (!response.ok) {
|
||||
const errData = await response.json();
|
||||
throw new Error(errData.error || '加载失败');
|
||||
}
|
||||
const shareData = await response.json();
|
||||
setData(shareData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchShare();
|
||||
}, [code]);
|
||||
|
||||
// 页面加载后处理 URL hash 定位
|
||||
useEffect(() => {
|
||||
if (!data || loading) return;
|
||||
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
// 支持 #msg-{id} 或 #msg-{index} 格式
|
||||
const targetId = hash.slice(1); // 移除 #
|
||||
const element = document.getElementById(targetId);
|
||||
if (element) {
|
||||
// 延迟滚动以确保页面渲染完成
|
||||
setTimeout(() => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Copy error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载中
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#DB6639]" />
|
||||
<span className="text-gray-500">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-medium text-gray-700 mb-2">{error}</h1>
|
||||
<p className="text-gray-500 mb-6">此分享链接可能已失效或不存在</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#DB6639] text-white rounded-lg hover:bg-[#C25A33] transition-colors"
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
开始使用 LionCode
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 顶部导航 */}
|
||||
<header className="sticky top-0 z-10 bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-800">LionCode</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg transition-colors',
|
||||
copied
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
<span>{copied ? '已复制' : '复制链接'}</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-[#DB6639] text-white rounded-lg hover:bg-[#C25A33] transition-colors"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>开始使用</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 分享信息头部 */}
|
||||
<div className="bg-gradient-to-r from-[#DB6639] to-[#E8845C] text-white">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-3">{data.share.title}</h1>
|
||||
{data.share.description && (
|
||||
<p className="text-white/80 mb-4">{data.share.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-white/70">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{formatDate(data.share.createdAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={14} />
|
||||
{data.share.viewCount} 次查看
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={14} />
|
||||
{data.conversation.messageCount} 条消息
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-white/20 rounded text-xs">
|
||||
{data.conversation.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="space-y-4">
|
||||
{data.messages.map((message, idx) => {
|
||||
// 计算用户消息索引(用于目录导航)
|
||||
const userMsgIndex = message.role === 'user'
|
||||
? data.messages.slice(0, idx + 1).filter(m => m.role === 'user').length
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
id={`msg-${message.id}`}
|
||||
data-msg-index={userMsgIndex}
|
||||
className={cn(
|
||||
'rounded-lg overflow-hidden scroll-mt-20',
|
||||
message.role === 'user'
|
||||
? 'ml-12'
|
||||
: 'bg-white border border-gray-200 shadow-sm'
|
||||
)}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
// 用户消息
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 bg-gray-200 rounded-lg p-4">
|
||||
{/* 用户上传的图片 */}
|
||||
{message.uploadedImages && message.uploadedImages.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{message.uploadedImages.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt={`上传图片 ${idx + 1}`}
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-gray-800 whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-9 h-9 bg-[#DB6639] rounded-full flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
|
||||
U
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// AI 消息
|
||||
<div>
|
||||
{/* 消息头部 */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<div className="w-7 h-7 bg-[#DB6639] rounded-lg flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-white" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-800 text-sm">LionCode AI</span>
|
||||
{message.usedTools && message.usedTools.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
{message.usedTools.map((tool, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-orange-100 text-orange-700 text-xs rounded-full"
|
||||
>
|
||||
{TOOL_NAMES[tool] || tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 思考内容 */}
|
||||
{message.thinkingContent && (
|
||||
<details className="mx-4 mt-3 bg-orange-50 rounded-lg border border-orange-100">
|
||||
<summary className="px-4 py-2 cursor-pointer text-sm font-medium text-orange-700 hover:bg-orange-100 rounded-lg">
|
||||
💭 思考过程
|
||||
</summary>
|
||||
<div className="px-4 py-3 text-sm text-gray-600 whitespace-pre-wrap border-t border-orange-100">
|
||||
{message.thinkingContent}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="px-4 py-4">
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className="text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 代码执行图片 */}
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="text-sm font-medium text-gray-600 mb-2">📊 代码执行结果</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{message.images.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img.startsWith('data:') ? img : `data:image/png;base64,${img}`}
|
||||
alt={`图表 ${idx + 1}`}
|
||||
className="w-full rounded-lg border border-gray-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索图片 */}
|
||||
{message.searchImages && message.searchImages.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="text-sm font-medium text-gray-600 mb-2">🖼️ 搜索结果图片</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{message.searchImages.slice(0, 8).map((img, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={img.imageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-lg overflow-hidden hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={img.imageUrl}
|
||||
alt={img.title || `图片 ${idx + 1}`}
|
||||
className="w-full h-20 object-cover"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token 统计 */}
|
||||
{(message.inputTokens || message.outputTokens) && (
|
||||
<div className="px-4 pb-3 text-xs text-gray-400">
|
||||
Token: 输入 {message.inputTokens?.toLocaleString() || 0} / 输出 {message.outputTokens?.toLocaleString() || 0}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 分享导航组件 */}
|
||||
<ShareNavigator messages={data.messages} />
|
||||
|
||||
{/* 底部 */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-8">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<span className="font-semibold text-gray-800">LionCode AI</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-4">智能对话,助力创作</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 bg-[#DB6639] text-white rounded-lg hover:bg-[#C25A33] transition-colors"
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
开始免费使用 LionCode
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,344 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Search, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 消息接口
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 问答轮次接口
|
||||
interface Round {
|
||||
index: number;
|
||||
userMessage: Message;
|
||||
assistantMessage?: Message;
|
||||
preview: string;
|
||||
messageIds: string[];
|
||||
}
|
||||
|
||||
// 选择模式
|
||||
type SelectionMode = 'all' | 'recent_1' | 'recent_3' | 'recent_5' | 'custom';
|
||||
|
||||
interface MessageSelectorProps {
|
||||
messages: Message[];
|
||||
selectedMessageIds: string[] | null;
|
||||
onSelectionChange: (messageIds: string[] | null) => void;
|
||||
}
|
||||
|
||||
export function MessageSelector({
|
||||
messages,
|
||||
selectedMessageIds,
|
||||
onSelectionChange,
|
||||
}: MessageSelectorProps) {
|
||||
const [selectionMode, setSelectionMode] = useState<SelectionMode>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 将消息按问答轮次分组
|
||||
const rounds = useMemo(() => {
|
||||
const result: Round[] = [];
|
||||
let currentRound: Round | null = null;
|
||||
|
||||
messages.forEach((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
if (currentRound) {
|
||||
result.push(currentRound);
|
||||
}
|
||||
currentRound = {
|
||||
index: result.length + 1,
|
||||
userMessage: msg,
|
||||
preview: msg.content.slice(0, 50) + (msg.content.length > 50 ? '...' : ''),
|
||||
messageIds: [msg.id],
|
||||
};
|
||||
} else if (msg.role === 'assistant' && currentRound) {
|
||||
currentRound.assistantMessage = msg;
|
||||
currentRound.messageIds.push(msg.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (currentRound) {
|
||||
result.push(currentRound);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [messages]);
|
||||
|
||||
// 根据消息数量生成选项
|
||||
const modeOptions = useMemo(() => {
|
||||
const options: { value: SelectionMode; label: string }[] = [
|
||||
{ value: 'all', label: '全部对话' },
|
||||
];
|
||||
|
||||
if (rounds.length > 1) {
|
||||
options.push({ value: 'recent_1', label: '最近 1 轮' });
|
||||
}
|
||||
if (rounds.length > 3) {
|
||||
options.push({ value: 'recent_3', label: '最近 3 轮' });
|
||||
}
|
||||
if (rounds.length > 5) {
|
||||
options.push({ value: 'recent_5', label: '最近 5 轮' });
|
||||
}
|
||||
if (rounds.length > 1) {
|
||||
options.push({ value: 'custom', label: '自定义选择' });
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [rounds.length]);
|
||||
|
||||
// 根据选择模式获取选中的消息ID
|
||||
const getMessageIdsByMode = useCallback(
|
||||
(mode: SelectionMode): string[] | null => {
|
||||
if (mode === 'all') return null;
|
||||
if (mode === 'custom') return selectedMessageIds;
|
||||
|
||||
const recentCount = parseInt(mode.replace('recent_', ''));
|
||||
const recentRounds = rounds.slice(-recentCount);
|
||||
return recentRounds.flatMap((r) => r.messageIds);
|
||||
},
|
||||
[rounds, selectedMessageIds]
|
||||
);
|
||||
|
||||
// 处理模式变化
|
||||
const handleModeChange = (mode: SelectionMode) => {
|
||||
setSelectionMode(mode);
|
||||
if (mode === 'custom') {
|
||||
if (selectedMessageIds === null) {
|
||||
onSelectionChange(messages.map((m) => m.id));
|
||||
}
|
||||
} else {
|
||||
onSelectionChange(getMessageIdsByMode(mode));
|
||||
}
|
||||
};
|
||||
|
||||
// 当前选中的轮次
|
||||
const selectedRoundIndices = useMemo(() => {
|
||||
if (selectedMessageIds === null) return new Set(rounds.map((r) => r.index));
|
||||
return new Set(
|
||||
rounds
|
||||
.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id)))
|
||||
.map((r) => r.index)
|
||||
);
|
||||
}, [rounds, selectedMessageIds]);
|
||||
|
||||
// 切换轮次选择
|
||||
const toggleRound = (round: Round) => {
|
||||
if (selectionMode !== 'custom') return;
|
||||
|
||||
if (selectedMessageIds === null) {
|
||||
const allIds = messages.map((m) => m.id);
|
||||
const newIds = allIds.filter((id) => !round.messageIds.includes(id));
|
||||
onSelectionChange(newIds);
|
||||
} else {
|
||||
const isSelected = round.messageIds.some((id) => selectedMessageIds.includes(id));
|
||||
if (isSelected) {
|
||||
const newIds = selectedMessageIds.filter((id) => !round.messageIds.includes(id));
|
||||
onSelectionChange(newIds.length > 0 ? newIds : []);
|
||||
} else {
|
||||
onSelectionChange([...selectedMessageIds, ...round.messageIds]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 快捷操作
|
||||
const handleSelectAll = () => {
|
||||
onSelectionChange(messages.map((m) => m.id));
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
const handleSelectInverse = () => {
|
||||
if (selectedMessageIds === null) {
|
||||
onSelectionChange([]);
|
||||
} else {
|
||||
const allIds = messages.map((m) => m.id);
|
||||
const inverseIds = allIds.filter((id) => !selectedMessageIds.includes(id));
|
||||
onSelectionChange(inverseIds);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤
|
||||
const filteredRounds = useMemo(() => {
|
||||
if (!searchQuery.trim()) return rounds;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return rounds.filter(
|
||||
(round) =>
|
||||
round.userMessage.content.toLowerCase().includes(query) ||
|
||||
round.assistantMessage?.content.toLowerCase().includes(query)
|
||||
);
|
||||
}, [rounds, searchQuery]);
|
||||
|
||||
// 计算选中数量
|
||||
const selectedCount = useMemo(() => {
|
||||
if (selectedMessageIds === null) return rounds.length;
|
||||
return rounds.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id))).length;
|
||||
}, [rounds, selectedMessageIds]);
|
||||
|
||||
// 如果只有一轮对话,不显示选择器
|
||||
if (rounds.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCustomMode = selectionMode === 'custom';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 标题行 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
选择分享范围
|
||||
</label>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
共 {rounds.length} 轮对话
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 左右分栏布局 */}
|
||||
<div className="flex gap-3 border border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||
{/* 左侧:选择模式 */}
|
||||
<div className="w-[140px] flex-shrink-0 bg-[var(--color-bg-secondary)] border-r border-[var(--color-border)] p-2 space-y-1">
|
||||
{modeOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-all text-sm',
|
||||
selectionMode === option.value
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="selectionMode"
|
||||
value={option.value}
|
||||
checked={selectionMode === option.value}
|
||||
onChange={() => handleModeChange(option.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧:消息列表 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 搜索框和快捷操作 */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-[var(--color-border)]">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索消息..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-md text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{isCustomMode && (
|
||||
<div className="flex items-center gap-1 text-xs flex-shrink-0">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<span className="text-[var(--color-border)]">|</span>
|
||||
<button
|
||||
onClick={handleSelectNone}
|
||||
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
>
|
||||
全不选
|
||||
</button>
|
||||
<span className="text-[var(--color-border)]">|</span>
|
||||
<button
|
||||
onClick={handleSelectInverse}
|
||||
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
>
|
||||
反选
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[180px]">
|
||||
{filteredRounds.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
没有找到匹配的消息
|
||||
</div>
|
||||
) : (
|
||||
filteredRounds.map((round) => {
|
||||
const isSelected = selectedRoundIndices.has(round.index);
|
||||
const canToggle = isCustomMode;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={round.index}
|
||||
onClick={() => canToggle && toggleRound(round)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2.5 border-b border-[var(--color-border)] last:border-b-0 transition-colors',
|
||||
canToggle ? 'cursor-pointer hover:bg-[var(--color-bg-hover)]' : '',
|
||||
isSelected && 'bg-[var(--color-primary)]/5'
|
||||
)}
|
||||
>
|
||||
{/* 复选框 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors',
|
||||
isSelected
|
||||
? 'bg-[var(--color-primary)] border-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)]',
|
||||
!canToggle && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check size={10} className="text-white" />}
|
||||
</div>
|
||||
|
||||
{/* 序号 */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 w-5 h-5 rounded-full text-xs flex items-center justify-center',
|
||||
isSelected
|
||||
? 'bg-[var(--color-primary)]/20 text-[var(--color-primary)]'
|
||||
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{round.index}
|
||||
</span>
|
||||
|
||||
{/* 内容预览 */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
isSelected
|
||||
? 'text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{round.preview}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--color-bg-secondary)] border-t border-[var(--color-border)] text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>已选 {selectedCount}/{rounds.length} 轮</span>
|
||||
{isCustomMode && selectedCount === 0 && (
|
||||
<span className="text-amber-600">请至少选择一轮</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,467 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
X,
|
||||
Link2,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
ImageIcon,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { MessageSelector } from './MessageSelector';
|
||||
|
||||
// 消息接口
|
||||
interface MessageForShare {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
messages?: MessageForShare[];
|
||||
onPreview?: (url: string) => void;
|
||||
}
|
||||
|
||||
interface ShareInfo {
|
||||
shareId: string;
|
||||
shareCode: string;
|
||||
shareUrl: string;
|
||||
title: string;
|
||||
viewCount?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export function ShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
messages = [],
|
||||
onPreview,
|
||||
}: ShareModalProps) {
|
||||
// 表单状态
|
||||
const [title, setTitle] = useState(conversationTitle);
|
||||
const [includeThinking, setIncludeThinking] = useState(true);
|
||||
const [includeToolCalls, setIncludeToolCalls] = useState(false);
|
||||
const [includeImages, setIncludeImages] = useState(true);
|
||||
const [selectedMessageIds, setSelectedMessageIds] = useState<string[] | null>(null); // null 表示全部
|
||||
|
||||
// UI 状态
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showQrCode, setShowQrCode] = useState(false);
|
||||
|
||||
// 已有分享列表
|
||||
const [existingShares, setExistingShares] = useState<ShareInfo[]>([]);
|
||||
const [loadingShares, setLoadingShares] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTitle(conversationTitle);
|
||||
setShareInfo(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setShowQrCode(false);
|
||||
setSelectedMessageIds(null); // 重置为全部
|
||||
loadExistingShares();
|
||||
}
|
||||
}, [isOpen, conversationTitle]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 加载已有分享
|
||||
const loadExistingShares = async () => {
|
||||
try {
|
||||
setLoadingShares(true);
|
||||
const response = await fetch(`/api/conversations/${conversationId}/share`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingShares(data.shares || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load shares error:', err);
|
||||
} finally {
|
||||
setLoadingShares(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建分享
|
||||
const handleCreateShare = async () => {
|
||||
// 验证:自定义选择时必须至少选择一条消息
|
||||
if (selectedMessageIds !== null && selectedMessageIds.length === 0) {
|
||||
setError('请至少选择一轮对话进行分享');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/conversations/${conversationId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
includeThinking,
|
||||
includeToolCalls,
|
||||
includeImages,
|
||||
selectedMessageIds, // 新增:选择的消息ID,null 表示全部
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '创建分享失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setShareInfo(data);
|
||||
loadExistingShares();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建分享失败');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 复制链接
|
||||
const handleCopy = async () => {
|
||||
if (!shareInfo?.shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareInfo.shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Copy error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除分享
|
||||
const handleDeleteShare = async (shareId: string) => {
|
||||
if (!confirm('确定要删除此分享吗?')) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/conversations/${conversationId}/share?shareId=${shareId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (response.ok) {
|
||||
setExistingShares((prev) => prev.filter((s) => s.shareId !== shareId));
|
||||
if (shareInfo?.shareId === shareId) {
|
||||
setShareInfo(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete share error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative w-full max-w-2xl bg-[var(--color-bg-primary)] rounded-[4px] shadow-xl"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={20} className="text-[var(--color-primary)]" />
|
||||
<h2 className="text-base font-medium text-[var(--color-text-primary)]">
|
||||
分享对话
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-5">
|
||||
{shareInfo ? (
|
||||
// 分享成功后显示链接
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Check size={20} />
|
||||
<span className="font-medium">分享链接已生成</span>
|
||||
</div>
|
||||
|
||||
{/* 链接复制区域 */}
|
||||
<div className="flex items-center gap-2 p-3 bg-[var(--color-bg-secondary)] rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={shareInfo.shareUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'p-2 rounded transition-colors',
|
||||
copied
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'hover:bg-[var(--color-bg-hover)] text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
title="复制链接"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 二维码 */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setShowQrCode(!showQrCode)}
|
||||
className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
<QrCode size={16} />
|
||||
<span>{showQrCode ? '隐藏二维码' : '显示二维码'}</span>
|
||||
</button>
|
||||
{showQrCode && (
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCodeSVG
|
||||
value={shareInfo.shareUrl}
|
||||
size={160}
|
||||
level="M"
|
||||
includeMargin
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPreview && shareInfo.shareUrl) {
|
||||
onClose();
|
||||
onPreview(shareInfo.shareUrl);
|
||||
} else {
|
||||
window.open(shareInfo.shareUrl, '_blank');
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-hover)] transition-colors"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
<span>打开预览</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShareInfo(null)}
|
||||
className="px-4 py-2.5 text-[var(--color-text-secondary)] bg-[var(--color-bg-secondary)] rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors"
|
||||
>
|
||||
创建新分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 创建分享表单
|
||||
<div className="space-y-4">
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1.5">
|
||||
分享标题
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="输入分享标题"
|
||||
className="w-full px-3 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 消息选择器 */}
|
||||
{messages.length > 0 && (
|
||||
<MessageSelector
|
||||
messages={messages}
|
||||
selectedMessageIds={selectedMessageIds}
|
||||
onSelectionChange={setSelectedMessageIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容选项 - 横向布局 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
分享设置
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="flex items-center gap-2 px-3 py-2.5 bg-[var(--color-bg-secondary)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeThinking}
|
||||
onChange={(e) => setIncludeThinking(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[var(--color-border)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<MessageSquare size={14} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
|
||||
<span className="text-xs text-[var(--color-text-primary)]">思考过程</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 px-3 py-2.5 bg-[var(--color-bg-secondary)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeToolCalls}
|
||||
onChange={(e) => setIncludeToolCalls(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[var(--color-border)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<Wrench size={14} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
|
||||
<span className="text-xs text-[var(--color-text-primary)]">工具调用</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 px-3 py-2.5 bg-[var(--color-bg-secondary)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeImages}
|
||||
onChange={(e) => setIncludeImages(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[var(--color-border)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<ImageIcon size={14} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
|
||||
<span className="text-xs text-[var(--color-text-primary)]">包含图片</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建按钮 */}
|
||||
<button
|
||||
onClick={handleCreateShare}
|
||||
disabled={isCreating || !title.trim()}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-colors',
|
||||
isCreating || !title.trim()
|
||||
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] cursor-not-allowed'
|
||||
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)]'
|
||||
)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>生成中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 size={16} />
|
||||
<span>生成分享链接</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已有分享列表 */}
|
||||
{existingShares.length > 0 && !shareInfo && (
|
||||
<div className="mt-6 pt-4 border-t border-[var(--color-border)]">
|
||||
<h3 className="text-sm font-medium text-[var(--color-text-secondary)] mb-3">
|
||||
已有分享 ({existingShares.length})
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{existingShares.map((share) => (
|
||||
<div
|
||||
key={share.shareId}
|
||||
className="flex items-center justify-between p-3 bg-[var(--color-bg-secondary)] rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{share.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={12} />
|
||||
{share.viewCount || 0} 次查看
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(share.shareUrl);
|
||||
}}
|
||||
className="p-1.5 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPreview) {
|
||||
onClose();
|
||||
onPreview(share.shareUrl);
|
||||
} else {
|
||||
window.open(share.shareUrl, '_blank');
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
title="打开"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteShare(share.shareId)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { List, ChevronUp, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageAnchor {
|
||||
id: string;
|
||||
index: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
interface ShareNavigatorProps {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ShareNavigator({ messages }: ShareNavigatorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
// 提取用户消息作为目录项
|
||||
const userMessages: MessageAnchor[] = messages
|
||||
.filter((msg) => msg.role === 'user')
|
||||
.map((msg, idx) => ({
|
||||
id: msg.id,
|
||||
index: idx + 1,
|
||||
preview: msg.content.slice(0, 30) + (msg.content.length > 30 ? '...' : ''),
|
||||
}));
|
||||
|
||||
// 监听滚动,显示/隐藏回到顶部按钮
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowBackTop(window.scrollY > 500);
|
||||
|
||||
// 更新当前可视消息
|
||||
const messageElements = document.querySelectorAll('[data-msg-index]');
|
||||
let currentIndex: number | null = null;
|
||||
|
||||
messageElements.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= 200 && rect.bottom > 0) {
|
||||
currentIndex = parseInt(el.getAttribute('data-msg-index') || '0');
|
||||
}
|
||||
});
|
||||
|
||||
setActiveIndex(currentIndex);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
handleScroll(); // 初始化
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 跳转到指定消息
|
||||
const scrollToMessage = useCallback((id: string, index: number) => {
|
||||
const element = document.getElementById(`msg-${id}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// 更新 URL hash
|
||||
window.history.replaceState(null, '', `#msg-${index}`);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = useCallback(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}, []);
|
||||
|
||||
// 如果用户消息少于 2 条,不显示导航
|
||||
if (userMessages.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 浮动导航按钮组 */}
|
||||
<div className="fixed right-6 bottom-6 z-50 flex flex-col items-end gap-3">
|
||||
{/* 回到顶部按钮 */}
|
||||
{showBackTop && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="w-10 h-10 bg-white border border-gray-200 rounded-full shadow-lg flex items-center justify-center text-gray-600 hover:bg-gray-50 hover:text-[#DB6639] transition-all"
|
||||
title="回到顶部"
|
||||
>
|
||||
<ChevronUp size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 目录按钮 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full shadow-lg flex items-center justify-center transition-all',
|
||||
isOpen
|
||||
? 'bg-[#DB6639] text-white'
|
||||
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-[#DB6639]'
|
||||
)}
|
||||
title="消息目录"
|
||||
>
|
||||
{isOpen ? <X size={20} /> : <List size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 目录面板 */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 目录内容 */}
|
||||
<div className="fixed right-6 bottom-20 z-50 w-72 max-h-[60vh] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
消息目录
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{userMessages.length} 条问题
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 目录列表 */}
|
||||
<div className="overflow-y-auto max-h-[calc(60vh-48px)]">
|
||||
{userMessages.map((msg) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => scrollToMessage(msg.id, msg.index)}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-b-0',
|
||||
activeIndex === msg.index && 'bg-orange-50 border-l-2 border-l-[#DB6639]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 w-5 h-5 rounded-full text-xs flex items-center justify-center mt-0.5',
|
||||
activeIndex === msg.index
|
||||
? 'bg-[#DB6639] text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
)}
|
||||
>
|
||||
{msg.index}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm leading-relaxed',
|
||||
activeIndex === msg.index
|
||||
? 'text-[#DB6639] font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
{msg.preview}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -88,8 +88,9 @@ export function CodeBlock({
|
||||
// 规范化语言名称
|
||||
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
|
||||
|
||||
// 判断是否支持 HTML 预览(仅 html/htm,不包括 xml/svg)
|
||||
const isHtmlPreviewable = ['html', 'htm'].includes(language.toLowerCase());
|
||||
// 判断是否支持 HTML 预览
|
||||
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
|
||||
['html', 'htm'].includes(language.toLowerCase());
|
||||
|
||||
// 判断是否可执行:语言支持 + 代码满足执行条件
|
||||
const canRun = isRunnableLanguage(language) && isCodeExecutable(code, language);
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
CREATE TABLE "shared_conversations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"share_id" varchar(64) NOT NULL,
|
||||
"conversation_id" varchar(64) NOT NULL,
|
||||
"user_id" varchar(64) NOT NULL,
|
||||
"share_code" varchar(12) NOT NULL,
|
||||
"title" varchar(255),
|
||||
"description" text,
|
||||
"include_thinking" boolean DEFAULT true,
|
||||
"include_tool_calls" boolean DEFAULT false,
|
||||
"include_images" boolean DEFAULT true,
|
||||
"view_count" integer DEFAULT 0,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "shared_conversations_share_id_unique" UNIQUE("share_id"),
|
||||
CONSTRAINT "shared_conversations_share_code_unique" UNIQUE("share_code")
|
||||
);
|
||||
@ -1 +0,0 @@
|
||||
ALTER TABLE "shared_conversations" ADD COLUMN "snapshot_at" timestamp with time zone;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -92,20 +92,6 @@
|
||||
"when": 1766339459689,
|
||||
"tag": "0012_flippant_marvel_apes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1766540812748,
|
||||
"tag": "0013_charming_natasha_romanoff",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1766546111189,
|
||||
"tag": "0014_naive_kronos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -313,40 +313,6 @@ export const promptOptimizations = pgTable('prompt_optimizations', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 对话分享表
|
||||
// ============================================
|
||||
export const sharedConversations = pgTable('shared_conversations', {
|
||||
id: serial('id').primaryKey(),
|
||||
// 分享唯一标识
|
||||
shareId: varchar('share_id', { length: 64 }).notNull().unique(),
|
||||
// 关联对话
|
||||
conversationId: varchar('conversation_id', { length: 64 }).notNull(),
|
||||
// 分享创建者
|
||||
userId: varchar('user_id', { length: 64 }).notNull(),
|
||||
// 短链接码(用于分享 URL)
|
||||
shareCode: varchar('share_code', { length: 12 }).notNull().unique(),
|
||||
// 分享标题(可自定义)
|
||||
title: varchar('title', { length: 255 }),
|
||||
// 分享描述
|
||||
description: text('description'),
|
||||
// 内容控制
|
||||
includeThinking: boolean('include_thinking').default(true),
|
||||
includeToolCalls: boolean('include_tool_calls').default(false),
|
||||
includeImages: boolean('include_images').default(true),
|
||||
// 统计信息
|
||||
viewCount: integer('view_count').default(0),
|
||||
// 状态
|
||||
isActive: boolean('is_active').default(true),
|
||||
// 快照时间点(只显示此时间之前的消息)
|
||||
snapshotAt: timestamp('snapshot_at', { withTimezone: true }),
|
||||
// 选择的消息ID列表(JSON数组,null 表示全部消息)
|
||||
selectedMessageIds: jsonb('selected_message_ids').$type<string[]>(),
|
||||
// 时间戳
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 关系定义
|
||||
// ============================================
|
||||
@ -433,18 +399,6 @@ export const notesRelations = relations(notes, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// 分享关系
|
||||
export const sharedConversationsRelations = relations(sharedConversations, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [sharedConversations.userId],
|
||||
references: [users.userId],
|
||||
}),
|
||||
conversation: one(conversations, {
|
||||
fields: [sharedConversations.conversationId],
|
||||
references: [conversations.conversationId],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
@ -515,6 +469,3 @@ export type NewNote = typeof notes.$inferInsert;
|
||||
|
||||
export type PromptOptimization = typeof promptOptimizations.$inferSelect;
|
||||
export type NewPromptOptimization = typeof promptOptimizations.$inferInsert;
|
||||
|
||||
export type SharedConversation = typeof sharedConversations.$inferSelect;
|
||||
export type NewSharedConversation = typeof sharedConversations.$inferInsert;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user