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 (
+
+ );
+}
+
// ── 来源骨架屏 ──────────────────────────────────────────
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({
{/* 右上角跳转按钮 */}
- {isStreaming &&
}
{models.length > 0 && (
)}
+ {isStreaming ? (
+
+ ) : input.trim() ? (
+
+ ) : null}
{/* 空状态提示 */}
@@ -711,7 +778,6 @@ export default function StudioNotebookDetailPage({
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
- {isStreaming &&
}
{models.length > 0 && (
)}
+ {isStreaming ? (
+
+ ) : input.trim() ? (
+
+ ) : null}
@@ -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 ? (