feat(notebook): 来源上传优化 + 工作室问答停止回答功能
- 上传来源后显示处理中占位卡,轮询成功后自动选中该来源 - 失败时不选中,占位卡去重(已在真实列表中则不显示) - 输入框三态:空→无按钮,有内容→发送箭头,streaming→停止按钮 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
42ce20b4df
commit
91a6183757
1 file changed
+104
-21
@@ -6,11 +6,13 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowUp,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Files,
|
Files,
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
Square,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -119,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() {
|
function SourceCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -137,6 +153,7 @@ function SourcesDialog({
|
|||||||
sources,
|
sources,
|
||||||
isLoadingSources,
|
isLoadingSources,
|
||||||
selectedSourceIds,
|
selectedSourceIds,
|
||||||
|
pendingSources,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
@@ -146,6 +163,7 @@ function SourcesDialog({
|
|||||||
sources: SourceItem[];
|
sources: SourceItem[];
|
||||||
isLoadingSources: boolean;
|
isLoadingSources: boolean;
|
||||||
selectedSourceIds: number[];
|
selectedSourceIds: number[];
|
||||||
|
pendingSources: Map<string, { id?: number; title: string }>;
|
||||||
onToggle: (id: number) => void;
|
onToggle: (id: number) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -189,15 +207,26 @@ function SourcesDialog({
|
|||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{isLoadingSources
|
{isLoadingSources
|
||||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||||
: sources.map((src) => (
|
: <>
|
||||||
<SourceCard
|
{Array.from(pendingSources.entries())
|
||||||
key={src.id}
|
.filter(([, s]) => !sources.some((src) => {
|
||||||
item={src}
|
const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? "";
|
||||||
selected={selectedSourceIds.includes(src.id)}
|
return srcTitle === s.title;
|
||||||
onToggle={onToggle}
|
}))
|
||||||
onDelete={onDelete}
|
.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>
|
</div>
|
||||||
{!isLoadingSources && sources.length === 0 && (
|
{!isLoadingSources && sources.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||||
@@ -231,6 +260,8 @@ export default function StudioNotebookDetailPage({
|
|||||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||||
|
// 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配)
|
||||||
|
const pendingSelectTitlesRef = useRef<Set<string>>(new Set());
|
||||||
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||||
const THREADS_LIMIT = 5;
|
const THREADS_LIMIT = 5;
|
||||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||||
@@ -255,6 +286,22 @@ export default function StudioNotebookDetailPage({
|
|||||||
setSelectedSourceIds(sources.map((s) => s.id));
|
setSelectedSourceIds(sources.map((s) => s.id));
|
||||||
}
|
}
|
||||||
}, [sources, notebook?.id, initializedForNotebook]);
|
}, [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 citationRehypePlugins = useMemo(() => {
|
||||||
const mapped = sources.map((s) => ({
|
const mapped = sources.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -299,6 +346,10 @@ export default function StudioNotebookDetailPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
void thread.stop();
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
const handleSend = async (e: React.SyntheticEvent) => {
|
const handleSend = async (e: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
@@ -360,20 +411,17 @@ export default function StudioNotebookDetailPage({
|
|||||||
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
|
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
|
||||||
const taskStatus = data.status === 'failed' ? 'error' : data.status;
|
const taskStatus = data.status === 'failed' ? 'error' : data.status;
|
||||||
if (taskStatus !== 'processing') {
|
if (taskStatus !== 'processing') {
|
||||||
// 任务完成,从轮询队列移除并刷新来源列表
|
// 先取出 source 信息,再从轮询队列移除
|
||||||
|
const source = pendingSources.get(taskId);
|
||||||
setPendingSources((prev) => {
|
setPendingSources((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const source = newMap.get(taskId);
|
|
||||||
newMap.delete(taskId);
|
newMap.delete(taskId);
|
||||||
if (source) {
|
|
||||||
if (taskStatus === 'success') {
|
|
||||||
// toast.success(`来源 "${source.title}" 处理完成`);
|
|
||||||
} else {
|
|
||||||
// toast.error(`来源 "${source.title}" 处理失败`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
|
// 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中
|
||||||
|
if (taskStatus === 'success' && source?.title) {
|
||||||
|
pendingSelectTitlesRef.current.add(source.title);
|
||||||
|
}
|
||||||
// 刷新来源列表
|
// 刷新来源列表
|
||||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||||
// 刷新 notebooks 数据以更新 source_count
|
// 刷新 notebooks 数据以更新 source_count
|
||||||
@@ -383,7 +431,7 @@ export default function StudioNotebookDetailPage({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`轮询任务 ${taskId} 状态失败:`, error);
|
console.error(`轮询任务 ${taskId} 状态失败:`, error);
|
||||||
}
|
}
|
||||||
}, [notebook?.id, queryClient]);
|
}, [notebook?.id, pendingSources, queryClient]);
|
||||||
|
|
||||||
// 控制轮询 - 只在对话框打开时轮询
|
// 控制轮询 - 只在对话框打开时轮询
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -585,7 +633,6 @@ export default function StudioNotebookDetailPage({
|
|||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
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 && (
|
{models.length > 0 && (
|
||||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
<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">
|
<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">
|
||||||
@@ -600,6 +647,24 @@ export default function StudioNotebookDetailPage({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</form>
|
||||||
|
|
||||||
{/* 空状态提示 */}
|
{/* 空状态提示 */}
|
||||||
@@ -713,7 +778,6 @@ export default function StudioNotebookDetailPage({
|
|||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
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 && (
|
{models.length > 0 && (
|
||||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
<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">
|
<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">
|
||||||
@@ -728,6 +792,24 @@ export default function StudioNotebookDetailPage({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -771,6 +853,7 @@ export default function StudioNotebookDetailPage({
|
|||||||
sources={sources}
|
sources={sources}
|
||||||
isLoadingSources={isLoadingSources}
|
isLoadingSources={isLoadingSources}
|
||||||
selectedSourceIds={selectedSourceIds}
|
selectedSourceIds={selectedSourceIds}
|
||||||
|
pendingSources={pendingSources}
|
||||||
onToggle={handleToggleSource}
|
onToggle={handleToggleSource}
|
||||||
onDelete={handleDeleteSource}
|
onDelete={handleDeleteSource}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user