修改名称 笔记本改为 空间
This commit is contained in:
1 parent
06508cfb7c
commit
82d14351fc
12 files changed
+446
-393
No files matched your search
@@ -102,7 +102,7 @@ export function WorkspaceNavStudio() {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
|
||||
{/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
|
||||
{preview.map((nb) => (
|
||||
<SidebarMenuItem key={nb.id}>
|
||||
<SidebarMenuButton
|
||||
@@ -152,7 +152,7 @@ export function WorkspaceNavStudio() {
|
||||
value={name}
|
||||
onChange={(e) => setName(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"
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
/** 空间外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
export function getNotebookApiBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
|
||||
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
@@ -20,14 +20,16 @@ export async function fetchStudioToken(email: string): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
|
||||
export async function fetchStudioNotebooks(
|
||||
token: string,
|
||||
): Promise<StudioNotebook[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
|
||||
if (!res.ok) throw new Error(`获取空间列表失败:${res.status}`);
|
||||
return res.json() as Promise<StudioNotebook[]>;
|
||||
}
|
||||
|
||||
@@ -62,13 +64,23 @@ export async function uploadSourceText(
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
search_space_id: notebookId,
|
||||
content,
|
||||
title: title || "粘贴的文字",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
|
||||
export async function deleteSearchSpace(
|
||||
id: number,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
@@ -78,12 +90,19 @@ export async function deleteSearchSpace(id: number, token: string): Promise<void
|
||||
if (!res.ok) throw new Error(`删除失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
|
||||
export async function renameSearchSpace(
|
||||
id: number,
|
||||
name: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
|
||||
import {
|
||||
fetchNotebookSources,
|
||||
fetchStudioNotebooks,
|
||||
fetchStudioToken,
|
||||
} from "./api";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import type { SourceItem, StudioNotebook } from "./types";
|
||||
|
||||
@@ -30,7 +34,7 @@ export function useStudioNotebooks(enabled = true): {
|
||||
queryKey: ["studio", "notebooks", tokenQuery.data],
|
||||
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
|
||||
enabled: enabled && Boolean(tokenQuery.data),
|
||||
// 笔记本列表不会高频变动;新建/删除路径已通过 invalidateQueries 主动失效
|
||||
// 空间列表不会高频变动;新建/删除路径已通过 invalidateQueries 主动失效
|
||||
// (见 workspace-nav-studio.tsx),不需要短 staleTime + windowFocus 双兜底。
|
||||
staleTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
@@ -105,7 +109,9 @@ export function useNotebookChat(
|
||||
const sourceIds =
|
||||
selectedSourceIds.length > 0
|
||||
? toDocIds(selectedSourceIds)
|
||||
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
|
||||
: sources
|
||||
.map((s) => s.document?.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
await thread.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
@@ -124,7 +130,14 @@ export function useNotebookChat(
|
||||
);
|
||||
};
|
||||
|
||||
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
|
||||
return {
|
||||
thread,
|
||||
messages: thread.messages,
|
||||
isStreaming: thread.isLoading,
|
||||
send,
|
||||
threadId,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useNotebookSources(
|
||||
@@ -161,7 +174,8 @@ export function useNotebookSources(
|
||||
|
||||
return {
|
||||
sources: sourcesQuery.data ?? [],
|
||||
isLoading: (!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
|
||||
isLoading:
|
||||
(!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
|
||||
isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ interface AIWritingFormProps {
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
/** 预选笔记本 ID(传入时自动切换素材来源为「我的笔记」) */
|
||||
/** 预选空间 ID(传入时自动切换素材来源为「我的笔记」) */
|
||||
initialNotebookId?: number;
|
||||
/** 预选文档 ID 列表 */
|
||||
initialDocumentIds?: number[];
|
||||
@@ -45,7 +45,7 @@ const MAX_INTENT_LENGTH = 500;
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
|
||||
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
|
||||
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
|
||||
{ value: 'notebook', label: '我的笔记', desc: '从空间中检索素材' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
@@ -139,7 +139,7 @@ export function AIWritingForm({
|
||||
|
||||
function handleMaterialSourceChange(val: MaterialSource) {
|
||||
setMaterialSource(val);
|
||||
// studioToken 模式下笔记本和文档已锁定,切换素材来源时不清空
|
||||
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
|
||||
if (val !== 'notebook' && !studioToken) {
|
||||
setSelectedNotebookId(null);
|
||||
setSelectedDocumentIds([]);
|
||||
@@ -296,10 +296,10 @@ export function AIWritingForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
|
||||
{/* 选了"我的笔记"才展示空间 / 文档选择器 */}
|
||||
{materialSource === 'notebook' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* studioToken 模式:笔记本已锁定,不展示选择器 */}
|
||||
{/* studioToken 模式:空间已锁定,不展示选择器 */}
|
||||
{!studioToken && (
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
@@ -307,7 +307,7 @@ export function AIWritingForm({
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
@@ -324,7 +324,7 @@ export function AIWritingForm({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sourcesLoading
|
||||
? '文档加载中…'
|
||||
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
</p>
|
||||
{!sourcesLoading && (
|
||||
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AIMaterial {
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
|
||||
sourceType: "internet" | "intranet" | "skill" | "notebook";
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||
@@ -22,7 +22,13 @@ export interface AIMaterial {
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
type:
|
||||
| "list_skills"
|
||||
| "match_skills"
|
||||
| "select_skills"
|
||||
| "invoke_skill"
|
||||
| "skill_result"
|
||||
| "stop_early";
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
skillName?: string;
|
||||
@@ -41,8 +47,8 @@ export interface SkillSearchStep {
|
||||
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
|
||||
|
||||
export interface KeyPoint {
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
}
|
||||
|
||||
export interface OutlineSection {
|
||||
@@ -65,23 +71,28 @@ export interface AIOutline {
|
||||
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
|
||||
*/
|
||||
export function normalizeKeyPoint(raw: unknown): KeyPoint {
|
||||
if (typeof raw === 'string') {
|
||||
if (typeof raw === "string") {
|
||||
return { pointTitle: raw, subPoints: [] };
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
if (raw && typeof raw === "object") {
|
||||
const r = raw as Record<string, unknown>;
|
||||
const title =
|
||||
typeof r.pointTitle === 'string' ? r.pointTitle :
|
||||
typeof r.point_title === 'string' ? r.point_title :
|
||||
typeof r.title === 'string' ? r.title :
|
||||
'';
|
||||
typeof r.pointTitle === "string"
|
||||
? r.pointTitle
|
||||
: typeof r.point_title === "string"
|
||||
? r.point_title
|
||||
: typeof r.title === "string"
|
||||
? r.title
|
||||
: "";
|
||||
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
|
||||
const subPoints: string[] = Array.isArray(subsRaw)
|
||||
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
|
||||
? subsRaw
|
||||
.map((s) => (typeof s === "string" ? s : String(s ?? "")))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return { pointTitle: title, subPoints };
|
||||
}
|
||||
return { pointTitle: '', subPoints: [] };
|
||||
return { pointTitle: "", subPoints: [] };
|
||||
}
|
||||
|
||||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||||
@@ -93,7 +104,7 @@ export interface BlockedSection {
|
||||
}
|
||||
|
||||
/** 单个被阻塞章节的处理决策 */
|
||||
export type SectionDecisionKind = 'supplement' | 'delete' | 'loose';
|
||||
export type SectionDecisionKind = "supplement" | "delete" | "loose";
|
||||
|
||||
export interface SectionDecision {
|
||||
sectionTitle: string;
|
||||
@@ -103,15 +114,15 @@ export interface SectionDecision {
|
||||
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReviewIssue {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
severity: 'major' | 'minor';
|
||||
category: "fact" | "logic" | "language";
|
||||
severity: "major" | "minor";
|
||||
location: string;
|
||||
issue: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
verdict: 'pass' | 'reject';
|
||||
verdict: "pass" | "reject";
|
||||
factScore: number;
|
||||
logicScore: number;
|
||||
languageScore: number;
|
||||
@@ -123,44 +134,44 @@ export interface ReviewResult {
|
||||
// ── 写作状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WritingStatus =
|
||||
| 'idle'
|
||||
| 'researching'
|
||||
| 'awaiting_material_confirm'
|
||||
| 'writing_outline'
|
||||
| 'awaiting_outline_confirm'
|
||||
| 'writing_draft'
|
||||
| 'awaiting_section_help'
|
||||
| 'awaiting_draft_confirm'
|
||||
| 'reviewing'
|
||||
| 'awaiting_review_confirm'
|
||||
| 'revising'
|
||||
| 'done'
|
||||
| 'error';
|
||||
| "idle"
|
||||
| "researching"
|
||||
| "awaiting_material_confirm"
|
||||
| "writing_outline"
|
||||
| "awaiting_outline_confirm"
|
||||
| "writing_draft"
|
||||
| "awaiting_section_help"
|
||||
| "awaiting_draft_confirm"
|
||||
| "reviewing"
|
||||
| "awaiting_review_confirm"
|
||||
| "revising"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProgressEventType =
|
||||
| 'connected'
|
||||
| 'step_start'
|
||||
| 'step_done'
|
||||
| 'intent_chunk'
|
||||
| 'intent_ready'
|
||||
| 'research_progress'
|
||||
| 'materials_ready'
|
||||
| 'outline_chunk'
|
||||
| 'outline_ready'
|
||||
| 'draft_chunk'
|
||||
| 'draft_ready'
|
||||
| 'review_chunk'
|
||||
| 'review_item_done'
|
||||
| 'review_ready'
|
||||
| 'await_user'
|
||||
| 'resumed'
|
||||
| 'notice'
|
||||
| 'skill_search_step'
|
||||
| 'skill_select_chunk'
|
||||
| 'done'
|
||||
| 'error';
|
||||
| "connected"
|
||||
| "step_start"
|
||||
| "step_done"
|
||||
| "intent_chunk"
|
||||
| "intent_ready"
|
||||
| "research_progress"
|
||||
| "materials_ready"
|
||||
| "outline_chunk"
|
||||
| "outline_ready"
|
||||
| "draft_chunk"
|
||||
| "draft_ready"
|
||||
| "review_chunk"
|
||||
| "review_item_done"
|
||||
| "review_ready"
|
||||
| "await_user"
|
||||
| "resumed"
|
||||
| "notice"
|
||||
| "skill_search_step"
|
||||
| "skill_select_chunk"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface ProgressEvent {
|
||||
type: ProgressEventType;
|
||||
@@ -183,13 +194,18 @@ export interface ProgressEvent {
|
||||
draftTitle?: string;
|
||||
revisionCount?: number;
|
||||
// review_item_done (streaming per-category result)
|
||||
reviewItemCategory?: 'fact' | 'logic' | 'language';
|
||||
reviewItemCategory?: "fact" | "logic" | "language";
|
||||
reviewItemScore?: number;
|
||||
reviewItemIssues?: ReviewIssue[];
|
||||
// review_ready
|
||||
reviewResult?: ReviewResult;
|
||||
// await_user
|
||||
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
|
||||
pausePoint?:
|
||||
| "material_confirm"
|
||||
| "outline_confirm"
|
||||
| "draft_confirm"
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
pauseContext?: Record<string, unknown>;
|
||||
// section_help:素材不足、待用户决策的章节
|
||||
blockedSections?: BlockedSection[];
|
||||
@@ -229,19 +245,19 @@ export interface AIWritingRequest {
|
||||
/** 素材检索关键词数量(1~10,默认 1) */
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
writingMode: "strict" | "loose";
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
|
||||
materialSource: 'knowledge_base' | 'skill' | 'notebook';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
materialSource: "knowledge_base" | "skill" | "notebook";
|
||||
/** 笔记来源(可选):空间 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
|
||||
noteSourceIds?: number[];
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamingReviewItem {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
category: "fact" | "logic" | "language";
|
||||
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
|
||||
isDone: boolean;
|
||||
// 流式过程文本(正文 / 思考分开)
|
||||
@@ -266,7 +282,7 @@ export interface RevisionSummary {
|
||||
export interface StreamingDraftSection {
|
||||
sectionIndex: number;
|
||||
sectionTitle: string;
|
||||
text: string; // 正文内容(非思考)
|
||||
text: string; // 正文内容(非思考)
|
||||
thinkingText: string; // 思考内容(<think>...</think> 内)
|
||||
isDone: boolean;
|
||||
}
|
||||
@@ -274,7 +290,12 @@ export interface StreamingDraftSection {
|
||||
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompletedIntervention {
|
||||
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
|
||||
pausePoint:
|
||||
| "material_confirm"
|
||||
| "outline_confirm"
|
||||
| "draft_confirm"
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
action: string;
|
||||
// material_confirm
|
||||
materials?: AIMaterial[];
|
||||
|
||||
@@ -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">
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
# ── 纯笔记模式 ──
|
||||
# 用户在表单选了「我的笔记」作为素材来源 → 跳过 web/intranet 检索,
|
||||
# 仅使用 Step 1.5 已拉取的 notebook_materials。
|
||||
# 与 UI 文案"从笔记本中检索素材"对齐。
|
||||
# 与 UI 文案"从空间中检索素材"对齐。
|
||||
_emit(f"仅使用笔记素材,跳过 web/intranet 检索({len(notebook_materials)} 条)")
|
||||
all_results_nested = []
|
||||
else:
|
||||
|
||||
+2
-2
@@ -1078,7 +1078,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
|
||||
<notebook_mode>
|
||||
## 角色
|
||||
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
|
||||
你是一个空间 AI 助手,**只能**基于空间文档来源回答用户问题,禁止使用任何其他知识。
|
||||
|
||||
## 可用知识来源
|
||||
{source_line}
|
||||
@@ -1104,7 +1104,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- 未调用 `search_notebook_sources` 就直接回答
|
||||
- 引用不在检索结果中的 chunk_id
|
||||
- 省略引用标记
|
||||
- 使用笔记本来源之外的知识
|
||||
- 使用空间来源之外的知识
|
||||
</notebook_mode>"""
|
||||
|
||||
return prompt
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""笔记本 RAG 检索工具。
|
||||
"""空间 RAG 检索工具。
|
||||
|
||||
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
|
||||
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
|
||||
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在空间模式下引用回答。
|
||||
|
||||
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user