This commit is contained in:
ZyGuo committed 2026-05-27 12:07:07 +08:00
commit fa65e67069
18 files changed
+1326 -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>
</>
);
}
+8
View File
@@ -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 分离)。 */
export function getNoteApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
+49
View File
@@ -3,6 +3,55 @@ import { useMemo } from "react";
import { visit } 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 =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
+94
View File
@@ -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[]>;
}
+146
View File
@@ -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,
};
}
+36
View File
@@ -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;
}
+3 -1
View File
@@ -596,7 +596,9 @@ export function useThreads(
offset += response.length;
}
return threads;
return threads.filter(
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
);
},
refetchOnWindowFocus: false,
});
+2
View File
@@ -6,6 +6,7 @@ function readEnv(
| "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL"
| "VITE_NOTEBOOK_API_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue {
@@ -20,6 +21,7 @@ export const env = {
// 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_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"),
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 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 />}
+1
View File
@@ -59,3 +59,4 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
# PYTHON_EXTRA_INDEX_URL=
# PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
@@ -466,16 +466,24 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
# 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(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
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,
),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=apply_prompt_template(
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),
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
),
state_schema=ThreadState,
)
@@ -868,6 +868,8 @@ def apply_prompt_template(
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
) -> str:
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,
)
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
@@ -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": [source_ids],
"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}"