Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq
This commit is contained in:
commit
43e73df066
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
return threads;
|
||||
return threads.filter(
|
||||
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
|
||||
);
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
+18
-8
@@ -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,
|
||||
)
|
||||
+45
-1
@@ -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
|
||||
+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