Compare commits
8 Commits
039a9b6b49
...
e415ff5787
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e415ff5787 | ||
|
|
e7de47d0d9 | ||
|
|
bbe07e203e | ||
|
|
c2b97f0f2d | ||
|
|
728c13eb5e | ||
|
|
5189ebb232 | ||
|
|
c946f8608c | ||
|
|
a50ab2c8ee |
219
src/app/api/conversations/[id]/tags/auto/route.ts
Normal file
219
src/app/api/conversations/[id]/tags/auto/route.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { userSettings, conversations, messages } from '@/drizzle/schema';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { decryptApiKey } from '@/lib/crypto';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签生成的 Prompt 模板
|
||||||
|
function buildTagPrompt(conversationTitle: string, messageContents: string[]): string {
|
||||||
|
const conversationText = messageContents.join('\n\n');
|
||||||
|
|
||||||
|
return `你是一个专业的对话标签分析助手。请根据以下对话内容,生成合适的标签来描述这段对话的主题和内容。
|
||||||
|
|
||||||
|
## 对话标题
|
||||||
|
${conversationTitle}
|
||||||
|
|
||||||
|
## 对话内容
|
||||||
|
${conversationText}
|
||||||
|
|
||||||
|
## 标签要求
|
||||||
|
1. 生成 3-5 个最相关的标签
|
||||||
|
2. 每个标签应该简洁明了(2-6 个字)
|
||||||
|
3. 标签应该反映对话的主要主题、技术栈、问题类型等
|
||||||
|
4. 优先使用常见的分类词汇,如:Python、React、调试、性能、架构、API、数据库等
|
||||||
|
5. 为每个标签提供一个置信度分数(0-100)
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
请严格按照以下 JSON 格式输出,不要有任何其他文字:
|
||||||
|
|
||||||
|
{
|
||||||
|
"tags": [
|
||||||
|
{ "name": "标签名称", "confidence": 95 },
|
||||||
|
{ "name": "标签名称", "confidence": 88 }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化 URL
|
||||||
|
function normalizeBaseUrl(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/conversations/[id]/tags/auto - AI 自动生成标签
|
||||||
|
export async function POST(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// 获取对话信息
|
||||||
|
const conversation = await db.query.conversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(conversations.conversationId, id),
|
||||||
|
eq(conversations.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取对话消息
|
||||||
|
const messageList = await db.query.messages.findMany({
|
||||||
|
where: eq(messages.conversationId, id),
|
||||||
|
orderBy: [asc(messages.createdAt)],
|
||||||
|
columns: {
|
||||||
|
role: true,
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messageList.length === 0) {
|
||||||
|
return NextResponse.json({ error: '对话没有消息内容' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户设置
|
||||||
|
const settings = await db.query.userSettings.findFirst({
|
||||||
|
where: eq(userSettings.userId, user.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings?.cchApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请先在设置中配置您的 API Key' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密 API Key
|
||||||
|
const decryptedApiKey = decryptApiKey(settings.cchApiKey);
|
||||||
|
const cchUrl = settings.cchUrl || process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/';
|
||||||
|
const apiFormat = (settings.apiFormat as 'claude' | 'openai') || 'claude';
|
||||||
|
|
||||||
|
// 构建消息内容(限制长度,避免 token 过多)
|
||||||
|
const messageContents = messageList.slice(0, 20).map((msg) => {
|
||||||
|
const prefix = msg.role === 'user' ? '用户' : 'AI助手';
|
||||||
|
// 限制每条消息的长度
|
||||||
|
const content = msg.content.length > 500 ? msg.content.slice(0, 500) + '...' : msg.content;
|
||||||
|
return `${prefix}: ${content}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建 Prompt
|
||||||
|
const tagPrompt = buildTagPrompt(conversation.title, messageContents);
|
||||||
|
|
||||||
|
// 调用 AI API
|
||||||
|
let apiUrl: string;
|
||||||
|
let requestBody: object;
|
||||||
|
let headers: Record<string, string>;
|
||||||
|
|
||||||
|
if (apiFormat === 'openai') {
|
||||||
|
apiUrl = `${normalizeBaseUrl(cchUrl)}/v1/chat/completions`;
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${decryptedApiKey}`,
|
||||||
|
};
|
||||||
|
requestBody = {
|
||||||
|
model: settings.defaultModel || 'claude-sonnet-4-5-20250929',
|
||||||
|
messages: [{ role: 'user', content: tagPrompt }],
|
||||||
|
max_tokens: 512,
|
||||||
|
temperature: 0.3, // 低温度,输出更稳定
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
apiUrl = `${normalizeBaseUrl(cchUrl)}/v1/messages`;
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': decryptedApiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
};
|
||||||
|
requestBody = {
|
||||||
|
model: settings.defaultModel || 'claude-sonnet-4-5-20250929',
|
||||||
|
max_tokens: 512,
|
||||||
|
messages: [{ role: 'user', content: tagPrompt }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API/tags/auto] 开始生成标签,对话ID:', id);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[API/tags/auto] API 调用失败:', response.status, errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '生成标签失败,请稍后重试' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 提取 AI 响应内容
|
||||||
|
let aiContent: string;
|
||||||
|
if (apiFormat === 'openai') {
|
||||||
|
aiContent = data.choices?.[0]?.message?.content || '';
|
||||||
|
} else {
|
||||||
|
aiContent = data.content?.[0]?.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API/tags/auto] AI 响应:', aiContent);
|
||||||
|
|
||||||
|
// 解析 JSON 响应
|
||||||
|
let suggestedTags: Array<{ name: string; confidence: number }> = [];
|
||||||
|
try {
|
||||||
|
// 尝试提取 JSON 部分(AI 可能会添加额外的文字)
|
||||||
|
const jsonMatch = aiContent.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
if (Array.isArray(parsed.tags)) {
|
||||||
|
suggestedTags = parsed.tags
|
||||||
|
.filter((tag: { name?: string; confidence?: number }) =>
|
||||||
|
tag.name && typeof tag.name === 'string' && tag.name.length <= 20
|
||||||
|
)
|
||||||
|
.map((tag: { name: string; confidence?: number }) => ({
|
||||||
|
name: tag.name.trim(),
|
||||||
|
confidence: Math.min(100, Math.max(0, Math.round(tag.confidence || 80))),
|
||||||
|
}))
|
||||||
|
.slice(0, 8); // 最多返回 8 个标签
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('[API/tags/auto] 解析 AI 响应失败:', parseError);
|
||||||
|
// 如果解析失败,尝试简单提取
|
||||||
|
const words = aiContent.match(/[\u4e00-\u9fa5a-zA-Z]+/g) || [];
|
||||||
|
suggestedTags = words
|
||||||
|
.filter((w) => w.length >= 2 && w.length <= 10)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((name) => ({ name, confidence: 70 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedTags.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无法生成有效的标签建议' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回推荐的标签(不自动保存,让用户选择)
|
||||||
|
return NextResponse.json({
|
||||||
|
suggestedTags,
|
||||||
|
currentTags: conversation.tags || [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API/tags/auto] 生成标签错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '生成标签时发生错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/app/api/conversations/[id]/tags/route.ts
Normal file
106
src/app/api/conversations/[id]/tags/route.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { conversations } from '@/drizzle/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/conversations/[id]/tags - 获取对话标签
|
||||||
|
export async function GET(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const conversation = await db.query.conversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(conversations.conversationId, id),
|
||||||
|
eq(conversations.userId, user.userId)
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
tags: true,
|
||||||
|
autoTaggedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tags: conversation.tags || [],
|
||||||
|
autoTaggedAt: conversation.autoTaggedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签失败:', error);
|
||||||
|
return NextResponse.json({ error: '获取标签失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/conversations/[id]/tags - 更新对话标签
|
||||||
|
export async function PATCH(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { tags } = body;
|
||||||
|
|
||||||
|
// 验证标签格式
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
return NextResponse.json({ error: '标签格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个标签
|
||||||
|
const validTags = tags.filter(
|
||||||
|
(tag: unknown) => typeof tag === 'string' && tag.trim().length > 0 && tag.length <= 20
|
||||||
|
).map((tag: string) => tag.trim());
|
||||||
|
|
||||||
|
// 限制标签数量
|
||||||
|
if (validTags.length > 10) {
|
||||||
|
return NextResponse.json({ error: '标签数量不能超过10个' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重
|
||||||
|
const uniqueTags = [...new Set(validTags)];
|
||||||
|
|
||||||
|
// 验证对话存在且属于当前用户
|
||||||
|
const existingConversation = await db.query.conversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(conversations.conversationId, id),
|
||||||
|
eq(conversations.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingConversation) {
|
||||||
|
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
const [updated] = await db
|
||||||
|
.update(conversations)
|
||||||
|
.set({
|
||||||
|
tags: uniqueTags,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(conversations.conversationId, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tags: updated.tags,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新标签失败:', error);
|
||||||
|
return NextResponse.json({ error: '更新标签失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/tags/route.ts
Normal file
58
src/app/api/tags/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { conversations } from '@/drizzle/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
// 标签统计类型
|
||||||
|
interface TagStats {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tags - 获取当前用户所有对话的标签统计
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户所有对话的标签
|
||||||
|
const userConversations = await db.query.conversations.findMany({
|
||||||
|
where: eq(conversations.userId, user.userId),
|
||||||
|
columns: {
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
orderBy: [desc(conversations.lastMessageAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计标签出现次数
|
||||||
|
const tagCountMap = new Map<string, number>();
|
||||||
|
|
||||||
|
userConversations.forEach((conversation) => {
|
||||||
|
const tags = conversation.tags as string[] | null;
|
||||||
|
if (tags && Array.isArray(tags)) {
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
const normalizedTag = tag.trim();
|
||||||
|
if (normalizedTag) {
|
||||||
|
tagCountMap.set(normalizedTag, (tagCountMap.get(normalizedTag) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为数组并按出现次数排序
|
||||||
|
const tagStats: TagStats[] = Array.from(tagCountMap.entries())
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tags: tagStats,
|
||||||
|
totalConversations: userConversations.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签统计失败:', error);
|
||||||
|
return NextResponse.json({ error: '获取标签统计失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import { LinkPreviewModal } from '@/components/features/LinkPreviewModal';
|
|||||||
import { ExportDropdown } from '@/components/features/ExportDropdown';
|
import { ExportDropdown } from '@/components/features/ExportDropdown';
|
||||||
import { ShareModal } from '@/components/features/ShareModal';
|
import { ShareModal } from '@/components/features/ShareModal';
|
||||||
import { SummaryButton } from '@/components/features/SummaryGenerator';
|
import { SummaryButton } from '@/components/features/SummaryGenerator';
|
||||||
|
import { TagManager } from '@/components/features/Tags';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConversation, useConversations } from '@/hooks/useConversations';
|
import { useConversation, useConversations } from '@/hooks/useConversations';
|
||||||
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
|
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
|
||||||
@ -457,10 +458,10 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 固定顶部 Header */}
|
{/* 固定顶部 Header */}
|
||||||
<header className="px-4 py-2 flex flex-col gap-1 border-b border-[var(--color-border)] bg-[var(--color-bg-primary)] sticky top-0 z-10">
|
<header className="flex flex-col border-b border-[var(--color-border)] bg-[var(--color-bg-primary)] sticky top-0 z-10">
|
||||||
{/* 第一行:标题和操作按钮 */}
|
{/* 第一行:标题 + 操作按钮 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4 px-4 py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
|
||||||
{/* 标题区域 - 可点击显示下拉菜单 */}
|
{/* 标题区域 - 可点击显示下拉菜单 */}
|
||||||
@ -509,7 +510,7 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
<div className="relative" ref={titleMenuRef}>
|
<div className="relative" ref={titleMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTitleMenuOpen(!titleMenuOpen)}
|
onClick={() => setTitleMenuOpen(!titleMenuOpen)}
|
||||||
className="flex items-center gap-1 text-base font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] px-2 py-1 rounded-lg transition-colors max-w-[300px]"
|
className="flex items-center gap-1 text-base font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] px-2 py-1 rounded-[4px] transition-colors max-w-[320px]"
|
||||||
>
|
>
|
||||||
<span className="truncate">{conversation?.title || 'New Chat'}</span>
|
<span className="truncate">{conversation?.title || 'New Chat'}</span>
|
||||||
<ChevronDown size={16} className={cn(
|
<ChevronDown size={16} className={cn(
|
||||||
@ -520,76 +521,87 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
|
|
||||||
{/* 下拉菜单 */}
|
{/* 下拉菜单 */}
|
||||||
{titleMenuOpen && (
|
{titleMenuOpen && (
|
||||||
<div className="absolute left-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-20 min-w-[140px]">
|
<div className="absolute left-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[4px] shadow-lg py-1 z-20 min-w-[140px]">
|
||||||
<button
|
<button
|
||||||
onClick={handleStartRename}
|
onClick={handleStartRename}
|
||||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
Rename
|
重命名
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteConversation}
|
onClick={handleDeleteConversation}
|
||||||
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
|
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
Delete
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
{/* 右侧操作按钮 */}
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* 思考模式开关 - 只在非 Codex 模型时显示 */}
|
{/* 思考模式开关 - 只在非 Codex 模型时显示 */}
|
||||||
{!selectedModelId.toLowerCase().includes('codex') && (
|
{!selectedModelId.toLowerCase().includes('codex') && (
|
||||||
|
<button
|
||||||
|
onClick={handleThinkingToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-[4px] transition-colors',
|
||||||
|
enableThinking
|
||||||
|
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||||
|
)}
|
||||||
|
title={enableThinking ? '关闭思考模式' : '开启思考模式'}
|
||||||
|
>
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>思考</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 智能摘要按钮 */}
|
||||||
|
<SummaryButton
|
||||||
|
conversationId={chatId}
|
||||||
|
messages={messages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
}))}
|
||||||
|
hasSummary={!!conversation?.summary}
|
||||||
|
existingSummary={conversation?.summary}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleThinkingToggle}
|
onClick={() => setShareModalOpen(true)}
|
||||||
className={cn(
|
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-[4px] transition-colors"
|
||||||
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors',
|
title="分享对话"
|
||||||
enableThinking
|
|
||||||
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
|
|
||||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
|
||||||
)}
|
|
||||||
title={enableThinking ? '关闭思考模式' : '开启思考模式'}
|
|
||||||
>
|
>
|
||||||
<Clock size={16} />
|
<Share2 size={16} />
|
||||||
<span>思考</span>
|
<span>分享</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
<ExportDropdown
|
||||||
|
conversationId={chatId}
|
||||||
{/* 智能摘要按钮 */}
|
conversationTitle={conversation?.title || '新对话'}
|
||||||
<SummaryButton
|
disabled={isStreaming}
|
||||||
conversationId={chatId}
|
/>
|
||||||
messages={messages.map(m => ({
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
}))}
|
|
||||||
hasSummary={!!conversation?.summary}
|
|
||||||
existingSummary={conversation?.summary}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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="分享对话"
|
|
||||||
>
|
|
||||||
<Share2 size={16} />
|
|
||||||
<span>分享</span>
|
|
||||||
</button>
|
|
||||||
<ExportDropdown
|
|
||||||
conversationId={chatId}
|
|
||||||
conversationTitle={conversation?.title || '新对话'}
|
|
||||||
disabled={isStreaming}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第二行:助手信息 */}
|
{/* 渐变分隔线 */}
|
||||||
<div className="pl-12">
|
<div className="h-px mx-4" style={{ background: 'linear-gradient(90deg, transparent, var(--color-border), transparent)' }} />
|
||||||
|
|
||||||
|
{/* 第二行:助手信息 + 标签管理 */}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-4 py-2 pl-[60px]">
|
||||||
|
{/* 左侧:助手信息 */}
|
||||||
<ChatHeaderInfo
|
<ChatHeaderInfo
|
||||||
assistant={conversation?.assistant || null}
|
assistant={conversation?.assistant || null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 右侧:标签管理区域 */}
|
||||||
|
{conversation && !isNewChat && (
|
||||||
|
<TagManager conversationId={chatId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useState } from 'react';
|
|||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles } from 'lucide-react';
|
||||||
import { SummaryModal } from './SummaryModal';
|
import { SummaryModal } from './SummaryModal';
|
||||||
import type { SummaryMessage } from './types';
|
import type { SummaryMessage } from './types';
|
||||||
import { Tooltip } from '@/components/ui/Tooltip';
|
|
||||||
|
|
||||||
interface SummaryButtonProps {
|
interface SummaryButtonProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@ -39,21 +38,20 @@ export function SummaryButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip content="智能摘要" position="bottom">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={handleClick}
|
||||||
onClick={handleClick}
|
className={`relative flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-[4px] transition-colors ${className}`}
|
||||||
className={`relative p-2 rounded text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-primary)] transition-all duration-150 ${className}`}
|
title="生成智能摘要"
|
||||||
aria-label="生成智能摘要"
|
>
|
||||||
>
|
<Sparkles size={16} />
|
||||||
<Sparkles size={18} />
|
<span>摘要</span>
|
||||||
|
|
||||||
{/* 已有摘要指示器 */}
|
{/* 已有摘要指示器 */}
|
||||||
{hasSummary && (
|
{hasSummary && (
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full border-2 border-[var(--color-bg-primary)] animate-pulse" />
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 摘要 Modal */}
|
{/* 摘要 Modal */}
|
||||||
<SummaryModal
|
<SummaryModal
|
||||||
|
|||||||
280
src/components/features/Tags/AutoTagModal.tsx
Normal file
280
src/components/features/Tags/AutoTagModal.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X, Sparkles, Check, Loader2 } from 'lucide-react';
|
||||||
|
import type { SuggestedTag } from '@/types/tags';
|
||||||
|
import { getTagColor } from '@/types/tags';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AutoTagModalProps {
|
||||||
|
/** 是否显示 */
|
||||||
|
isOpen: boolean;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** AI 推荐的标签 */
|
||||||
|
suggestedTags: SuggestedTag[];
|
||||||
|
/** 当前已有的标签 */
|
||||||
|
currentTags: string[];
|
||||||
|
/** 应用标签回调 */
|
||||||
|
onApply: (selectedTags: string[]) => void;
|
||||||
|
/** 是否加载中 */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoTagModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
suggestedTags,
|
||||||
|
currentTags,
|
||||||
|
onApply,
|
||||||
|
loading = false,
|
||||||
|
}: AutoTagModalProps) {
|
||||||
|
// 选中的标签(默认选中前3个高置信度的)
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 客户端挂载状态(用于 Portal SSR 安全)
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// 组件挂载后设置 mounted 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化选中状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestedTags.length > 0) {
|
||||||
|
const defaultSelected = suggestedTags
|
||||||
|
.filter((tag) => !currentTags.includes(tag.name))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((tag) => tag.name);
|
||||||
|
setSelectedTags(defaultSelected);
|
||||||
|
}
|
||||||
|
}, [suggestedTags, currentTags]);
|
||||||
|
|
||||||
|
// 阻止背景滚动
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 关闭处理
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (!loading) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [loading, onClose]);
|
||||||
|
|
||||||
|
// ESC 键关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !loading) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEsc);
|
||||||
|
return () => document.removeEventListener('keydown', handleEsc);
|
||||||
|
}
|
||||||
|
}, [isOpen, loading, handleClose]);
|
||||||
|
|
||||||
|
// 切换标签选中状态
|
||||||
|
const toggleTag = (tagName: string) => {
|
||||||
|
setSelectedTags((prev) => {
|
||||||
|
if (prev.includes(tagName)) {
|
||||||
|
return prev.filter((t) => t !== tagName);
|
||||||
|
}
|
||||||
|
return [...prev, tagName];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理应用
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(selectedTags);
|
||||||
|
};
|
||||||
|
|
||||||
|
// SSR 安全检查:未挂载或未打开时不渲染
|
||||||
|
if (!mounted || !isOpen) return null;
|
||||||
|
|
||||||
|
// 使用 Portal 将 Modal 渲染到 body,脱离组件树的层叠上下文
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
style={{ animation: 'fadeIn 0.2s ease-out' }}
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal 内容 */}
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md mx-4 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[4px] shadow-lg overflow-hidden"
|
||||||
|
style={{ animation: 'scaleIn 0.2s ease-out' }}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--color-border-light)]">
|
||||||
|
<div className="w-9 h-9 flex items-center justify-center bg-[var(--color-primary)] rounded-[4px]">
|
||||||
|
<Sparkles size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||||
|
AI 智能标签生成
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
基于对话内容自动分析生成标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-7 h-7 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-[4px] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
{loading ? (
|
||||||
|
// 加载状态
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="relative w-14 h-14 mb-4">
|
||||||
|
<div className="absolute inset-0 border-3 border-[var(--color-border)] border-t-[var(--color-primary)] rounded-full animate-spin" />
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-xl">
|
||||||
|
✨
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
|
正在分析对话内容...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||||
|
AI 正在理解上下文并生成相关标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : suggestedTags.length === 0 ? (
|
||||||
|
// 无结果
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||||
|
暂无推荐标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 标签列表
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-[var(--color-text-secondary)] mb-3">
|
||||||
|
根据对话内容,AI 推荐以下标签:
|
||||||
|
</p>
|
||||||
|
{suggestedTags.map((tag) => {
|
||||||
|
const isSelected = selectedTags.includes(tag.name);
|
||||||
|
const isExisting = currentTags.includes(tag.name);
|
||||||
|
const color = getTagColor(tag.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.name}
|
||||||
|
onClick={() => !isExisting && toggleTag(tag.name)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-[4px] border transition-all',
|
||||||
|
isExisting
|
||||||
|
? 'bg-[var(--color-bg-tertiary)] border-[var(--color-border-light)] opacity-50 cursor-not-allowed'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-[var(--color-primary-light)] border-[var(--color-primary)] cursor-pointer'
|
||||||
|
: 'bg-[var(--color-bg-tertiary)] border-[var(--color-border-light)] cursor-pointer hover:bg-[var(--color-bg-hover)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 复选框 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-4.5 h-4.5 flex items-center justify-center rounded-[4px] border-2 transition-all',
|
||||||
|
isExisting
|
||||||
|
? 'bg-[var(--color-bg-hover)] border-[var(--color-border)]'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-[var(--color-primary)] border-[var(--color-primary)]'
|
||||||
|
: 'border-[var(--color-border)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(isSelected || isExisting) && (
|
||||||
|
<Check size={10} className="text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签预览 */}
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs font-medium rounded-[4px] border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.bg,
|
||||||
|
color: color.text,
|
||||||
|
borderColor: color.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
{isExisting && (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
|
(已存在)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 置信度 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-12 h-1 bg-[var(--color-bg-hover)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-primary)] rounded-full"
|
||||||
|
style={{ width: `${tag.confidence}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)] font-mono w-8">
|
||||||
|
{tag.confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-[var(--color-border-light)]">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded-[4px] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={loading || selectedTags.length === 0}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-primary)] rounded-[4px] hover:bg-[var(--color-primary-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
应用标签 ({selectedTags.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动画样式 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/features/Tags/TagBadge.tsx
Normal file
81
src/components/features/Tags/TagBadge.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { getTagColor } from '@/types/tags';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TagBadgeProps {
|
||||||
|
/** 标签名称 */
|
||||||
|
name: string;
|
||||||
|
/** 是否显示删除按钮 */
|
||||||
|
removable?: boolean;
|
||||||
|
/** 删除回调 */
|
||||||
|
onRemove?: () => void;
|
||||||
|
/** 点击回调 */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** 是否选中状态 */
|
||||||
|
selected?: boolean;
|
||||||
|
/** 尺寸 */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
/** 显示数量 */
|
||||||
|
count?: number;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagBadge({
|
||||||
|
name,
|
||||||
|
removable = false,
|
||||||
|
onRemove,
|
||||||
|
onClick,
|
||||||
|
selected = false,
|
||||||
|
size = 'sm',
|
||||||
|
count,
|
||||||
|
className,
|
||||||
|
}: TagBadgeProps) {
|
||||||
|
const color = getTagColor(name);
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'px-2 py-0.5 text-[10px]',
|
||||||
|
md: 'px-2.5 py-1 text-xs',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-[4px] font-medium transition-all border',
|
||||||
|
sizeStyles[size],
|
||||||
|
onClick ? 'cursor-pointer hover:opacity-80' : '',
|
||||||
|
selected ? 'ring-1 ring-[var(--color-primary)]' : '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.bg,
|
||||||
|
color: color.text,
|
||||||
|
borderColor: color.border,
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[80px]">{name}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span
|
||||||
|
className="px-1 py-0 rounded text-[9px] font-medium"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{removable && onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="w-3.5 h-3.5 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/features/Tags/TagFilter.tsx
Normal file
94
src/components/features/Tags/TagFilter.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Tag, Loader2 } from 'lucide-react';
|
||||||
|
import { useTags, useTagFilter } from '@/hooks/useTags';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TagFilterProps {
|
||||||
|
/** 选中标签变化回调 */
|
||||||
|
onFilterChange?: (selectedTags: string[]) => void;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagFilter({ onFilterChange, className }: TagFilterProps) {
|
||||||
|
const { tags, loading } = useTags();
|
||||||
|
const { selectedTags, selectTag, clearFilter } = useTagFilter();
|
||||||
|
|
||||||
|
const handleTagClick = (tagName: string) => {
|
||||||
|
selectTag(tagName);
|
||||||
|
// 通知父组件筛选变化
|
||||||
|
if (onFilterChange) {
|
||||||
|
const newSelected = selectedTags.includes(tagName) && selectedTags.length === 1
|
||||||
|
? []
|
||||||
|
: [tagName];
|
||||||
|
onFilterChange(newSelected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
clearFilter();
|
||||||
|
if (onFilterChange) {
|
||||||
|
onFilterChange([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 没有标签时不显示
|
||||||
|
if (!loading && tags.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border-b border-[var(--color-border-light)]', className)}>
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Tag size={12} className="text-[var(--color-text-muted)]" />
|
||||||
|
<span className="text-[10px] font-medium text-[var(--color-text-muted)] uppercase tracking-wider">
|
||||||
|
标签筛选
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签列表 - 紧凑胶囊式 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 size={14} className="animate-spin text-[var(--color-text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{tags.slice(0, 10).map((tag) => {
|
||||||
|
const isSelected = selectedTags.includes(tag.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.name}
|
||||||
|
onClick={() => handleTagClick(tag.name)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] border transition-all cursor-pointer',
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--color-primary-light)] border-[rgba(224,107,62,0.3)] text-[var(--color-primary)]'
|
||||||
|
: 'bg-transparent border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] hover:border-[rgba(255,255,255,0.12)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[60px]">{tag.name}</span>
|
||||||
|
{tag.count !== undefined && (
|
||||||
|
<span className="opacity-60 text-[9px]">{tag.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/components/features/Tags/TagManager.tsx
Normal file
217
src/components/features/Tags/TagManager.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
|
import { Tag, Sparkles, Loader2, Plus, X, ChevronDown } from 'lucide-react';
|
||||||
|
import { useConversationTags } from '@/hooks/useTags';
|
||||||
|
import { AutoTagModal } from './AutoTagModal';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TagManagerProps {
|
||||||
|
/** 对话 ID */
|
||||||
|
conversationId: string;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagManager({ conversationId, className }: TagManagerProps) {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
loading,
|
||||||
|
updating,
|
||||||
|
generating,
|
||||||
|
suggestedTags,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
generateTags,
|
||||||
|
applySuggestedTags,
|
||||||
|
clearSuggestedTags,
|
||||||
|
} = useConversationTags(conversationId);
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [showInput, setShowInput] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 聚焦输入框
|
||||||
|
useEffect(() => {
|
||||||
|
if (showInput && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [showInput]);
|
||||||
|
|
||||||
|
// 点击外部关闭下拉菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (menuOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
// 处理添加标签
|
||||||
|
const handleAddTag = async () => {
|
||||||
|
const trimmed = inputValue.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addTag(trimmed);
|
||||||
|
setInputValue('');
|
||||||
|
setShowInput(false);
|
||||||
|
} catch {
|
||||||
|
// 错误已在 hook 中处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTag();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setInputValue('');
|
||||||
|
setShowInput(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 AI 生成标签
|
||||||
|
const handleGenerateTags = async () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
try {
|
||||||
|
await generateTags();
|
||||||
|
setShowModal(true);
|
||||||
|
} catch {
|
||||||
|
// 错误已在 hook 中处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理应用推荐标签
|
||||||
|
const handleApplyTags = async (selectedTags: string[]) => {
|
||||||
|
try {
|
||||||
|
await applySuggestedTags(selectedTags);
|
||||||
|
setShowModal(false);
|
||||||
|
} catch {
|
||||||
|
// 错误已在 hook 中处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理关闭弹窗
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
clearSuggestedTags();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开添加标签输入框
|
||||||
|
const handleOpenAddTag = () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setShowInput(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Loader2 size={14} className="animate-spin text-[var(--color-text-tertiary)]" />
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">加载标签...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
{/* 标签展示区域 - 显示全部标签 */}
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className="group inline-flex items-center gap-1 px-2 py-1 bg-[var(--color-bg-tertiary)] rounded-[4px] text-[11px] text-[var(--color-text-secondary)] transition-all hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="hidden group-hover:flex items-center justify-center w-3 h-3 rounded-[2px] text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary-light)] transition-all"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 添加标签输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
setShowInput(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="输入标签..."
|
||||||
|
maxLength={20}
|
||||||
|
disabled={updating}
|
||||||
|
className="w-20 px-2 py-1 text-[11px] bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded-[4px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-primary)] focus:w-24 transition-all"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 管理标签下拉按钮 */}
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
disabled={generating}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded-[4px] text-[11px] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Tag size={12} />
|
||||||
|
)}
|
||||||
|
<span>{generating ? '生成中...' : '管理标签'}</span>
|
||||||
|
<ChevronDown size={12} className={cn(
|
||||||
|
'transition-transform',
|
||||||
|
menuOpen && 'rotate-180'
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 下拉菜单 */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[4px] shadow-lg py-1 z-20 min-w-[140px]">
|
||||||
|
<button
|
||||||
|
onClick={handleOpenAddTag}
|
||||||
|
disabled={tags.length >= 10}
|
||||||
|
className="w-full px-3 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
添加标签
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateTags}
|
||||||
|
disabled={generating}
|
||||||
|
className="w-full px-3 py-2 text-left text-xs text-[var(--color-primary)] hover:bg-[var(--color-primary-light)] flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Sparkles size={12} />
|
||||||
|
AI 智能生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自动生成标签弹窗 */}
|
||||||
|
<AutoTagModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
suggestedTags={suggestedTags}
|
||||||
|
currentTags={tags}
|
||||||
|
onApply={handleApplyTags}
|
||||||
|
loading={generating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/features/Tags/index.ts
Normal file
4
src/components/features/Tags/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { TagBadge } from './TagBadge';
|
||||||
|
export { TagFilter } from './TagFilter';
|
||||||
|
export { TagManager } from './TagManager';
|
||||||
|
export { AutoTagModal } from './AutoTagModal';
|
||||||
@ -6,11 +6,13 @@ import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot
|
|||||||
import { UserMenu } from '@/components/ui/UserMenu';
|
import { UserMenu } from '@/components/ui/UserMenu';
|
||||||
import { NewChatModal } from '@/components/features/NewChatModal';
|
import { NewChatModal } from '@/components/features/NewChatModal';
|
||||||
import { SearchModal } from '@/components/features/SearchModal';
|
import { SearchModal } from '@/components/features/SearchModal';
|
||||||
|
import { TagFilter } from '@/components/features/Tags';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConversations } from '@/hooks/useConversations';
|
import { useConversations } from '@/hooks/useConversations';
|
||||||
|
import { useTagFilter } from '@/hooks/useTags';
|
||||||
import { useAuth } from '@/providers/AuthProvider';
|
import { useAuth } from '@/providers/AuthProvider';
|
||||||
import type { Conversation } from '@/drizzle/schema';
|
import type { Conversation } from '@/drizzle/schema';
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useHotkeys } from '@/hooks/useHotkeys';
|
import { useHotkeys } from '@/hooks/useHotkeys';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@ -22,6 +24,7 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { conversations, loading, deleteConversation, updateConversation } = useConversations();
|
const { conversations, loading, deleteConversation, updateConversation } = useConversations();
|
||||||
|
const { selectedTags, selectTag, clearFilter, matchesFilter } = useTagFilter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@ -32,6 +35,12 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 根据标签筛选对话
|
||||||
|
const filteredConversations = useMemo(() => {
|
||||||
|
if (selectedTags.length === 0) return conversations;
|
||||||
|
return conversations.filter((conv) => matchesFilter(conv.tags as string[] | null));
|
||||||
|
}, [conversations, selectedTags, matchesFilter]);
|
||||||
|
|
||||||
// 聚焦输入框
|
// 聚焦输入框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingId && inputRef.current) {
|
if (editingId && inputRef.current) {
|
||||||
@ -157,8 +166,8 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 按时间分组对话
|
// 按时间分组对话(使用筛选后的列表)
|
||||||
const groupedConversations = groupConversationsByTime(conversations);
|
const groupedConversations = groupConversationsByTime(filteredConversations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -200,6 +209,17 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 标签筛选 */}
|
||||||
|
<TagFilter
|
||||||
|
onFilterChange={(tags) => {
|
||||||
|
if (tags.length > 0) {
|
||||||
|
selectTag(tags[0]);
|
||||||
|
} else {
|
||||||
|
clearFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 助手库入口 */}
|
{/* 助手库入口 */}
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<Link
|
<Link
|
||||||
@ -301,13 +321,23 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
|||||||
<Link
|
<Link
|
||||||
href={`/chat/${conversation.conversationId}`}
|
href={`/chat/${conversation.conversationId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate pr-8',
|
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors pr-8',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]'
|
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]'
|
||||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{conversation.title}
|
<div className="truncate">{conversation.title}</div>
|
||||||
|
{/* 标签 - 极简文字式 */}
|
||||||
|
{conversation.tags && (conversation.tags as string[]).length > 0 && (
|
||||||
|
<div className="mt-1 text-[10px] text-[var(--color-text-muted)] truncate">
|
||||||
|
{(conversation.tags as string[]).slice(0, 3).map((tag, index) => (
|
||||||
|
<span key={tag} className="text-[var(--color-text-tertiary)]">
|
||||||
|
<span className="opacity-50">#</span>{tag}{index < Math.min((conversation.tags as string[]).length, 3) - 1 ? ' ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
2
src/drizzle/migrations/0016_next_ultimo.sql
Normal file
2
src/drizzle/migrations/0016_next_ultimo.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "conversations" ADD COLUMN "tags" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "conversations" ADD COLUMN "auto_tagged_at" timestamp with time zone;
|
||||||
1354
src/drizzle/migrations/meta/0016_snapshot.json
Normal file
1354
src/drizzle/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -113,6 +113,13 @@
|
|||||||
"when": 1766812096044,
|
"when": 1766812096044,
|
||||||
"tag": "0015_milky_anthem",
|
"tag": "0015_milky_anthem",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766857466874,
|
||||||
|
"tag": "0016_next_ultimo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -173,6 +173,9 @@ export const conversations = pgTable('conversations', {
|
|||||||
// 状态
|
// 状态
|
||||||
isArchived: boolean('is_archived').default(false),
|
isArchived: boolean('is_archived').default(false),
|
||||||
isPinned: boolean('is_pinned').default(false),
|
isPinned: boolean('is_pinned').default(false),
|
||||||
|
// 标签系统
|
||||||
|
tags: jsonb('tags').$type<string[]>().default([]), // 对话标签数组
|
||||||
|
autoTaggedAt: timestamp('auto_tagged_at', { withTimezone: true }), // AI 自动标签生成时间
|
||||||
// 时间戳
|
// 时间戳
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
|
|||||||
229
src/hooks/useTags.ts
Normal file
229
src/hooks/useTags.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { TagStats, SuggestedTag, AutoTagResponse, AllTagsResponse } from '@/types/tags';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局标签管理 Hook - 获取用户所有标签统计
|
||||||
|
*/
|
||||||
|
export function useTags() {
|
||||||
|
const [tags, setTags] = useState<TagStats[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTags = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch('/api/tags');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取标签失败');
|
||||||
|
}
|
||||||
|
const data: AllTagsResponse = await response.json();
|
||||||
|
setTags(data.tags);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, [fetchTags]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tags,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchTags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话标签管理 Hook - 管理单个对话的标签
|
||||||
|
*/
|
||||||
|
export function useConversationTags(conversationId: string | null) {
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [suggestedTags, setSuggestedTags] = useState<SuggestedTag[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 获取对话标签
|
||||||
|
const fetchTags = useCallback(async () => {
|
||||||
|
if (!conversationId) {
|
||||||
|
setTags([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}/tags`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取标签失败');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setTags(data.tags || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
const updateTags = useCallback(async (newTags: string[]) => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpdating(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}/tags`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tags: newTags }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || '更新标签失败');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setTags(data.tags || []);
|
||||||
|
return data.tags;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// 添加单个标签
|
||||||
|
const addTag = useCallback(async (tag: string) => {
|
||||||
|
const trimmedTag = tag.trim();
|
||||||
|
if (!trimmedTag || tags.includes(trimmedTag)) return;
|
||||||
|
const newTags = [...tags, trimmedTag];
|
||||||
|
return updateTags(newTags);
|
||||||
|
}, [tags, updateTags]);
|
||||||
|
|
||||||
|
// 移除单个标签
|
||||||
|
const removeTag = useCallback(async (tag: string) => {
|
||||||
|
const newTags = tags.filter((t) => t !== tag);
|
||||||
|
return updateTags(newTags);
|
||||||
|
}, [tags, updateTags]);
|
||||||
|
|
||||||
|
// AI 自动生成标签
|
||||||
|
const generateTags = useCallback(async () => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
setSuggestedTags([]);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}/tags/auto`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || '生成标签失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: AutoTagResponse = await response.json();
|
||||||
|
setSuggestedTags(data.suggestedTags);
|
||||||
|
return data.suggestedTags;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// 应用选中的推荐标签
|
||||||
|
const applySuggestedTags = useCallback(async (selectedTags: string[]) => {
|
||||||
|
// 合并现有标签和选中的推荐标签,去重
|
||||||
|
const mergedTags = [...new Set([...tags, ...selectedTags])];
|
||||||
|
const result = await updateTags(mergedTags);
|
||||||
|
setSuggestedTags([]); // 清空推荐列表
|
||||||
|
return result;
|
||||||
|
}, [tags, updateTags]);
|
||||||
|
|
||||||
|
// 清空推荐标签
|
||||||
|
const clearSuggestedTags = useCallback(() => {
|
||||||
|
setSuggestedTags([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, [fetchTags]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tags,
|
||||||
|
loading,
|
||||||
|
updating,
|
||||||
|
generating,
|
||||||
|
suggestedTags,
|
||||||
|
error,
|
||||||
|
refetch: fetchTags,
|
||||||
|
updateTags,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
generateTags,
|
||||||
|
applySuggestedTags,
|
||||||
|
clearSuggestedTags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签筛选 Hook - 用于侧边栏筛选对话
|
||||||
|
*/
|
||||||
|
export function useTagFilter() {
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 切换标签选中状态
|
||||||
|
const toggleTag = useCallback((tag: string) => {
|
||||||
|
setSelectedTags((prev) => {
|
||||||
|
if (prev.includes(tag)) {
|
||||||
|
return prev.filter((t) => t !== tag);
|
||||||
|
}
|
||||||
|
return [...prev, tag];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 选中单个标签(单选模式)
|
||||||
|
const selectTag = useCallback((tag: string) => {
|
||||||
|
setSelectedTags((prev) => {
|
||||||
|
if (prev.includes(tag) && prev.length === 1) {
|
||||||
|
return []; // 如果已选中且只有一个,则取消选中
|
||||||
|
}
|
||||||
|
return [tag];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 清除所有筛选
|
||||||
|
const clearFilter = useCallback(() => {
|
||||||
|
setSelectedTags([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 检查对话是否匹配筛选条件
|
||||||
|
const matchesFilter = useCallback((conversationTags: string[] | null) => {
|
||||||
|
if (selectedTags.length === 0) return true;
|
||||||
|
if (!conversationTags || conversationTags.length === 0) return false;
|
||||||
|
// 对话必须包含所有选中的标签
|
||||||
|
return selectedTags.every((tag) => conversationTags.includes(tag));
|
||||||
|
}, [selectedTags]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedTags,
|
||||||
|
isFiltering: selectedTags.length > 0,
|
||||||
|
toggleTag,
|
||||||
|
selectTag,
|
||||||
|
clearFilter,
|
||||||
|
matchesFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
69
src/types/tags.ts
Normal file
69
src/types/tags.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 自动标签功能相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 标签统计信息
|
||||||
|
export interface TagStats {
|
||||||
|
/** 标签名称 */
|
||||||
|
name: string;
|
||||||
|
/** 该标签的对话数量 */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 推荐的标签
|
||||||
|
export interface SuggestedTag {
|
||||||
|
/** 标签名称 */
|
||||||
|
name: string;
|
||||||
|
/** AI 置信度 (0-100) */
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动生成标签 API 响应
|
||||||
|
export interface AutoTagResponse {
|
||||||
|
/** AI 推荐的标签列表 */
|
||||||
|
suggestedTags: SuggestedTag[];
|
||||||
|
/** 当前对话已有的标签 */
|
||||||
|
currentTags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签更新 API 请求
|
||||||
|
export interface UpdateTagsRequest {
|
||||||
|
/** 新的标签数组 */
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签更新 API 响应
|
||||||
|
export interface UpdateTagsResponse {
|
||||||
|
/** 更新后的标签数组 */
|
||||||
|
tags: string[];
|
||||||
|
/** 更新时间 */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局标签 API 响应
|
||||||
|
export interface AllTagsResponse {
|
||||||
|
/** 标签统计列表 */
|
||||||
|
tags: TagStats[];
|
||||||
|
/** 总对话数 */
|
||||||
|
totalConversations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签筛选状态
|
||||||
|
export interface TagFilterState {
|
||||||
|
/** 当前选中的标签 */
|
||||||
|
selectedTags: string[];
|
||||||
|
/** 是否启用筛选 */
|
||||||
|
isFiltering: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签颜色 - 统一简洁风格
|
||||||
|
export const TAG_COLOR = {
|
||||||
|
bg: 'rgba(113, 113, 122, 0.12)', // 灰色半透明背景
|
||||||
|
text: '#71717A', // 灰色文字
|
||||||
|
border: 'rgba(113, 113, 122, 0.2)', // 灰色边框
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 获取标签颜色(统一返回相同颜色,保持简洁一致)
|
||||||
|
export function getTagColor(_tagName: string): typeof TAG_COLOR {
|
||||||
|
return TAG_COLOR;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user