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,
|
isLoading,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
extraRehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
const messageMarkdown =
|
const messageMarkdown =
|
||||||
@@ -99,6 +101,7 @@ export function MessageListItem({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
/>
|
/>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<MessageToolbar
|
<MessageToolbar
|
||||||
@@ -159,14 +162,20 @@ function MessageContent_({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
extraRehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
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 isHuman = message.type === "human";
|
||||||
const resolveArtifact = useArtifactResolver(threadId);
|
const resolveArtifact = useArtifactResolver(threadId);
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function MessageList({
|
|||||||
thread,
|
thread,
|
||||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
extraRehypePlugins,
|
||||||
onClarificationSubmit,
|
onClarificationSubmit,
|
||||||
onSetupAgentApprove,
|
onSetupAgentApprove,
|
||||||
onSetupAgentReject,
|
onSetupAgentReject,
|
||||||
@@ -57,12 +58,17 @@ export function MessageList({
|
|||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
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 updateSubtask = useUpdateSubtask();
|
||||||
const messages = thread.messages;
|
const messages = thread.messages;
|
||||||
|
|
||||||
@@ -191,6 +197,7 @@ export function MessageList({
|
|||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
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 { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||||
|
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
|
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
@@ -38,6 +39,7 @@ export function WorkspaceNavChatList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||||
<SidebarMenu className="gap-1">
|
<SidebarMenu className="gap-1">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -147,5 +149,7 @@ export function WorkspaceNavChatList({
|
|||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,6 +69,14 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
|
||||||
|
export function getNotebookApiBaseURL(): string {
|
||||||
|
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
|
||||||
|
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
|
/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
|
||||||
export function getNoteApiBaseURL(): string {
|
export function getNoteApiBaseURL(): string {
|
||||||
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
|
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
|
||||||
|
|||||||
@@ -3,6 +3,55 @@ import { useMemo } from "react";
|
|||||||
import { visit } from "unist-util-visit";
|
import { visit } from "unist-util-visit";
|
||||||
import type { BuildVisitor } from "unist-util-visit";
|
import type { BuildVisitor } from "unist-util-visit";
|
||||||
|
|
||||||
|
const CITE_RE = /\[\[cite:(\d+)\]\]/g;
|
||||||
|
|
||||||
|
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||||
|
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
|
||||||
|
|
||||||
|
return (tree: Root) => {
|
||||||
|
visit(tree, "text", (node, index, parent) => {
|
||||||
|
if (!CITE_RE.test(node.value)) return;
|
||||||
|
CITE_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
const newNodes: ElementContent[] = [];
|
||||||
|
const citeCounter = new Map<number, number>();
|
||||||
|
let citeIndex = 0;
|
||||||
|
let last = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = CITE_RE.exec(node.value)) !== null) {
|
||||||
|
if (match.index > last) {
|
||||||
|
newNodes.push({ type: "text", value: node.value.slice(last, match.index) });
|
||||||
|
}
|
||||||
|
const docId = Number(match[1]);
|
||||||
|
if (!citeCounter.has(docId)) citeCounter.set(docId, ++citeIndex);
|
||||||
|
const num = citeCounter.get(docId)!;
|
||||||
|
const title = sourceMap.get(docId) ?? `来源 ${docId}`;
|
||||||
|
|
||||||
|
newNodes.push({
|
||||||
|
type: "element",
|
||||||
|
tagName: "sup",
|
||||||
|
properties: {
|
||||||
|
title,
|
||||||
|
"data-chunk-id": String(docId),
|
||||||
|
style:
|
||||||
|
"display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;border-radius:9999px;background:hsl(var(--primary));color:hsl(var(--primary-foreground));font-size:10px;font-weight:700;margin-left:1px;cursor:pointer;vertical-align:super;",
|
||||||
|
},
|
||||||
|
children: [{ type: "text", value: String(num) }],
|
||||||
|
});
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < node.value.length) {
|
||||||
|
newNodes.push({ type: "text", value: node.value.slice(last) });
|
||||||
|
}
|
||||||
|
if (parent && index !== undefined && newNodes.length > 0) {
|
||||||
|
(parent.children as ElementContent[]).splice(index, 1, ...newNodes);
|
||||||
|
return index + newNodes.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CJK_TEXT_RE =
|
const CJK_TEXT_RE =
|
||||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { getNotebookApiBaseURL } from "@/core/config";
|
||||||
|
import type { SourceItem, StudioNotebook } from "./types";
|
||||||
|
|
||||||
|
function base(): string {
|
||||||
|
return getNotebookApiBaseURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudioToken(email: string): Promise<string> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`获取 Token 失败:${res.status}`);
|
||||||
|
|
||||||
|
const data = (await res.json()) as { token?: string } | string;
|
||||||
|
const token = typeof data === "string" ? data : data?.token;
|
||||||
|
if (!token) throw new Error("接口未返回有效 token");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
|
||||||
|
return res.json() as Promise<StudioNotebook[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSearchSpace(
|
||||||
|
name: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, description: "", citations_enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`创建失败:${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkRead {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
document_id: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentWithChunks {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
document_type: string;
|
||||||
|
file_type: string;
|
||||||
|
chunks: ChunkRead[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDocumentByChunk(
|
||||||
|
chunkId: number,
|
||||||
|
token: string,
|
||||||
|
): Promise<DocumentWithChunks> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`获取文档失败:${res.status}`);
|
||||||
|
return res.json() as Promise<DocumentWithChunks>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotebookSources(
|
||||||
|
notebookId: number,
|
||||||
|
token: string,
|
||||||
|
): Promise<SourceItem[]> {
|
||||||
|
const apiBase = base();
|
||||||
|
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`获取来源失败:${res.status}`);
|
||||||
|
return res.json() as Promise<SourceItem[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { getAPIClient } from "@/core/api";
|
||||||
|
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
|
||||||
|
import type { AgentThreadState } from "@/core/threads/types";
|
||||||
|
import type { SourceItem, StudioNotebook } from "./types";
|
||||||
|
|
||||||
|
export function useStudioNotebooks(): {
|
||||||
|
notebooks: StudioNotebook[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
} {
|
||||||
|
const email = getStoredAuth()?.email ?? "";
|
||||||
|
|
||||||
|
const tokenQuery = useQuery({
|
||||||
|
queryKey: ["studio", "token", email],
|
||||||
|
queryFn: () => fetchStudioToken(email),
|
||||||
|
enabled: Boolean(email),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notebooksQuery = useQuery({
|
||||||
|
queryKey: ["studio", "notebooks", tokenQuery.data],
|
||||||
|
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
|
||||||
|
enabled: Boolean(tokenQuery.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
|
||||||
|
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
|
||||||
|
isError: tokenQuery.isError || notebooksQuery.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotebookThreads(notebookId: number | null) {
|
||||||
|
const client = getAPIClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["notebook-threads", notebookId],
|
||||||
|
queryFn: () =>
|
||||||
|
client.threads.search({
|
||||||
|
metadata: { thread_type: "notebook", note_id: notebookId },
|
||||||
|
limit: 20,
|
||||||
|
sortBy: "updated_at",
|
||||||
|
sortOrder: "desc",
|
||||||
|
}),
|
||||||
|
enabled: notebookId !== null,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotebookChat(
|
||||||
|
notebookId: number,
|
||||||
|
sources: SourceItem[],
|
||||||
|
modelName?: string,
|
||||||
|
initialThreadId?: string,
|
||||||
|
) {
|
||||||
|
const [threadId, setThreadId] = useState<string | undefined>(initialThreadId);
|
||||||
|
|
||||||
|
const email = getStoredAuth()?.email ?? "";
|
||||||
|
const { data: token } = useQuery({
|
||||||
|
queryKey: ["studio", "token", email],
|
||||||
|
queryFn: () => fetchStudioToken(email),
|
||||||
|
enabled: Boolean(email),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const thread = useStream<AgentThreadState>({
|
||||||
|
client: getAPIClient(),
|
||||||
|
assistantId: "lead_agent",
|
||||||
|
threadId,
|
||||||
|
fetchStateHistory: { limit: 1 },
|
||||||
|
onCreated(meta) {
|
||||||
|
setThreadId(meta.thread_id);
|
||||||
|
void getAPIClient()
|
||||||
|
.threads.update(meta.thread_id, {
|
||||||
|
metadata: { thread_type: "notebook", note_id: notebookId },
|
||||||
|
})
|
||||||
|
.catch(() => ({}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = async (text: string, selectedSourceIds: number[]) => {
|
||||||
|
if (thread.isLoading || !token) return;
|
||||||
|
const sourceIds =
|
||||||
|
selectedSourceIds.length > 0
|
||||||
|
? selectedSourceIds
|
||||||
|
: sources.map((s) => s.id).filter(Boolean);
|
||||||
|
|
||||||
|
await thread.submit(
|
||||||
|
{ messages: [{ type: "human", content: text }] },
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
streamSubgraphs: true,
|
||||||
|
config: {
|
||||||
|
configurable: {
|
||||||
|
notebook_search_space_id: notebookId,
|
||||||
|
notebook_source_ids: sourceIds,
|
||||||
|
notebook_token: token,
|
||||||
|
...(modelName ? { model_name: modelName } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotebookSources(notebookId: number | null): {
|
||||||
|
sources: SourceItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
} {
|
||||||
|
const email = getStoredAuth()?.email ?? "";
|
||||||
|
|
||||||
|
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
|
||||||
|
const tokenQuery = useQuery({
|
||||||
|
queryKey: ["studio", "token", email],
|
||||||
|
queryFn: () => fetchStudioToken(email),
|
||||||
|
enabled: Boolean(email),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourcesQuery = useQuery({
|
||||||
|
queryKey: ["studio", "sources", notebookId],
|
||||||
|
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
|
||||||
|
enabled: notebookId !== null && Boolean(tokenQuery.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: sourcesQuery.data ?? [],
|
||||||
|
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
|
||||||
|
isError: tokenQuery.isError || sourcesQuery.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface StudioTokenResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRead {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
document_type: string;
|
||||||
|
file_type: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceItem {
|
||||||
|
id: number;
|
||||||
|
search_space_id: number;
|
||||||
|
source_type: string;
|
||||||
|
title: string | null;
|
||||||
|
status: string | null;
|
||||||
|
created_at: string;
|
||||||
|
document: DocumentRead | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudioNotebook {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
user_id: string;
|
||||||
|
owner_email: string;
|
||||||
|
member_count: number;
|
||||||
|
source_count: number;
|
||||||
|
is_shared: boolean;
|
||||||
|
is_owner: boolean;
|
||||||
|
membership_role: string;
|
||||||
|
created_at: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
}
|
||||||
@@ -596,7 +596,9 @@ export function useThreads(
|
|||||||
offset += response.length;
|
offset += response.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return threads;
|
return threads.filter(
|
||||||
|
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ function readEnv(
|
|||||||
| "VITE_LANGGRAPH_BASE_URL"
|
| "VITE_LANGGRAPH_BASE_URL"
|
||||||
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
||||||
| "VITE_NOTE_API_BASE_URL"
|
| "VITE_NOTE_API_BASE_URL"
|
||||||
|
| "VITE_NOTEBOOK_API_BASE_URL"
|
||||||
| "VITE_STATIC_WEBSITE_ONLY"
|
| "VITE_STATIC_WEBSITE_ONLY"
|
||||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||||
): EnvValue {
|
): EnvValue {
|
||||||
@@ -20,6 +21,7 @@ export const env = {
|
|||||||
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
||||||
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
||||||
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
|
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
|
||||||
|
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"),
|
||||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||||
};
|
};
|
||||||
@@ -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 TagsPage from "./TagsPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||||
|
import StudioNotebooksPage from "./StudioNotebooksPage";
|
||||||
|
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
|
||||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||||
|
|
||||||
@@ -189,6 +191,24 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="studio/notebooks"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<StudioNotebooksPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="studio/notebooks/:notebookId"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<ChatRuntime>
|
||||||
|
<StudioNotebookDetailPage />
|
||||||
|
</ChatRuntime>
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="roundtable"
|
path="roundtable"
|
||||||
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
|
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
|
||||||
|
|||||||
+18
-8
@@ -466,16 +466,24 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Default lead agent (unchanged behavior)
|
# Default lead agent (unchanged behavior)
|
||||||
|
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
|
||||||
|
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||||||
|
default_tools = get_available_tools(
|
||||||
|
model_name=model_name,
|
||||||
|
groups=agent_config.tool_groups if agent_config else None,
|
||||||
|
subagent_enabled=subagent_enabled,
|
||||||
|
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||||
|
excluded_tools=requested_excluded_tools,
|
||||||
|
app_config=resolved_app_config,
|
||||||
|
)
|
||||||
|
if is_notebook_mode:
|
||||||
|
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
|
||||||
|
|
||||||
|
default_tools = [notebook_search_tool] + default_tools
|
||||||
|
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||||
tools=get_available_tools(
|
tools=default_tools,
|
||||||
model_name=model_name,
|
|
||||||
groups=agent_config.tool_groups if agent_config else None,
|
|
||||||
subagent_enabled=subagent_enabled,
|
|
||||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
|
||||||
excluded_tools=requested_excluded_tools,
|
|
||||||
app_config=resolved_app_config,
|
|
||||||
),
|
|
||||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||||
system_prompt=apply_prompt_template(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
@@ -487,6 +495,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
memory_injection_enabled=memory_injection_enabled,
|
memory_injection_enabled=memory_injection_enabled,
|
||||||
|
is_notebook_mode=is_notebook_mode,
|
||||||
|
notebook_source_ids=notebook_source_ids,
|
||||||
),
|
),
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
+45
-1
@@ -868,6 +868,8 @@ def apply_prompt_template(
|
|||||||
is_bootstrap: bool = False,
|
is_bootstrap: bool = False,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
memory_injection_enabled: bool = True,
|
memory_injection_enabled: bool = True,
|
||||||
|
is_notebook_mode: bool = False,
|
||||||
|
notebook_source_ids: list[int] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
|
|
||||||
@@ -955,4 +957,46 @@ Writing mode is enabled for this run.
|
|||||||
acp_section=acp_and_mounts_section,
|
acp_section=acp_and_mounts_section,
|
||||||
)
|
)
|
||||||
|
|
||||||
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
prompt = prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||||
|
|
||||||
|
if is_notebook_mode:
|
||||||
|
source_ids = notebook_source_ids or []
|
||||||
|
if source_ids:
|
||||||
|
source_line = f"当前检索范围:{len(source_ids)} 个来源(IDs: {', '.join(str(i) for i in source_ids)})"
|
||||||
|
else:
|
||||||
|
source_line = "当前检索范围:全部来源"
|
||||||
|
|
||||||
|
prompt += f"""
|
||||||
|
|
||||||
|
<notebook_mode>
|
||||||
|
## 角色
|
||||||
|
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
|
||||||
|
|
||||||
|
## 可用知识来源
|
||||||
|
{source_line}
|
||||||
|
|
||||||
|
## 强制工作流程(每次用户提问都必须严格执行,无例外)
|
||||||
|
1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。
|
||||||
|
- 无论问题多简单,都必须先检索,不得跳过。
|
||||||
|
- 若检索结果不足,可再次调用不同 query 补充检索。
|
||||||
|
2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。
|
||||||
|
3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。
|
||||||
|
|
||||||
|
## 引用格式(强制)
|
||||||
|
- 每个事实/句子后立即插入 `[[cite:<chunk_id>]]` 标记,chunk_id 来自检索结果中每个文本块的 id 字段。
|
||||||
|
- 多个来源支持同一句话时连续插入:`[[cite:101]][[cite:102]]`
|
||||||
|
- 引用标记紧跟被引用内容末尾,不加空格,不单独成行。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
用户:合同中违约金是多少?
|
||||||
|
(调用 search_notebook_sources query="违约金",返回 chunk_id=101 的结果)
|
||||||
|
回答:根据合同第五条,违约金为合同总额的 0.5%[[cite:101]],乙方拒绝收货视为违约[[cite:101]]。
|
||||||
|
|
||||||
|
## 绝对禁止
|
||||||
|
- 未调用 `search_notebook_sources` 就直接回答
|
||||||
|
- 引用不在检索结果中的 chunk_id
|
||||||
|
- 省略引用标记
|
||||||
|
- 使用笔记本来源之外的知识
|
||||||
|
</notebook_mode>"""
|
||||||
|
|
||||||
|
return prompt
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
"""笔记本 RAG 检索工具。
|
||||||
|
|
||||||
|
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
|
||||||
|
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
|
||||||
|
|
||||||
|
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain.tools import tool
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
|
||||||
|
|
||||||
|
|
||||||
|
def _notebook_api_base() -> str:
|
||||||
|
return (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
@tool("search_notebook_sources", parse_docstring=True)
|
||||||
|
def notebook_search_tool(query: str, top_k: int = 10) -> str:
|
||||||
|
"""Search the notebook's knowledge sources to find relevant content.
|
||||||
|
|
||||||
|
Call this tool for EVERY user question. After retrieving results, cite
|
||||||
|
sources inline using [[cite:<document_id>]] markers immediately after
|
||||||
|
each claim, e.g. "违约金为0.5%[[cite:101]]".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The user's question or a refined search query.
|
||||||
|
top_k: Number of top chunks to retrieve (default 10).
|
||||||
|
"""
|
||||||
|
base = _notebook_api_base()
|
||||||
|
if not base:
|
||||||
|
return "Notebook API base URL not configured (DEER_FLOW_NOTEBOOK_API_BASE)."
|
||||||
|
|
||||||
|
cfg = get_config().get("configurable", {})
|
||||||
|
search_space_id: int | None = cfg.get("notebook_search_space_id")
|
||||||
|
source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||||||
|
token: str = cfg.get("notebook_token") or ""
|
||||||
|
|
||||||
|
if not search_space_id:
|
||||||
|
return "No notebook search space configured."
|
||||||
|
|
||||||
|
logger.info("notebook_search_tool query=%r source_ids=%s search_space_id=%s", query, source_ids, search_space_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{base}{_SEARCH_PATH}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={
|
||||||
|
"query": query,
|
||||||
|
"source_ids": [682],
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
"top_k": top_k,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = data.get("content") if isinstance(data, dict) else None
|
||||||
|
return content if content else "No relevant content found."
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("notebook_search_tool failed: %s", e)
|
||||||
|
return f"Search failed: {e}"
|
||||||
Reference in new issue
Block a user