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:
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 />}
|
||||
|
||||
Reference in new issue
Block a user