diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 0a40a4c0..d61f8a90 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -76,12 +76,14 @@ export function MessageListItem({ isLoading, threadId, tokenUsageEnabled = false, + extraRehypePlugins, }: { className?: string; message: Message; isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; + extraRehypePlugins?: ReturnType; }) { const isHuman = message.type === "human"; const messageMarkdown = @@ -99,6 +101,7 @@ export function MessageListItem({ isLoading={isLoading} threadId={threadId} tokenUsageEnabled={tokenUsageEnabled} + extraRehypePlugins={extraRehypePlugins} /> {!isLoading && ( ; }) { - 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( diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index f9581a7a..ce10ffb2 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -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; paddingBottom?: number; tokenUsageEnabled?: boolean; + extraRehypePlugins?: ReturnType; onClarificationSubmit?: (answer: string) => void | Promise; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; }) { 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} /> ); }); diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 99f69bf8..7fdff4a3 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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 ( + <> @@ -147,5 +149,7 @@ export function WorkspaceNavChatList({ )} + {!showCollapseTrigger && } + ); } diff --git a/frontend-web/src/components/workspace/workspace-nav-studio.tsx b/frontend-web/src/components/workspace/workspace-nav-studio.tsx new file mode 100644 index 00000000..e546c39e --- /dev/null +++ b/frontend-web/src/components/workspace/workspace-nav-studio.tsx @@ -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(["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 ( + <> + + + + + 工作室 + + + + + + + {/* 新建笔记本 */} + + { setDialogOpen(true); setError(""); setName(""); }} + > + + 新建笔记本 + + + + {/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */} + {isLoading + ? Array.from({ length: 3 }).map((_, i) => ( + +
+ + )) + : preview.map((nb) => ( + + + + + {nb.name} + + + + ))} + + {/* 所有笔记本 */} + {!isLoading && notebooks.length > 0 && ( + + + + ··· + 所有笔记本 + + + + )} + + + + + + !creating && setDialogOpen(v)}> + + + 新建笔记本 + +
+ 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 &&

{error}

} +
+ + +
+
+
+
+ + ); +} diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 822d9869..9727e6f6 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -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) { diff --git a/frontend-web/src/core/rehype/index.ts b/frontend-web/src/core/rehype/index.ts index e79ac39a..14d72b11 100644 --- a/frontend-web/src/core/rehype/index.ts +++ b/frontend-web/src/core/rehype/index.ts @@ -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(); + 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; diff --git a/frontend-web/src/core/studio/api.ts b/frontend-web/src/core/studio/api.ts new file mode 100644 index 00000000..16e8d90a --- /dev/null +++ b/frontend-web/src/core/studio/api.ts @@ -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 { + 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 { + 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; +} + +export async function createSearchSpace( + name: string, + token: string, +): Promise { + 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 { + 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; +} + +export async function fetchNotebookSources( + notebookId: number, + token: string, +): Promise { + 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; +} diff --git a/frontend-web/src/core/studio/hooks.ts b/frontend-web/src/core/studio/hooks.ts new file mode 100644 index 00000000..bb767664 --- /dev/null +++ b/frontend-web/src/core/studio/hooks.ts @@ -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(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({ + 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, + }; +} diff --git a/frontend-web/src/core/studio/types.ts b/frontend-web/src/core/studio/types.ts new file mode 100644 index 00000000..beb79b50 --- /dev/null +++ b/frontend-web/src/core/studio/types.ts @@ -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; +} diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 3c486450..7a63b7d8 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -596,7 +596,9 @@ export function useThreads( offset += response.length; } - return threads; + return threads.filter( + (t) => (t.metadata as Record | null)?.thread_type !== "notebook", + ); }, refetchOnWindowFocus: false, }); diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 5e1d7c01..10b6603c 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -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"), }; diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx new file mode 100644 index 00000000..d0e944ba --- /dev/null +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -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 ( + + PDF + + ); + } + if (item.source_type === "KNOWLEDGE_CONNECTOR") return ; + if (item.source_type === "NOTE") return ; + return ; +} + +// ── 单个来源卡片 ──────────────────────────────────────── +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 ( +
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" + }`} + > +
+ {isProcessing ? ( + + ) : ( + + )} +
+

{title}

+
+ {title} +
+
+ ); +} + +// ── 来源骨架屏 ────────────────────────────────────────── +function SourceCardSkeleton() { + return ( +
+
+
+
+ ); +} + +// ── 来源 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 ( + !v && onClose()}> + + +
+ 来源 +

+ {notebookName} · 选中的来源将用于 AI 回答(不选默认全部) +

