From 0e7149eec39a89e4ae350e46bcf04d6c64a70b47 Mon Sep 17 00:00:00 2001 From: xuhd Date: Fri, 5 Jun 2026 13:38:08 +0800 Subject: [PATCH] feat(notebook): load notebook space via token and pre-select docs/sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotebookAIWritingPage reads `token` from URL, fetches space data from notebook-token API (search_space_id, doc_ids, user_token) and space name from searchspaces API; keep-alive ping every 50s resets Redis TTL - Pass user_token as studioToken through AIWritingPage → AIWritingLayout → WritingFormPanel → AIWritingPanel → AIWritingForm to skip getTokenByEmail when studio token is already available - useStudioNotebooks accepts enabled flag; useNotebookSources accepts overrideToken to bypass the internal token fetch - AIWritingForm hides notebook selector when studioToken is provided (notebook is pre-locked) and pre-selects initialDocumentIds Co-Authored-By: Claude Sonnet 4.6 --- .../assistant-ui/document-upload-popup.tsx | 48 ++---------- frontend-web/src/core/studio/hooks.ts | 27 ++++--- .../components/ai-writing/AIWritingForm.tsx | 47 +++++++----- .../components/ai-writing/AIWritingPanel.tsx | 7 ++ .../src/open-canvas/pages/AIWritingPage.tsx | 20 +++-- .../src/pages/NotebookAIWritingPage.tsx | 76 ++++++++++++++++--- 6 files changed, 140 insertions(+), 85 deletions(-) diff --git a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx index 9b2d7b17..4f5587a5 100644 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx +++ b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx @@ -19,9 +19,6 @@ import { import { Textarea } from "@/components/ui/textarea"; import { Search, - Sparkles, - Moon, - BookOpen, Loader2, Plus, Trash2, @@ -33,7 +30,6 @@ import Empty from "./empty"; import { NetworkActor } from "./network-actor"; // ── 适配层 ───────────────────────────────────────────────── -const writeYLog = (..._args: unknown[]) => {}; function authenticatedFetch(url: string, init?: RequestInit) { const token = getStoredAuth()?.access_token; @@ -58,13 +54,7 @@ function getYUserId(): string { return "1157761008018653184"; } -type SearchMode = "A" | "Y" | "knowledge"; -const searchModes = [ - { value: "A", label: "A系统", icon: Sparkles }, - { value: "Y", label: "Y系统", icon: Moon }, - { value: "knowledge", label: "知识库", icon: BookOpen }, -] as const; const searchTypes = [ { value: "human", label: "人物基因库" }, @@ -274,7 +264,7 @@ const DocumentUploadPopupContent: FC<{ data?: { query: string; mode: string } ) => Promise; initialSearchText?: string; -}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, closeDialog, initialSearchText }) => { +}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => { const searchSpaceId = String(searchSpace?.id); const backendUrl = typeof window !== "undefined" @@ -286,7 +276,6 @@ const DocumentUploadPopupContent: FC<{ const [searchValue, setSearchValue] = useState(""); const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search"); const [isSearching, setIsSearching] = useState(false); - const [importSources, setImportSources] = useState([]); const [isImporting, setIsImporting] = useState(false); const [listData, setListData] = useState([]); const [searchType, setSearchType] = useState("human"); @@ -396,34 +385,7 @@ const DocumentUploadPopupContent: FC<{ setSelectedSearchSources([]); }; - const handleAllSearch = async (searchMode: SearchMode, text: string) => { - if (isSearching || !text.trim()) return; - setIsSearching(true); - const reqBody = { - query: text, - search_space_id: searchSpaceId, - search_mode: searchMode, - }; - const reqUrl = `${backendUrl}/api/v1/source/new`; - try { - const response = await authenticatedFetch(reqUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(reqBody), - }); - if (response.ok) { - const data = await response.json() as { result: unknown[] }; - writeYLog({ optDetail: "笔记检索数据", optType: 3, reqParam: reqBody, url: reqUrl, returnParam: JSON.stringify({ data }), optResult: "0" }); - const results = data.result as listDataItem[]; - results.forEach((item) => { item.checked = false; }); - setImportSources(results); - } - } catch (error) { - writeYLog({ optDetail: "笔记检索数据", optType: 3, reqParam: reqBody, url: reqUrl, returnParam: JSON.stringify({ data: error }), optResult: "1" }); - } finally { - setIsSearching(false); - } - }; + function urlSafeBtoa(str: string): string { const encoder = new TextEncoder(); @@ -599,7 +561,11 @@ const DocumentUploadPopupContent: FC<{ return ( - + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > 添加来源 从外部系统搜索并导入来源
diff --git a/frontend-web/src/core/studio/hooks.ts b/frontend-web/src/core/studio/hooks.ts index 679096af..f7b6edf3 100644 --- a/frontend-web/src/core/studio/hooks.ts +++ b/frontend-web/src/core/studio/hooks.ts @@ -8,7 +8,7 @@ import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./ import type { AgentThreadState } from "@/core/threads/types"; import type { SourceItem, StudioNotebook } from "./types"; -export function useStudioNotebooks(): { +export function useStudioNotebooks(enabled = true): { notebooks: StudioNotebook[]; isLoading: boolean; isError: boolean; @@ -19,7 +19,7 @@ export function useStudioNotebooks(): { const tokenQuery = useQuery({ queryKey: ["studio", "token", email], queryFn: () => fetchStudioToken(email), - enabled: Boolean(email), + enabled: enabled && Boolean(email), staleTime: 5 * 60 * 1000, retry: 1, }); @@ -27,7 +27,7 @@ export function useStudioNotebooks(): { const notebooksQuery = useQuery({ queryKey: ["studio", "notebooks", tokenQuery.data], queryFn: () => fetchStudioNotebooks(tokenQuery.data!), - enabled: Boolean(tokenQuery.data), + enabled: enabled && Boolean(tokenQuery.data), staleTime: 60 * 1000, retry: 1, }); @@ -121,33 +121,38 @@ export function useNotebookChat( return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId }; } -export function useNotebookSources(notebookId: number | null): { +export function useNotebookSources( + notebookId: number | null, + overrideToken?: string, +): { sources: SourceItem[]; isLoading: boolean; isError: boolean; } { const email = getStoredAuth()?.email ?? ""; - // 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求 + // 若外部直接提供 user_token(如 notebook-token 接口返回的),跳过 getTokenByEmail const tokenQuery = useQuery({ queryKey: ["studio", "token", email], queryFn: () => fetchStudioToken(email), - enabled: Boolean(email), + enabled: !overrideToken && Boolean(email), staleTime: 5 * 60 * 1000, retry: 1, }); + const resolvedToken = overrideToken ?? tokenQuery.data; + const sourcesQuery = useQuery({ - queryKey: ["studio", "sources", notebookId], - queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!), - enabled: notebookId !== null && Boolean(tokenQuery.data), + queryKey: ["studio", "sources", notebookId, overrideToken ?? "default"], + queryFn: () => fetchNotebookSources(notebookId!, resolvedToken!), + enabled: notebookId !== null && Boolean(resolvedToken), staleTime: 60 * 1000, retry: 1, }); return { sources: sourcesQuery.data ?? [], - isLoading: tokenQuery.isLoading || sourcesQuery.isLoading, - isError: tokenQuery.isError || sourcesQuery.isError, + isLoading: (!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading, + isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError, }; } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index c53d386b..b29c6897 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -24,6 +24,10 @@ interface AIWritingFormProps { hideAdvancedFields?: boolean; /** 预选笔记本 ID(传入时自动切换素材来源为「我的笔记」) */ initialNotebookId?: number; + /** 预选文档 ID 列表 */ + initialDocumentIds?: number[]; + /** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */ + studioToken?: string; } const INPUT_CLASS = @@ -53,6 +57,8 @@ export function AIWritingForm({ disabled, hideAdvancedFields = false, initialNotebookId, + initialDocumentIds, + studioToken, }: AIWritingFormProps) { const { intent, setIntent, @@ -69,11 +75,11 @@ export function AIWritingForm({ const [selectedNotebookId, setSelectedNotebookId] = useState( initialNotebookId ?? null, ); - const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); + const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialDocumentIds ?? []); const { models, isLoading: modelsLoading } = useModels(); - const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(); - const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId); + const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken); + const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken); const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); @@ -291,22 +297,25 @@ export function AIWritingForm({ {/* 选了"我的笔记"才展示笔记本 / 文档选择器 */} {materialSource === 'notebook' && (
- + {/* studioToken 模式:笔记本已锁定,不展示选择器 */} + {!studioToken && ( + + )} {selectedNotebookId !== null && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index d0929596..ddb0e752 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -71,11 +71,16 @@ const STATUS_LABEL: Record = { export function AIWritingPanel({ hideAdvancedFields = false, initialNotebookId, + initialDocumentIds, + studioToken, }: { hideAdvancedFields?: boolean; initialNotebookId?: number; + initialDocumentIds?: number[]; + studioToken?: string; } = {}) { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions(); @@ -354,6 +359,8 @@ export function AIWritingPanel({ onSubmit={startWriting} hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} + initialDocumentIds={initialDocumentIds} + studioToken={studioToken} /> )} diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index de73ecb1..5d32da94 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -115,11 +115,17 @@ const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 }; /** * 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。 */ -function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) { +interface NotebookProps { + initialNotebookId?: number; + initialDocumentIds?: number[]; + studioToken?: string; +} + +function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) { return ( // 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
- +
); } @@ -129,7 +135,7 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean * - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单; * - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。 */ -function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) { +function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) { const layoutRef = useRef(null); const { state } = useAIWriting(); const { isHistoryMode } = useAIWritingHistory(); @@ -162,7 +168,7 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean } minSize={20} className="overflow-hidden flex flex-col" > - + ); @@ -203,9 +209,13 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean } export default function AIWritingPage({ hideAdvancedFields = false, initialNotebookId, + initialDocumentIds, + studioToken, }: { hideAdvancedFields?: boolean; initialNotebookId?: number; + initialDocumentIds?: number[]; + studioToken?: string; } = {}) { return ( @@ -217,7 +227,7 @@ export default function AIWritingPage({
- +
diff --git a/frontend-web/src/pages/NotebookAIWritingPage.tsx b/frontend-web/src/pages/NotebookAIWritingPage.tsx index 8c083368..a0d23ac8 100644 --- a/frontend-web/src/pages/NotebookAIWritingPage.tsx +++ b/frontend-web/src/pages/NotebookAIWritingPage.tsx @@ -1,9 +1,59 @@ +import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; +import { getNotebookApiBaseURL } from '@/core/config'; + +interface NotebookTokenData { + doc_ids: number[]; + search_space_id: number; + user_id: string; + user_token: string; +} export default function NotebookAIWritingPage() { const [searchParams] = useSearchParams(); - const notebookId = searchParams.get('notebook_id'); + const token = searchParams.get('token'); + + const [data, setData] = useState(null); + const [spaceName, setSpaceName] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) { + setError('缺少 token 参数'); + return; + } + const base = getNotebookApiBaseURL(); + fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`) + .then((res) => { + if (!res.ok) throw new Error(`请求失败 ${res.status}`); + return res.json() as Promise; + }) + .then((d) => { + setData(d); + return fetch(`${base}/api/v1/searchspaces/${d.search_space_id}`, { + headers: { Authorization: `Bearer ${d.user_token}` }, + }); + }) + .then((res) => { + if (!res.ok) return null; + return res.json() as Promise<{ name: string }>; + }) + .then((space) => { + if (space?.name) setSpaceName(space.name); + }) + .catch((e: Error) => setError(e.message)); + }, [token]); + + // Keep-alive:每 50 秒 ping 一次 token 接口,防止 Redis TTL 过期 + useEffect(() => { + if (!token) return; + const base = getNotebookApiBaseURL(); + const id = setInterval(() => { + fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`).catch(() => {}); + }, 50_000); + return () => clearInterval(id); + }, [token]); return (
@@ -20,12 +70,12 @@ export default function NotebookAIWritingPage() { {/* 分隔线 */}
- {/* 空间 ID */} - {notebookId && ( + {/* 空间名称 */} + {data?.search_space_id && (
空间 - {notebookId} + {spaceName ?? data.search_space_id}
)} @@ -40,12 +90,20 @@ export default function NotebookAIWritingPage() {
- {/* 主体:AIWritingPage 完整组件,含 context providers */} + {/* 主体 */}
- + {error ? ( +
{error}
+ ) : !data ? ( +
加载中…
+ ) : ( + + )}
);