feat: 笔记本 Studio 模块 — RAG 聊天、引用弹窗、侧边栏工作室

- 新增 Studio 笔记本列表页与详情页(RAG 流式聊天、历史线程切换)
- 修复 streamSubgraphs: true 使 AI 流式消息正常展示
- 引用圆圈 [[cite:N]] 渲染 + 点击弹窗显示文档 chunk 内容
- 侧边栏「工作室」区域支持折叠/展开,笔记本过滤 is_owner=true
- 后端 lead_agent 支持 notebook_source_ids / notebook_token 参数
- 线程 metadata 改为 thread_type=notebook,侧边栏过滤同步

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-05-26 23:55:08 +08:00
1 parent d738c13e9e
commit ff8eb930f7
17 files changed
+1325 -12

No files matched your search

@@ -0,0 +1,494 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlignLeft,
ArrowLeft,
CloudUpload,
Files,
Globe,
Loader2,
Plus,
Send,
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 { useModels } from "@/core/models/hooks";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MessageList } from "@/components/workspace/messages/message-list";
import { rehypeCitations } from "@/core/rehype";
// ── 来源卡片图标 ────────────────────────────────────────
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,
}: {
item: SourceItem;
selected: boolean;
onToggle: (id: number) => void;
}) {
const title = item.title ?? item.document?.title ?? "未命名";
const isProcessing = item.status === "processing";
return (
<div
title={title}
onClick={() => onToggle(item.id)}
className={`group relative flex h-32 w-full cursor-pointer flex-col justify-between rounded-2xl p-4 ring-1 transition-colors ${
selected
? "bg-primary/10 ring-primary"
: "bg-muted ring-border/50 hover:bg-muted/70"
}`}
>
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
{isProcessing ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : (
<SourceIcon item={item} />
)}
</div>
<p className="truncate text-xs font-medium text-foreground">{title}</p>
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[200px] -translate-x-1/2 rounded-lg bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md ring-1 ring-border group-hover:block">
{title}
</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,
onToggle,
}: {
open: boolean;
onClose: () => void;
notebookName: string;
sources: SourceItem[];
isLoadingSources: boolean;
selectedSourceIds: number[];
onToggle: (id: number) => void;
}) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent
showCloseButton={false}
className="flex h-[75vh] w-[900px] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[900px]"
>
<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">
{[
{ icon: Plus, label: "上传文件" },
{ icon: CloudUpload, label: "从云端硬盘添加" },
{ icon: Globe, label: "添加网站" },
{ icon: AlignLeft, label: "复制的文字" },
].map(({ icon: Icon, label }) => (
<button
key={label}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Icon className="size-4 shrink-0" />
<span className="text-left leading-tight">{label}</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} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
/>
))}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage() {
const { notebookId } = useParams<{ notebookId: string }>();
const { notebooks, isLoading } = useStudioNotebooks();
const { models } = useModels();
const queryClient = useQueryClient();
const [sourcesOpen, setSourcesOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
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);
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
const [citationLoading, setCitationLoading] = useState(false);
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);
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 handleSend = async (e: React.SyntheticEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
// 发送后切换到聊天视图(activeThreadId 由 onCreated 回调设置)
await send(text, selectedSourceIds);
};
const handleBackToList = () => {
setActiveThreadId(null);
setThreadId(undefined);
void refetchThreads();
};
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 (
<div className="relative flex h-full flex-col overflow-hidden bg-background">
{/* 背景渐变(列表视图时显示) */}
{!isChatView && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
}}
/>
)}
{/* 聊天视图:顶部栏 */}
{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>
<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>
)}
{/* 列表视图:笔记本标题 + 历史线程列表 */}
{!isChatView && (
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
) : (
<Files className="size-8 text-muted-foreground" />
)}
</div>
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
{notebook.name}
</h1>
<button
onClick={() => setSourcesOpen(true)}
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3.5" />
<span>{notebook.source_count} 个来源</span>
</button>
{/* 历史线程列表 */}
{notebookThreads && notebookThreads.length > 0 && (
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
{notebookThreads.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 px-4 py-3 text-left transition-colors hover:bg-muted/70"
>
<span className="truncate text-sm font-medium text-foreground">{title}</span>
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
</button>
);
})}
</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}
/>
</div>
)}
{/* 输入框区域 */}
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
<form
onSubmit={(e) => { void handleSend(e); }}
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
>
<button
type="button"
onClick={() => setSourcesOpen(true)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" />
</button>
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
>
{isStreaming ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</button>
</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}
onToggle={handleToggleSource}
/>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Files, Loader2, Plus } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
import type { StudioNotebook } from "@/core/studio/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
function NotebookCardSkeleton() {
return (
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
<div className="size-10 animate-pulse rounded-xl bg-muted" />
<div className="flex flex-col gap-1.5">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
</div>
</div>
);
}
function NotebookCard({ nb }: { nb: StudioNotebook }) {
return (
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
>
<div className="flex items-start justify-between">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
{nb.avatar_url ? (
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
) : (
<Files className="size-5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
{nb.name}
</span>
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
</div>
</Link>
);
}
export default function StudioNotebooksPage() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setCreating(true);
setError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setDialogOpen(false);
setName("");
// 刷新笔记本列表
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
return (
<div className="min-h-full bg-background p-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
<button
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="size-4" />
新建笔记本
</button>
</div>
{isError && (
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
</div>
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -33,6 +33,8 @@ import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -189,6 +191,24 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />}