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:
xuhdandClaude Opus 4.7 committed 2026-06-09 15:48:24 +08:00
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>