Files
deerflow-code/frontend-web/src/pages/StudioNotebookDetailPage.tsx
T

892 lines
39 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlertCircle,
AlignLeft,
ArrowLeft,
ArrowUp,
BookOpen,
Files,
Globe,
Loader2,
Plus,
Square,
X,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import { useModels } from "@/core/models/hooks";
import { readRuntimeEnv } from "@/env";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MessageList } from "@/components/workspace/messages/message-list";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ChatBox } from "@/components/workspace/chats/chat-box";
import { rehypeCitations } from "@/core/rehype";
import {
DocumentUploadDialogProvider,
useDocumentUploadDialog,
} from "@/components/assistant-ui/document-upload-popup";
// ── 来源卡片图标 ────────────────────────────────────────
function SourceIcon({ item }: { item: SourceItem }) {
const fileType = item.document?.file_type?.toLowerCase();
if (item.source_type === "FILE" && fileType === "pdf") {
return (
<span className="flex size-8 items-center justify-center rounded-lg bg-red-600 text-[10px] font-bold text-white">
PDF
</span>
);
}
if (item.source_type === "KNOWLEDGE_CONNECTOR") return <Globe className="size-6 text-muted-foreground" />;
if (item.source_type === "NOTE") return <AlignLeft className="size-6 text-muted-foreground" />;
return <Files className="size-6 text-muted-foreground" />;
}
// ── 单个来源卡片 ────────────────────────────────────────
function SourceCard({
item,
selected,
onToggle,
onDelete,
}: {
item: SourceItem;
selected: boolean;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}) {
const title = item.title ?? item.document?.title ?? "未命名";
const isProcessing = item.status === "processing";
const isFailed = item.status === "failed";
return (
<div
title={isFailed ? `${title}(处理失败)` : title}
onClick={() => {
if (isFailed) return;
onToggle(item.id);
}}
className={`group relative flex h-32 w-full flex-col justify-between rounded-2xl p-4 ring-1 transition-colors ${isFailed
? "cursor-default bg-destructive/5 ring-destructive/40"
: selected
? "cursor-pointer bg-primary/10 ring-primary"
: "cursor-pointer bg-muted ring-border/50 hover:bg-muted/70"
}`}
>
{/* 删除按钮 - 右上角始终显示,悬停时高亮 */}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
className="absolute top-1.5 right-1.5 z-20 rounded-md bg-background/80 backdrop-blur p-1.5 text-muted-foreground opacity-60 shadow-sm transition-all hover:bg-destructive hover:text-white group-hover:opacity-100"
title="删除来源"
>
<X className="size-3.5" />
</button>
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
{isProcessing ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : isFailed ? (
<AlertCircle className="size-6 text-destructive" />
) : (
<SourceIcon item={item} />
)}
</div>
<div className="min-w-0">
<p className={`truncate text-xs font-medium ${isFailed ? "text-destructive" : "text-foreground"}`}>{title}</p>
{isFailed && <p className="mt-0.5 text-[10px] text-destructive/80">处理失败,请删除后重试</p>}
</div>
</div>
);
}
// ── 上传中占位卡片 ──────────────────────────────────────
function PendingSourceCard({ title }: { title: string }) {
return (
<div className="group relative flex h-32 w-full flex-col justify-between rounded-2xl p-4 ring-1 ring-border/50 bg-muted/40 cursor-default">
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">{title}</p>
</div>
</div>
);
}
// ── 来源骨架屏 ──────────────────────────────────────────
function SourceCardSkeleton() {
return (
<div className="flex h-32 w-full flex-col justify-between rounded-2xl bg-muted p-4">
<div className="size-10 animate-pulse rounded-xl bg-muted-foreground/20" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted-foreground/20" />
</div>
);
}
// ── 来源 Dialog ─────────────────────────────────────────
function SourcesDialog({
open,
onClose,
notebookName,
sources,
isLoadingSources,
selectedSourceIds,
pendingSources,
pendingImportTitles,
onToggle,
onDelete,
}: {
open: boolean;
onClose: () => void;
notebookName: string;
sources: SourceItem[];
isLoadingSources: boolean;
selectedSourceIds: number[];
pendingSources: Map<string, { id?: number; title: string }>;
pendingImportTitles: string[];
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}) {
const { openDialog: openUploadPopup } = useDocumentUploadDialog();
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent
showCloseButton={false}
className="flex h-[90vh] w-[75vw] max-w-[75vw] sm:max-w-[75vw] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="flex flex-row items-start justify-between border-b border-border px-6 py-5">
<div>
<DialogTitle className="text-base font-semibold text-foreground">来源</DialogTitle>
<p className="mt-0.5 text-sm text-muted-foreground">
{notebookName} · 选中的来源将用于 AI 回答(不选默认全部)
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</DialogHeader>
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* 左侧导航 */}
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
<button
onClick={() => openUploadPopup()}
className="flex w-full flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-border bg-muted/40 px-4 py-8 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-primary"
>
<Plus className="size-8 shrink-0" />
<span className="text-sm font-medium leading-tight text-center">上传来源</span>
</button>
</div>
{/* 右侧内容 */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: <>
{Array.from(pendingSources.entries())
.filter(([, s]) => !sources.some((src) => {
const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? "";
return srcTitle === s.title;
}))
.map(([taskId, s]) => (
<PendingSourceCard key={taskId} title={s.title} />
))}
{pendingImportTitles
.filter((t) => !sources.some((src) => {
const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? "";
return srcTitle === t;
}))
.map((t) => (
<PendingSourceCard key={`import-${t}`} title={t} />
))}
{sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</>
}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage({
notebookId: notebookIdProp,
}: {
/** 内嵌使用(如课题研究右侧)时直接传入;缺省则从路由参数取。 */
notebookId?: number;
} = {}) {
const params = useParams<{ notebookId: string }>();
const notebookId =
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
const notebookFrontendUrl = readRuntimeEnv("VITE_AIPI_FRONTEND_URL") || "";
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
const { models } = useModels();
const queryClient = useQueryClient();
const [sourcesOpen, setSourcesOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
const [initializedForNotebook, setInitializedForNotebook] = useState<number | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
const [input, setInput] = useState("");
// null = 显示历史线程列表;string = 显示该线程的聊天
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
// 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配)
const pendingSelectTitlesRef = useRef<Set<string>>(new Set());
const [threadsExpanded, setThreadsExpanded] = useState(false);
const THREADS_LIMIT = 5;
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
const [citationLoading, setCitationLoading] = useState(false);
// 轮询任务管理:task_id -> 来源信息
const [pendingSources, setPendingSources] = useState<Map<string, { id?: number; title: string }>>(new Map());
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 同步导入(搜索导入)的占位标题列表,sources 刷新后按标题匹配移除
const [pendingImportTitles, setPendingImportTitles] = useState<string[]>([]);
useEffect(() => {
if (!selectedModel && models.length > 0) {
setSelectedModel(models[0]!.name);
}
}, [models, selectedModel]);
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
useEffect(() => {
if (sources.length > 0 && initializedForNotebook !== notebook?.id) {
setInitializedForNotebook(notebook?.id ?? null);
setSelectedSourceIds(sources.map((s) => s.id));
}
}, [sources, notebook?.id, initializedForNotebook]);
// sources 刷新后,将轮询成功/同步导入的来源(按标题匹配)自动选中,并移除占位卡
useEffect(() => {
const getTitle = (s: SourceItem) =>
s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
if (pendingSelectTitlesRef.current.size > 0) {
const matched = sources.filter((s) => pendingSelectTitlesRef.current.has(getTitle(s)));
if (matched.length > 0) {
setSelectedSourceIds((prev) => [...new Set([...prev, ...matched.map((s) => s.id)])]);
matched.forEach((s) => pendingSelectTitlesRef.current.delete(getTitle(s)));
}
}
setPendingImportTitles((prev) => {
if (prev.length === 0) return prev;
const sourceTitles = new Set(sources.map(getTitle));
const remaining = prev.filter((t) => !sourceTitles.has(t));
return remaining.length === prev.length ? prev : remaining;
});
}, [sources]);
const citationRehypePlugins = useMemo(() => {
const mapped = sources.map((s) => ({
id: s.id,
title: s.title ?? (s.document as { title?: string } | undefined)?.title ?? String(s.id),
}));
return [() => rehypeCitations(mapped)] as ReturnType<typeof import("@/core/rehype").useRehypeSplitWordsIntoSpans>;
}, [sources]);
const { data: notebookThreads, refetch: refetchThreads } = useNotebookThreads(notebook?.id ?? null);
const { thread, threadId: chatThreadId, isStreaming, send, setThreadId } = useNotebookChat(
notebook?.id ?? 0,
sources,
selectedModel || undefined,
activeThreadId ?? undefined,
);
const handleToggleSource = (id: number) => {
setSelectedSourceIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleDeleteSource = async (sourceId: number) => {
if (!notebook) return;
try {
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || "http://localhost:8001";
const response = await fetch(`${backendUrl}/api/v1/source/${sourceId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${studioToken ?? ""}` },
});
if (response.ok) {
// 刷新来源列表
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
// 刷新 notebooks 数据以更新 source_count
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
// 从选中列表中移除
setSelectedSourceIds((prev) => prev.filter((id) => id !== sourceId));
} else {
console.error("删除来源失败");
}
} catch (error) {
console.error("删除来源失败:", error);
}
};
const handleStop = useCallback(() => {
void thread.stop();
}, [thread]);
const handleSend = async (e: React.SyntheticEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
// 发送后切换到聊天视图(activeThreadId 由 onCreated 回调设置)
await send(text, selectedSourceIds);
};
// Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 +
// 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路,
// 把当前选中的来源也一并带上,保持上下文一致。
const handleClarificationSubmit = useCallback(
async (answer: string) => {
if (isStreaming) return;
await send(answer, selectedSourceIds);
},
[send, selectedSourceIds, isStreaming],
);
const handleBackToList = () => {
setActiveThreadId(null);
setThreadId(undefined);
void refetchThreads();
};
// 上传完成回调
const handleUploadComplete = (uploadedSources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => {
if (!notebook) return;
// 将正在处理的来源添加到轮询队列
const processing = uploadedSources.filter(s => s.status === 'processing' && s.task_id);
if (processing.length > 0) {
setPendingSources((prev) => {
const newMap = new Map(prev);
processing.forEach(s => {
if (s.task_id) {
newMap.set(s.task_id, { id: s.id, title: s.title });
}
});
return newMap;
});
}
// 同步导入(status=success,无 task_id):立刻显示占位卡 + 等 sources 刷新后自动选中
const syncTitles = uploadedSources.filter(s => s.status === 'success' && s.title).map(s => s.title);
if (syncTitles.length > 0) {
syncTitles.forEach(t => pendingSelectTitlesRef.current.add(t));
setPendingImportTitles(prev => [...new Set([...prev, ...syncTitles])]);
}
// 刷新来源列表
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
// 刷新 notebooks 数据以更新 source_count
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
};
// 轮询单个来源任务状态
const pollSourceStatus = useCallback(async (taskId: string) => {
if (!notebook) return;
try {
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || "http://localhost:8001";
const token = getStoredAuth()?.access_token ?? "";
const response = await fetch(`${backendUrl}/api/v1/task/${taskId}/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
const taskStatus = data.status === 'failed' ? 'error' : data.status;
if (taskStatus !== 'processing') {
// 先取出 source 信息,再从轮询队列移除
const source = pendingSources.get(taskId);
setPendingSources((prev) => {
const newMap = new Map(prev);
newMap.delete(taskId);
return newMap;
});
// 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中
if (taskStatus === 'success' && source?.title) {
pendingSelectTitlesRef.current.add(source.title);
}
// 刷新来源列表
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
// 刷新 notebooks 数据以更新 source_count
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
}
}
} catch (error) {
console.error(`轮询任务 ${taskId} 状态失败:`, error);
}
}, [notebook?.id, pendingSources, queryClient]);
// 控制轮询 - 只在对话框打开时轮询
useEffect(() => {
// 对话框关闭时,停止轮询(但保留 pendingSources 数据)
if (!sourcesOpen) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return;
}
// 对话框打开时,刷新来源列表获取最新状态
if (sourcesOpen && notebook) {
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
}
// 对话框打开且有待处理任务时,启动轮询
if (pendingSources.size === 0) {
return;
}
if (pollingIntervalRef.current) return;
// 立即检查一次
pendingSources.forEach((_, taskId) => {
void pollSourceStatus(taskId);
});
// 设置定时轮询
pollingIntervalRef.current = setInterval(() => {
setPendingSources((current) => {
if (current.size === 0) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return current;
}
current.forEach((_, taskId) => {
void pollSourceStatus(taskId);
});
return current;
});
}, 2000);
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [sourcesOpen, pendingSources, pollSourceStatus, notebook, queryClient]);
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 bg-background px-8">
<div className="size-16 animate-pulse rounded-2xl bg-muted" />
<div className="h-8 w-64 animate-pulse rounded-lg bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
);
}
if (!notebook) {
return (
<div className="flex h-full flex-col items-center justify-center bg-background">
<p className="text-muted-foreground">空间不存在</p>
</div>
);
}
const hasMessages = (thread.messages ?? []).some(
(m) => m.type === "human" || m.type === "ai",
);
// 聊天视图:有 activeThreadId 或已有消息
const isChatView = activeThreadId !== null || hasMessages;
return (
<DocumentUploadDialogProvider
searchSpace={{ id: notebook.id }}
onClose={() => {
// 关闭上传弹窗时刷新来源列表与 source_count(覆盖文件上传 / A·B·Y 导入等所有路径)
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
}}
onUploadSuccess={() => {
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
}}
onUploadComplete={handleUploadComplete}
>
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={chatThreadId ?? ""}>
<div className="relative flex h-full flex-col overflow-hidden bg-background">
{/* 背景渐变(列表视图时显示) */}
{!isChatView && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
}}
/>
)}
{/* 聊天视图:顶部栏 */}
{isChatView && (
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
<div className="flex items-center gap-3">
<button
onClick={handleBackToList}
className="rounded-lg p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</button>
<div className="flex size-8 items-center justify-center rounded-xl bg-muted">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-5 rounded-lg object-contain" />
) : (
<Files className="size-4 text-muted-foreground" />
)}
</div>
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
</div>
<div className="flex items-center gap-2">
<a
href={`${notebookFrontendUrl}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
>
<BookOpen className="size-3" />
<span>我的空间</span>
</a>
<button
onClick={() => setSourcesOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3" />
<span>{notebook.source_count} 个来源</span>
</button>
</div>
</div>
)}
{/* 列表视图:空间标题 + 历史线程列表 */}
{!isChatView && (
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
{/* 右上角跳转按钮 */}
<a
href={`${notebookFrontendUrl}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank"
rel="noreferrer"
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
>
<BookOpen className="size-3.5" />
<span>我的空间</span>
</a>
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
<div className="flex size-14 items-center justify-center leading-none select-none">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
) : (
<Files className="size-10 text-foreground/50" />
)}
</div>
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
{notebook.name}
</h1>
<button
onClick={() => setSourcesOpen(true)}
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
>
<Files className="size-3.5" />
<span>{notebook.source_count} 个来源</span>
</button>
{/* 输入框(列表视图内联) */}
<form
onSubmit={(e) => { void handleSend(e); }}
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
>
<button
type="button"
onClick={() => {
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
setSourcesOpen(true);
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isStreaming ? (
<button
type="button"
onClick={handleStop}
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="停止回答"
>
<Square className="size-3.5 fill-current" />
</button>
) : input.trim() ? (
<button
type="submit"
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
title="发送"
>
<ArrowUp className="size-3.5" />
</button>
) : null}
</form>
{/* 空状态提示 */}
{(!notebookThreads || notebookThreads.length === 0) && (
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
空间对话会显示在这里
</div>
)}
{/* 历史线程列表 */}
{notebookThreads && notebookThreads.length > 0 && (
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
const values = t.values as Record<string, unknown> | undefined;
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
const firstHuman = msgs?.find((m) => m.type === "human");
const rawContent = firstHuman?.content;
const preview =
typeof rawContent === "string"
? rawContent.slice(0, 60)
: Array.isArray(rawContent)
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
: "对话";
const title = (values?.title as string | undefined) || preview || "对话";
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={t.thread_id}
onClick={() => {
setActiveThreadId(t.thread_id);
setThreadId(t.thread_id);
}}
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
>
<span className="truncate text-sm font-medium text-foreground">{title}</span>
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
</button>
);
})}
</div>
{notebookThreads.length > THREADS_LIMIT && (
<button
onClick={() => setThreadsExpanded((v) => !v)}
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
</button>
)}
</div>
)}
</div>
</div>
)}
{/* 聊天视图:消息列表 */}
{isChatView && (
<div
className="min-h-0 flex-1 overflow-hidden"
onClick={(e) => {
const sup = (e.target as HTMLElement).closest<HTMLElement>("[data-chunk-id]");
if (!sup) return;
const chunkId = Number(sup.dataset.chunkId);
if (!chunkId) return;
setCitationChunkId(chunkId);
setCitationDoc(null);
setCitationLoading(true);
const email = getStoredAuth()?.email ?? "";
const tok = queryClient.getQueryData<string>(["studio", "token", email]) ?? "";
void fetchDocumentByChunk(chunkId, tok)
.then((doc) => setCitationDoc(doc))
.finally(() => setCitationLoading(false));
}}
>
<MessageList
threadId={chatThreadId ?? ""}
thread={thread}
paddingBottom={120}
extraRehypePlugins={citationRehypePlugins}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
)}
{/* 聊天视图的底部输入框 */}
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
<form
onSubmit={(e) => { void handleSend(e); }}
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
>
<button
type="button"
onClick={() => setSourcesOpen(true)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isStreaming ? (
<button
type="button"
onClick={handleStop}
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="停止回答"
>
<Square className="size-3.5 fill-current" />
</button>
) : input.trim() ? (
<button
type="submit"
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
title="发送"
>
<ArrowUp className="size-3.5" />
</button>
) : null}
</form>
</div>
{/* 引用来源弹窗 */}
<Dialog open={citationChunkId !== null} onOpenChange={(v) => { if (!v) setCitationChunkId(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-base">引用来源</DialogTitle>
</DialogHeader>
{citationLoading && (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{!citationLoading && citationDoc && (
<div className="flex flex-col gap-4 pt-1">
<div className="flex items-start gap-3">
<span className="mt-0.5 shrink-0 rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground uppercase">
{citationDoc.file_type || citationDoc.document_type}
</span>
<span className="text-sm font-medium text-foreground leading-snug">{citationDoc.title}</span>
</div>
{citationDoc.chunks.find((c) => c.id === citationChunkId) && (
<div className="max-h-60 overflow-y-auto rounded-xl bg-muted p-4 text-sm text-foreground leading-relaxed">
{citationDoc.chunks.find((c) => c.id === citationChunkId)!.content}
</div>
)}
</div>
)}
{!citationLoading && !citationDoc && (
<p className="py-4 text-sm text-muted-foreground">加载失败,请重试。</p>
)}
</DialogContent>
</Dialog>
{/* 来源 Dialog */}
<SourcesDialog
open={sourcesOpen}
onClose={() => setSourcesOpen(false)}
notebookName={notebook.name}
sources={sources}
isLoadingSources={isLoadingSources}
selectedSourceIds={selectedSourceIds}
pendingSources={pendingSources}
pendingImportTitles={pendingImportTitles}
onToggle={handleToggleSource}
onDelete={handleDeleteSource}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
</DocumentUploadDialogProvider>
);
}