fix(studio): hide sidebar group until token resolves; remove VITE_STUDIO_ENABLED
- workspace-nav-studio: return null while isLoading so the "工作室" group no longer briefly mounts and then unmounts when getTokenByEmail returns "用户不存在" (404). Skeleton branch became dead code, removed. - studio/hooks: switch all three token queries from retry: 1 to false — account-not-found is a permanent error; retry just doubled isError latency. - env: drop VITE_STUDIO_ENABLED everywhere (env.ts + 7 env files + workspace-nav-chat-list). Sidebar availability is now decided at runtime by token success instead of a build-time flag. - env: add VITE_AIPI_PRESENT_URL + VITE_AIPI_KNOWES_URL for the document upload popup's A-system jump + B-system knowledge iframe; reuse the existing VITE_AIPI_KNOWLEDGE_URL for the Y-system iframe. - document-upload-popup: replace 8 hardcoded URLs (6 present, 1 knowes, 1 knowlibrary) with the corresponding env vars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bd0eceda61
commit
4416348c51
13 files changed
+287
-80
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
@@ -63,10 +63,12 @@ function SourceCard({
|
||||
item,
|
||||
selected,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
item: SourceItem;
|
||||
selected: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
const title = item.title ?? item.document?.title ?? "未命名";
|
||||
const isProcessing = item.status === "processing";
|
||||
@@ -81,6 +83,18 @@ function SourceCard({
|
||||
: "bg-muted ring-border/50 hover:bg-muted/70"
|
||||
}`}
|
||||
>
|
||||
{/* 删除按钮 - 右上角始终显示,悬停时高亮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
className="absolute top-1.5 right-1.5 z-20 rounded-md bg-background/80 backdrop-blur p-1.5 text-muted-foreground opacity-60 shadow-sm transition-all hover:bg-destructive hover:text-white group-hover:opacity-100"
|
||||
title="删除来源"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
|
||||
{isProcessing ? (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
@@ -89,7 +103,7 @@ function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs font-medium text-foreground">{title}</p>
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[200px] -translate-x-1/2 rounded-lg bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md ring-1 ring-border group-hover:block">
|
||||
<div className="pointer-events-none absolute top-full left-1/2 z-10 mt-2 hidden w-max max-w-[200px] -translate-x-1/2 rounded-lg bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md ring-1 ring-border group-hover:block">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,6 +129,7 @@ function SourcesDialog({
|
||||
isLoadingSources,
|
||||
selectedSourceIds,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -123,6 +138,7 @@ function SourcesDialog({
|
||||
isLoadingSources: boolean;
|
||||
selectedSourceIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
const { openDialog: openUploadPopup } = useDocumentUploadDialog();
|
||||
|
||||
@@ -170,6 +186,7 @@ function SourcesDialog({
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -201,6 +218,9 @@ export default function StudioNotebookDetailPage() {
|
||||
const THREADS_LIMIT = 5;
|
||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||
const [citationLoading, setCitationLoading] = useState(false);
|
||||
// 轮询任务管理:task_id -> 来源信息
|
||||
const [pendingSources, setPendingSources] = useState<Map<string, { id?: number; title: string }>>(new Map());
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedModel && models.length > 0) {
|
||||
@@ -239,6 +259,29 @@ export default function StudioNotebookDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteSource = async (sourceId: number) => {
|
||||
if (!notebook) return;
|
||||
try {
|
||||
const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001";
|
||||
const response = await fetch(`${backendUrl}/api/v1/source/${sourceId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${studioToken ?? ""}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
// 刷新来源列表
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
// 刷新 notebooks 数据以更新 source_count
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
|
||||
// 从选中列表中移除
|
||||
setSelectedSourceIds((prev) => prev.filter((id) => id !== sourceId));
|
||||
} else {
|
||||
console.error("删除来源失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除来源失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
@@ -254,6 +297,117 @@ export default function StudioNotebookDetailPage() {
|
||||
void refetchThreads();
|
||||
};
|
||||
|
||||
// 上传完成回调
|
||||
const handleUploadComplete = (uploadedSources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => {
|
||||
if (!notebook) return;
|
||||
// 将正在处理的来源添加到轮询队列
|
||||
const processing = uploadedSources.filter(s => s.status === 'processing' && s.task_id);
|
||||
if (processing.length > 0) {
|
||||
setPendingSources((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
processing.forEach(s => {
|
||||
if (s.task_id) {
|
||||
newMap.set(s.task_id, { id: s.id, title: s.title });
|
||||
}
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
// 刷新来源列表
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||
// 刷新 notebooks 数据以更新 source_count
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
|
||||
};
|
||||
|
||||
// 轮询单个来源任务状态
|
||||
const pollSourceStatus = useCallback(async (taskId: string) => {
|
||||
if (!notebook) return;
|
||||
try {
|
||||
const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001";
|
||||
const token = getStoredAuth()?.access_token ?? "";
|
||||
const response = await fetch(`${backendUrl}/api/v1/task/${taskId}/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
|
||||
const taskStatus = data.status === 'failed' ? 'error' : data.status;
|
||||
if (taskStatus !== 'processing') {
|
||||
// 任务完成,从轮询队列移除并刷新来源列表
|
||||
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;
|
||||
});
|
||||
// 刷新来源列表
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
// 刷新 notebooks 数据以更新 source_count
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`轮询任务 ${taskId} 状态失败:`, error);
|
||||
}
|
||||
}, [notebook?.id, queryClient]);
|
||||
|
||||
// 控制轮询 - 只在对话框打开时轮询
|
||||
useEffect(() => {
|
||||
// 对话框关闭时,停止轮询(但保留 pendingSources 数据)
|
||||
if (!sourcesOpen) {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 对话框打开时,刷新来源列表获取最新状态
|
||||
if (sourcesOpen && notebook) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||
}
|
||||
|
||||
// 对话框打开且有待处理任务时,启动轮询
|
||||
if (pendingSources.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (pollingIntervalRef.current) return;
|
||||
|
||||
// 立即检查一次
|
||||
pendingSources.forEach((_, taskId) => {
|
||||
void pollSourceStatus(taskId);
|
||||
});
|
||||
// 设置定时轮询
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
setPendingSources((current) => {
|
||||
if (current.size === 0) {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
current.forEach((_, taskId) => {
|
||||
void pollSourceStatus(taskId);
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sourcesOpen, pendingSources, pollSourceStatus, notebook, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 bg-background px-8">
|
||||
@@ -283,8 +437,10 @@ export default function StudioNotebookDetailPage() {
|
||||
<DocumentUploadDialogProvider
|
||||
searchSpace={{ id: notebook.id }}
|
||||
onUploadSuccess={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "notebooks", studioToken] });
|
||||
}}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
>
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
@@ -381,7 +537,10 @@ export default function StudioNotebookDetailPage() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
setSourcesOpen(true);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
@@ -579,6 +738,7 @@ export default function StudioNotebookDetailPage() {
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
|
||||
Reference in new issue
Block a user