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 98fcdbe4..a0afd6d5 100644 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx +++ b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx @@ -826,7 +826,13 @@ const DocumentUploadPopupContent: FC<{ )} - {listData.length === 0 && } + {isSearching ? ( +
+ +
+ ) : ( + listData.length === 0 && + )} {searchType === "human" && (
{listData.map((item: listDataItem, index: number) => { diff --git a/frontend-web/src/components/assistant-ui/network-actor.tsx b/frontend-web/src/components/assistant-ui/network-actor.tsx index 9be9cfcd..a48576cb 100644 --- a/frontend-web/src/components/assistant-ui/network-actor.tsx +++ b/frontend-web/src/components/assistant-ui/network-actor.tsx @@ -545,7 +545,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
)}
-
+ {isSearching ? ( +
+ +
+ ) : null} +
{userList.map((item, index) => (
diff --git a/frontend-web/src/pages/NotebookAIWritingPage.tsx b/frontend-web/src/pages/NotebookAIWritingPage.tsx index 8c818d68..e23e5f24 100644 --- a/frontend-web/src/pages/NotebookAIWritingPage.tsx +++ b/frontend-web/src/pages/NotebookAIWritingPage.tsx @@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import { getNotebookApiBaseURL } from '@/core/config'; +import { useTheme, type ThemeType } from '@/contexts/ThemeContext'; + +const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue']; interface NotebookTokenData { doc_ids: number[]; @@ -13,6 +16,17 @@ interface NotebookTokenData { export default function NotebookAIWritingPage() { const [searchParams] = useSearchParams(); const token = searchParams.get('token'); + const themekey = searchParams.get('themekey'); + const { setTheme } = useTheme(); + + // 跟随调用方传入的 themekey 切换主题;缺省或非法值兜底为 light + useEffect(() => { + const next = themekey && VALID_THEMES.includes(themekey as ThemeType) + ? (themekey as ThemeType) + : 'light'; + setTheme(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themekey]); const [data, setData] = useState(null); const [spaceName, setSpaceName] = useState(null); diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx index b0747e85..bfb7ea8b 100644 --- a/frontend-web/src/pages/StudioNotebookDetailPage.tsx +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -6,11 +6,13 @@ import { AlertCircle, AlignLeft, ArrowLeft, + ArrowUp, BookOpen, Files, Globe, Loader2, Plus, + Square, X, } from "lucide-react"; @@ -27,6 +29,7 @@ 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 { useTheme } from "@/contexts/ThemeContext"; import { useModels } from "@/core/models/hooks"; import { Select, @@ -118,6 +121,20 @@ function SourceCard({ ); } +// ── 上传中占位卡片 ────────────────────────────────────── +function PendingSourceCard({ title }: { title: string }) { + return ( +
+
+ +
+
+

{title}

+
+
+ ); +} + // ── 来源骨架屏 ────────────────────────────────────────── function SourceCardSkeleton() { return ( @@ -136,6 +153,7 @@ function SourcesDialog({ sources, isLoadingSources, selectedSourceIds, + pendingSources, onToggle, onDelete, }: { @@ -145,6 +163,7 @@ function SourcesDialog({ sources: SourceItem[]; isLoadingSources: boolean; selectedSourceIds: number[]; + pendingSources: Map; onToggle: (id: number) => void; onDelete: (id: number) => void; }) { @@ -188,15 +207,26 @@ function SourcesDialog({
{isLoadingSources ? Array.from({ length: 8 }).map((_, i) => ) - : sources.map((src) => ( - - ))} + : <> + {Array.from(pendingSources.entries()) + .filter(([, s]) => !sources.some((src) => { + const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? ""; + return srcTitle === s.title; + })) + .map(([taskId, s]) => ( + + ))} + {sources.map((src) => ( + + ))} + + }
{!isLoadingSources && sources.length === 0 && (

暂无来源,请添加文件。

@@ -218,6 +248,7 @@ export default function StudioNotebookDetailPage({ const params = useParams<{ notebookId: string }>(); const notebookId = notebookIdProp != null ? String(notebookIdProp) : params.notebookId; + const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback const { models } = useModels(); const queryClient = useQueryClient(); @@ -229,6 +260,8 @@ export default function StudioNotebookDetailPage({ // null = 显示历史线程列表;string = 显示该线程的聊天 const [activeThreadId, setActiveThreadId] = useState(null); const [citationChunkId, setCitationChunkId] = useState(null); + // 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配) + const pendingSelectTitlesRef = useRef>(new Set()); const [threadsExpanded, setThreadsExpanded] = useState(false); const THREADS_LIMIT = 5; const [citationDoc, setCitationDoc] = useState(null); @@ -253,6 +286,22 @@ export default function StudioNotebookDetailPage({ setSelectedSourceIds(sources.map((s) => s.id)); } }, [sources, notebook?.id, initializedForNotebook]); + + // sources 刷新后,将轮询成功的来源(按标题匹配)自动选中 + useEffect(() => { + if (pendingSelectTitlesRef.current.size === 0) return; + const matched = sources.filter((s) => { + const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? ""; + return pendingSelectTitlesRef.current.has(title); + }); + if (matched.length > 0) { + setSelectedSourceIds((prev) => [...new Set([...prev, ...matched.map((s) => s.id)])]); + matched.forEach((s) => { + const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? ""; + pendingSelectTitlesRef.current.delete(title); + }); + } + }, [sources]); const citationRehypePlugins = useMemo(() => { const mapped = sources.map((s) => ({ id: s.id, @@ -297,6 +346,10 @@ export default function StudioNotebookDetailPage({ } }; + const handleStop = useCallback(() => { + void thread.stop(); + }, [thread]); + const handleSend = async (e: React.SyntheticEvent) => { e.preventDefault(); const text = input.trim(); @@ -358,20 +411,17 @@ export default function StudioNotebookDetailPage({ const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' }; const taskStatus = data.status === 'failed' ? 'error' : data.status; if (taskStatus !== 'processing') { - // 任务完成,从轮询队列移除并刷新来源列表 + // 先取出 source 信息,再从轮询队列移除 + const source = pendingSources.get(taskId); setPendingSources((prev) => { const newMap = new Map(prev); - const source = newMap.get(taskId); newMap.delete(taskId); - if (source) { - if (taskStatus === 'success') { - // toast.success(`来源 "${source.title}" 处理完成`); - } else { - // toast.error(`来源 "${source.title}" 处理失败`); - } - } return newMap; }); + // 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中 + if (taskStatus === 'success' && source?.title) { + pendingSelectTitlesRef.current.add(source.title); + } // 刷新来源列表 void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] }); // 刷新 notebooks 数据以更新 source_count @@ -381,7 +431,7 @@ export default function StudioNotebookDetailPage({ } catch (error) { console.error(`轮询任务 ${taskId} 状态失败:`, error); } - }, [notebook?.id, queryClient]); + }, [notebook?.id, pendingSources, queryClient]); // 控制轮询 - 只在对话框打开时轮询 useEffect(() => { @@ -508,7 +558,7 @@ export default function StudioNotebookDetailPage({
@@ -769,6 +853,7 @@ export default function StudioNotebookDetailPage({ sources={sources} isLoadingSources={isLoadingSources} selectedSourceIds={selectedSourceIds} + pendingSources={pendingSources} onToggle={handleToggleSource} onDelete={handleDeleteSource} /> diff --git a/frontend-web/src/pages/StudioNotebooksPage.tsx b/frontend-web/src/pages/StudioNotebooksPage.tsx index e090a6ae..40f444a5 100644 --- a/frontend-web/src/pages/StudioNotebooksPage.tsx +++ b/frontend-web/src/pages/StudioNotebooksPage.tsx @@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useStudioNotebooks } from "@/core/studio/hooks"; import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api"; import { getStoredAuth } from "@/core/auth"; +import { useTheme } from "@/contexts/ThemeContext"; import type { StudioNotebook } from "@/core/studio/types"; import { Dialog, @@ -23,13 +24,16 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; -function NotebookCardSkeleton() { +function NotebookCardSkeleton({ isDark }: { isDark: boolean }) { return ( -
-
+
+
-
-
+
+
); @@ -37,11 +41,13 @@ function NotebookCardSkeleton() { function NotebookCard({ nb, + isDark, onRename, onDelete, onOpen, }: { nb: StudioNotebook; + isDark: boolean; onRename: (nb: StudioNotebook) => void; onDelete: (nb: StudioNotebook) => void; /** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */ @@ -61,16 +67,16 @@ function NotebookCard({
- + {nb.name} - {nb.source_count} 个来源 + {nb.source_count} 个来源
); return ( -
+
{onOpen ? (