892 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|