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

@@ -76,12 +76,14 @@ export function MessageListItem({
isLoading,
threadId,
tokenUsageEnabled = false,
extraRehypePlugins,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const isHuman = message.type === "human";
const messageMarkdown =
@@ -99,6 +101,7 @@ export function MessageListItem({
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
extraRehypePlugins={extraRehypePlugins}
/>
{!isLoading && (
<MessageToolbar
@@ -159,14 +162,20 @@ function MessageContent_({
isLoading = false,
threadId,
tokenUsageEnabled = false,
extraRehypePlugins,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
@@ -48,6 +48,7 @@ export function MessageList({
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
@@ -57,12 +58,17 @@ export function MessageList({
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
@@ -191,6 +197,7 @@ export function MessageList({
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
extraRehypePlugins={extraRehypePlugins}
/>
);
});
@@ -16,6 +16,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -38,6 +39,7 @@ export function WorkspaceNavChatList({
const { t } = useI18n();
const pathname = usePathname();
return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1">
<SidebarMenuItem>
@@ -147,5 +149,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{!showCollapseTrigger && <WorkspaceNavStudio />}
</>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, Loader2, Plus } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
const NAV_ITEM_CLASS =
"text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
const SIDEBAR_PREVIEW_COUNT = 5;
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(true);
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);
}
};
if (isError || (!isLoading && notebooks.length === 0)) {
return null;
}
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
return (
<>
<Collapsible open={open} onOpenChange={setOpen}>
<SidebarGroup className="px-2 pt-2">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="flex cursor-pointer select-none items-center justify-between px-1 text-xs font-medium text-sidebar-foreground/50 hover:text-sidebar-foreground/80 transition-colors">
<span>工作室</span>
<ChevronDown
className={cn(
"size-3.5 transition-transform duration-200",
open ? "rotate-0" : "-rotate-90",
)}
/>
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenu className="mt-0.5 gap-0.5">
{/* 新建笔记本 */}
<SidebarMenuItem>
<SidebarMenuButton
className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
>
<Plus className="shrink-0" />
<span>新建笔记本</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{isLoading
? Array.from({ length: 3 }).map((_, i) => (
<SidebarMenuItem key={i}>
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
</SidebarMenuItem>
))
: preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
<SidebarMenuButton
asChild
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
className={cn(NAV_ITEM_CLASS)}
>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
title={nb.description || nb.name}
>
<BookOpen className="shrink-0 size-4" />
<span className="truncate">{nb.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{/* 所有笔记本 */}
{!isLoading && notebooks.length > 0 && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/page/workspace/studio/notebooks"}
className={cn(NAV_ITEM_CLASS)}
>
<Link to="/page/workspace/studio/notebooks">
<span className="mr-0.5 text-base leading-none">···</span>
<span>所有笔记本</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<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>
</>
);
}