Merge branch 'xuhd' into 'master'

feat(frontend): 来源弹窗加loading + 笔记页暗色适配 + 主题透传

See merge request knowledge-base-bj/deerflow-server!144
This commit is contained in:
xu hd committed 2026-06-23 03:28:12 +00:00
commit ce3a5b6bf9
5 files changed
+153 -34

No files matched your search

@@ -826,7 +826,13 @@ const DocumentUploadPopupContent: FC<{
</Button>
</div>
)}
{listData.length === 0 && <Empty />}
{isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
listData.length === 0 && <Empty />
)}
{searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item: listDataItem, index: number) => {
@@ -545,7 +545,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
</div>
)}
<div className="p-[10px]">
<div className="grid grid-cols-4 gap-[10px] user-list">
{isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
@@ -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<NotebookTokenData | null>(null);
const [spaceName, setSpaceName] = useState<string | null>(null);
@@ -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 (
<div className="group relative flex h-32 w-full flex-col justify-between rounded-2xl p-4 ring-1 ring-border/50 bg-muted/40 cursor-default">
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">{title}</p>
</div>
</div>
);
}
// ── 来源骨架屏 ──────────────────────────────────────────
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<string, { id?: number; title: string }>;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}) {
@@ -188,15 +207,26 @@ function SourcesDialog({
<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}
onDelete={onDelete}
/>
))}
: <>
{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]) => (
<PendingSourceCard key={taskId} title={s.title} />
))}
{sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</>
}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
@@ -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<string | null>(null);
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
// 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配)
const pendingSelectTitlesRef = useRef<Set<string>>(new Set());
const [threadsExpanded, setThreadsExpanded] = useState(false);
const THREADS_LIMIT = 5;
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(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({
</div>
<div className="flex items-center gap-2">
<a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank"
rel="noreferrer"
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"
@@ -532,7 +582,7 @@ export default function StudioNotebookDetailPage({
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
{/* 右上角跳转按钮 */}
<a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank"
rel="noreferrer"
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
@@ -583,7 +633,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 && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
@@ -598,6 +647,24 @@ export default function StudioNotebookDetailPage({
</SelectContent>
</Select>
)}
{isStreaming ? (
<button
type="button"
onClick={handleStop}
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="停止回答"
>
<Square className="size-3.5 fill-current" />
</button>
) : input.trim() ? (
<button
type="submit"
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
title="发送"
>
<ArrowUp className="size-3.5" />
</button>
) : null}
</form>
{/* 空状态提示 */}
@@ -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 && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
@@ -726,6 +792,24 @@ export default function StudioNotebookDetailPage({
</SelectContent>
</Select>
)}
{isStreaming ? (
<button
type="button"
onClick={handleStop}
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="停止回答"
>
<Square className="size-3.5 fill-current" />
</button>
) : input.trim() ? (
<button
type="submit"
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
title="发送"
>
<ArrowUp className="size-3.5" />
</button>
) : null}
</form>
</div>
@@ -769,6 +853,7 @@ export default function StudioNotebookDetailPage({
sources={sources}
isLoadingSources={isLoadingSources}
selectedSourceIds={selectedSourceIds}
pendingSources={pendingSources}
onToggle={handleToggleSource}
onDelete={handleDeleteSource}
/>
+18 -9
View File
@@ -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 (
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
<div
className={`flex flex-col rounded-[20px] p-5 ${isDark ? "bg-[#1e2330]" : "bg-[#f2f2f2]"}`}
style={{ minHeight: 180 }}
>
<div className={`size-10 animate-pulse rounded-xl ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className="mt-auto flex flex-col gap-1.5 pt-8">
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
<div className={`h-4 w-3/4 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className={`h-3 w-1/3 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
</div>
</div>
);
@@ -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({
<div className="size-7" />
</div>
<div className="mt-auto flex flex-col gap-1 pt-8">
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
<span className={`line-clamp-2 text-[15px] font-medium leading-snug ${isDark ? "text-[#e8eaf0]" : "text-[#1f1f1f]"}`}>
{nb.name}
</span>
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
</div>
</>
);
return (
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`} style={{ minHeight: 180 }}>
{onOpen ? (
<button
type="button"
@@ -125,6 +131,8 @@ export default function StudioNotebooksPage({
onOpenNotebook?: (id: number) => void;
} = {}) {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { theme } = useTheme();
const isDark = theme === "dark";
const queryClient = useQueryClient();
// ── 新建 ──
@@ -221,11 +229,12 @@ export default function StudioNotebooksPage({
{/* Grid */}
<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} />)
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} isDark={isDark} />)
: notebooks.map((nb) => (
<NotebookCard
key={nb.id}
nb={nb}
isDark={isDark}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
onOpen={onOpenNotebook}