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