修改名称 笔记本改为 空间
This commit is contained in:
1 parent
06508cfb7c
commit
82d14351fc
12 files changed
+446
-393
No files matched your search
@@ -230,7 +230,7 @@ export default function LightAppNoteListPage() {
|
||||
isDark ? "text-slate-100" : "text-slate-950",
|
||||
)}
|
||||
>
|
||||
我的笔记本
|
||||
我的空间
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -77,11 +77,10 @@ function SourceCard({
|
||||
<div
|
||||
title={title}
|
||||
onClick={() => onToggle(item.id)}
|
||||
className={`group relative flex h-32 w-full cursor-pointer flex-col justify-between rounded-2xl p-4 ring-1 transition-colors ${
|
||||
selected
|
||||
className={`group relative flex h-32 w-full cursor-pointer flex-col justify-between rounded-2xl p-4 ring-1 transition-colors ${selected
|
||||
? "bg-primary/10 ring-primary"
|
||||
: "bg-muted ring-border/50 hover:bg-muted/70"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{/* 删除按钮 - 右上角始终显示,悬停时高亮 */}
|
||||
<button
|
||||
@@ -181,14 +180,14 @@ function SourcesDialog({
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
@@ -432,7 +431,7 @@ export default function StudioNotebookDetailPage() {
|
||||
if (!notebook) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">笔记本不存在</p>
|
||||
<p className="text-muted-foreground">空间不存在</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -453,308 +452,308 @@ export default function StudioNotebookDetailPage() {
|
||||
}}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
>
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* 背景渐变(列表视图时显示) */}
|
||||
{!isChatView && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 聊天视图:顶部栏 */}
|
||||
{isChatView && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleBackToList}
|
||||
className="rounded-lg p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<div className="flex size-8 items-center justify-center rounded-xl bg-muted">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-5 rounded-lg object-contain" />
|
||||
) : (
|
||||
<Files className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
|
||||
</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`}
|
||||
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"
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 列表视图:笔记本标题 + 历史线程列表 */}
|
||||
{!isChatView && (
|
||||
<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`}
|
||||
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"
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
|
||||
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
|
||||
<div className="flex size-14 items-center justify-center leading-none select-none">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-10 text-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 输入框(列表视图内联) */}
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
setSourcesOpen(true);
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* 背景渐变(列表视图时显示) */}
|
||||
{!isChatView && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{(!notebookThreads || notebookThreads.length === 0) && (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
笔记本对话会显示在这里
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
|
||||
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notebookThreads.length > THREADS_LIMIT && (
|
||||
{/* 聊天视图:顶部栏 */}
|
||||
{isChatView && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setThreadsExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={handleBackToList}
|
||||
className="rounded-lg p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex size-8 items-center justify-center rounded-xl bg-muted">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-5 rounded-lg object-contain" />
|
||||
) : (
|
||||
<Files className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
|
||||
</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`}
|
||||
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"
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天视图:消息列表 */}
|
||||
{isChatView && (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
onClick={(e) => {
|
||||
const sup = (e.target as HTMLElement).closest<HTMLElement>("[data-chunk-id]");
|
||||
if (!sup) return;
|
||||
const chunkId = Number(sup.dataset.chunkId);
|
||||
if (!chunkId) return;
|
||||
setCitationChunkId(chunkId);
|
||||
setCitationDoc(null);
|
||||
setCitationLoading(true);
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const tok = queryClient.getQueryData<string>(["studio", "token", email]) ?? "";
|
||||
void fetchDocumentByChunk(chunkId, tok)
|
||||
.then((doc) => setCitationDoc(doc))
|
||||
.finally(() => setCitationLoading(false));
|
||||
}}
|
||||
>
|
||||
<MessageList
|
||||
threadId={chatThreadId ?? ""}
|
||||
thread={thread}
|
||||
paddingBottom={120}
|
||||
extraRehypePlugins={citationRehypePlugins}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 列表视图:空间标题 + 历史线程列表 */}
|
||||
{!isChatView && (
|
||||
<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`}
|
||||
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"
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
|
||||
{/* 聊天视图的底部输入框 */}
|
||||
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
|
||||
<div className="flex size-14 items-center justify-center leading-none select-none">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-10 text-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 引用来源弹窗 */}
|
||||
<Dialog open={citationChunkId !== null} onOpenChange={(v) => { if (!v) setCitationChunkId(null); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">引用来源</DialogTitle>
|
||||
</DialogHeader>
|
||||
{citationLoading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!citationLoading && citationDoc && (
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 shrink-0 rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground uppercase">
|
||||
{citationDoc.file_type || citationDoc.document_type}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground leading-snug">{citationDoc.title}</span>
|
||||
</div>
|
||||
{citationDoc.chunks.find((c) => c.id === citationChunkId) && (
|
||||
<div className="max-h-60 overflow-y-auto rounded-xl bg-muted p-4 text-sm text-foreground leading-relaxed">
|
||||
{citationDoc.chunks.find((c) => c.id === citationChunkId)!.content}
|
||||
{/* 输入框(列表视图内联) */}
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
setSourcesOpen(true);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{(!notebookThreads || notebookThreads.length === 0) && (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
空间对话会显示在这里
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
|
||||
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notebookThreads.length > THREADS_LIMIT && (
|
||||
<button
|
||||
onClick={() => setThreadsExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!citationLoading && !citationDoc && (
|
||||
<p className="py-4 text-sm text-muted-foreground">加载失败,请重试。</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 来源 Dialog */}
|
||||
<SourcesDialog
|
||||
open={sourcesOpen}
|
||||
onClose={() => setSourcesOpen(false)}
|
||||
notebookName={notebook.name}
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
{/* 聊天视图:消息列表 */}
|
||||
{isChatView && (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
onClick={(e) => {
|
||||
const sup = (e.target as HTMLElement).closest<HTMLElement>("[data-chunk-id]");
|
||||
if (!sup) return;
|
||||
const chunkId = Number(sup.dataset.chunkId);
|
||||
if (!chunkId) return;
|
||||
setCitationChunkId(chunkId);
|
||||
setCitationDoc(null);
|
||||
setCitationLoading(true);
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const tok = queryClient.getQueryData<string>(["studio", "token", email]) ?? "";
|
||||
void fetchDocumentByChunk(chunkId, tok)
|
||||
.then((doc) => setCitationDoc(doc))
|
||||
.finally(() => setCitationLoading(false));
|
||||
}}
|
||||
>
|
||||
<MessageList
|
||||
threadId={chatThreadId ?? ""}
|
||||
thread={thread}
|
||||
paddingBottom={120}
|
||||
extraRehypePlugins={citationRehypePlugins}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天视图的底部输入框 */}
|
||||
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 引用来源弹窗 */}
|
||||
<Dialog open={citationChunkId !== null} onOpenChange={(v) => { if (!v) setCitationChunkId(null); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">引用来源</DialogTitle>
|
||||
</DialogHeader>
|
||||
{citationLoading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!citationLoading && citationDoc && (
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 shrink-0 rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground uppercase">
|
||||
{citationDoc.file_type || citationDoc.document_type}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground leading-snug">{citationDoc.title}</span>
|
||||
</div>
|
||||
{citationDoc.chunks.find((c) => c.id === citationChunkId) && (
|
||||
<div className="max-h-60 overflow-y-auto rounded-xl bg-muted p-4 text-sm text-foreground leading-relaxed">
|
||||
{citationDoc.chunks.find((c) => c.id === citationChunkId)!.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!citationLoading && !citationDoc && (
|
||||
<p className="py-4 text-sm text-muted-foreground">加载失败,请重试。</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 来源 Dialog */}
|
||||
<SourcesDialog
|
||||
open={sourcesOpen}
|
||||
onClose={() => setSourcesOpen(false)}
|
||||
notebookName={notebook.name}
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
</DocumentUploadDialogProvider>
|
||||
);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export default function StudioNotebooksPage() {
|
||||
<div className="min-h-full bg-background p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">空间</h1>
|
||||
<button
|
||||
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
@@ -191,7 +191,7 @@ export default function StudioNotebooksPage() {
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||
<p className="text-sm text-muted-foreground">加载空间失败,请稍后重试。</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
@@ -220,7 +220,7 @@ export default function StudioNotebooksPage() {
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
@@ -239,7 +239,7 @@ export default function StudioNotebooksPage() {
|
||||
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名笔记本</DialogTitle>
|
||||
<DialogTitle>重命名空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -272,12 +272,12 @@ export default function StudioNotebooksPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除笔记本</DialogTitle>
|
||||
<DialogTitle>删除空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||
删除后空间内所有来源数据将一并移除,且无法恢复。
|
||||
</p>
|
||||
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
Reference in new issue
Block a user