+
+ +
+ +
+
+ {[ + { icon: Plus, label: "上传文件" }, + { icon: CloudUpload, label: "从云端硬盘添加" }, + { icon: Globe, label: "添加网站" }, + { icon: AlignLeft, label: "复制的文字" }, + ].map(({ icon: Icon, label }) => ( + + ))} +
+ +
+
+ {isLoadingSources + ? Array.from({ length: 8 }).map((_, i) => ) + : sources.map((src) => ( + + ))} +
+ {!isLoadingSources && sources.length === 0 && ( +

暂无来源,请添加文件。

+ )} +
+
+
+
+ ); +} + +// ── 主页面 ────────────────────────────────────────────── +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([]); + const [selectedModel, setSelectedModel] = useState(""); + const [input, setInput] = useState(""); + // null = 显示历史线程列表;string = 显示该线程的聊天 + const [activeThreadId, setActiveThreadId] = useState(null); + const [citationChunkId, setCitationChunkId] = useState(null); + const [citationDoc, setCitationDoc] = useState(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; + }, [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 ( +
+
+
+
+
+ ); + } + + if (!notebook) { + return ( +
+

笔记本不存在

+
+ ); + } + + const hasMessages = (thread.messages ?? []).some( + (m) => m.type === "human" || m.type === "ai", + ); + + // 聊天视图:有 activeThreadId 或已有消息 + const isChatView = activeThreadId !== null || hasMessages; + + return ( +
+ {/* 背景渐变(列表视图时显示) */} + {!isChatView && ( +
+ )} + + {/* 聊天视图:顶部栏 */} + {isChatView && ( +
+
+ +
+ {notebook.avatar_url ? ( + + ) : ( + + )} +
+ {notebook.name} +
+ +
+ )} + + {/* 列表视图:笔记本标题 + 历史线程列表 */} + {!isChatView && ( +
+
+ {notebook.avatar_url ? ( + + ) : ( + + )} +
+

+ {notebook.name} +

+ + + {/* 历史线程列表 */} + {notebookThreads && notebookThreads.length > 0 && ( +
+

历史对话

+ {notebookThreads.map((t) => { + const values = t.values as Record | 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 ( + + ); + })} +
+ )} +
+ )} + + {/* 聊天视图:消息列表 */} + {isChatView && ( +
{ + const sup = (e.target as HTMLElement).closest("[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(["studio", "token", email]) ?? ""; + void fetchDocumentByChunk(chunkId, tok) + .then((doc) => setCitationDoc(doc)) + .finally(() => setCitationLoading(false)); + }} + > + +
+ )} + + {/* 输入框区域 */} +
+
{ 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" + > + + {models.length > 0 && ( + + )} + 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" + /> + +
+
+ + {/* 引用来源弹窗 */} + { if (!v) setCitationChunkId(null); }}> + + + 引用来源 + + {citationLoading && ( +
+ +
+ )} + {!citationLoading && citationDoc && ( +
+
+ + {citationDoc.file_type || citationDoc.document_type} + + {citationDoc.title} +
+ {citationDoc.chunks.find((c) => c.id === citationChunkId) && ( +
+ {citationDoc.chunks.find((c) => c.id === citationChunkId)!.content} +
+ )} +
+ )} + {!citationLoading && !citationDoc && ( +

加载失败,请重试。

+ )} +
+
+ + {/* 来源 Dialog */} + setSourcesOpen(false)} + notebookName={notebook.name} + sources={sources} + isLoadingSources={isLoadingSources} + selectedSourceIds={selectedSourceIds} + onToggle={handleToggleSource} + /> +
+ ); +} diff --git a/frontend-web/src/pages/StudioNotebooksPage.tsx b/frontend-web/src/pages/StudioNotebooksPage.tsx new file mode 100644 index 00000000..00bb0dcb --- /dev/null +++ b/frontend-web/src/pages/StudioNotebooksPage.tsx @@ -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 ( +
+
+
+
+
+
+
+ ); +} + +function NotebookCard({ nb }: { nb: StudioNotebook }) { + return ( + +
+
+ {nb.avatar_url ? ( + + ) : ( + + )} +
+
+
+ + {nb.name} + + {nb.source_count} 个来源 +
+ + ); +} + +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(["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 ( +
+
+

笔记本

+ +
+ + {isError && ( +

加载笔记本失败,请稍后重试。

+ )} + +
+ {isLoading + ? Array.from({ length: 8 }).map((_, i) => ) + : notebooks.map((nb) => )} +
+ + !creating && setDialogOpen(v)}> + + + 新建笔记本 + +
+ 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 &&

{error}

} +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index a721f0da..e49623da 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -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({ } /> + + + + } + /> + + + + + + } + /> } diff --git a/offline-backend-20260512/.env b/offline-backend-20260512/.env index 7271a16d..66b368c2 100644 --- a/offline-backend-20260512/.env +++ b/offline-backend-20260512/.env @@ -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 \ No newline at end of file diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index d27dd73f..8078855c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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, ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 9d2dbe3e..3e7171d7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -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{datetime.now().strftime('%Y-%m-%d, %A')}" + prompt = prompt + f"\n{datetime.now().strftime('%Y-%m-%d, %A')}" + + 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""" + + +## 角色 +你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。 + +## 可用知识来源 +{source_line} + +## 强制工作流程(每次用户提问都必须严格执行,无例外) +1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。 + - 无论问题多简单,都必须先检索,不得跳过。 + - 若检索结果不足,可再次调用不同 query 补充检索。 +2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。 +3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。 + +## 引用格式(强制) +- 每个事实/句子后立即插入 `[[cite:]]` 标记,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 +- 省略引用标记 +- 使用笔记本来源之外的知识 +""" + + return prompt diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py new file mode 100644 index 00000000..8b1c6067 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py @@ -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:]] 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}"