feat(notebook): load notebook space via token and pre-select docs/sources
- NotebookAIWritingPage reads `token` from URL, fetches space data from notebook-token API (search_space_id, doc_ids, user_token) and space name from searchspaces API; keep-alive ping every 50s resets Redis TTL - Pass user_token as studioToken through AIWritingPage → AIWritingLayout → WritingFormPanel → AIWritingPanel → AIWritingForm to skip getTokenByEmail when studio token is already available - useStudioNotebooks accepts enabled flag; useNotebookSources accepts overrideToken to bypass the internal token fetch - AIWritingForm hides notebook selector when studioToken is provided (notebook is pre-locked) and pre-selects initialDocumentIds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
e53c1a0f7b
commit
0e7149eec3
6 files changed
+140
-85
No files matched your search
@@ -19,9 +19,6 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Search,
|
||||
Sparkles,
|
||||
Moon,
|
||||
BookOpen,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
@@ -33,7 +30,6 @@ import Empty from "./empty";
|
||||
import { NetworkActor } from "./network-actor";
|
||||
|
||||
// ── 适配层 ─────────────────────────────────────────────────
|
||||
const writeYLog = (..._args: unknown[]) => {};
|
||||
|
||||
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||
const token = getStoredAuth()?.access_token;
|
||||
@@ -58,13 +54,7 @@ function getYUserId(): string {
|
||||
return "1157761008018653184";
|
||||
}
|
||||
|
||||
type SearchMode = "A" | "Y" | "knowledge";
|
||||
|
||||
const searchModes = [
|
||||
{ value: "A", label: "A系统", icon: Sparkles },
|
||||
{ value: "Y", label: "Y系统", icon: Moon },
|
||||
{ value: "knowledge", label: "知识库", icon: BookOpen },
|
||||
] as const;
|
||||
|
||||
const searchTypes = [
|
||||
{ value: "human", label: "人物基因库" },
|
||||
@@ -274,7 +264,7 @@ const DocumentUploadPopupContent: FC<{
|
||||
data?: { query: string; mode: string }
|
||||
) => Promise<void>;
|
||||
initialSearchText?: string;
|
||||
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, closeDialog, initialSearchText }) => {
|
||||
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => {
|
||||
const searchSpaceId = String(searchSpace?.id);
|
||||
const backendUrl =
|
||||
typeof window !== "undefined"
|
||||
@@ -286,7 +276,6 @@ const DocumentUploadPopupContent: FC<{
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [importSources, setImportSources] = useState<unknown[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [listData, setListData] = useState<listDataItem[]>([]);
|
||||
const [searchType, setSearchType] = useState("human");
|
||||
@@ -396,34 +385,7 @@ const DocumentUploadPopupContent: FC<{
|
||||
setSelectedSearchSources([]);
|
||||
};
|
||||
|
||||
const handleAllSearch = async (searchMode: SearchMode, text: string) => {
|
||||
if (isSearching || !text.trim()) return;
|
||||
setIsSearching(true);
|
||||
const reqBody = {
|
||||
query: text,
|
||||
search_space_id: searchSpaceId,
|
||||
search_mode: searchMode,
|
||||
};
|
||||
const reqUrl = `${backendUrl}/api/v1/source/new`;
|
||||
try {
|
||||
const response = await authenticatedFetch(reqUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { result: unknown[] };
|
||||
writeYLog({ optDetail: "笔记检索数据", optType: 3, reqParam: reqBody, url: reqUrl, returnParam: JSON.stringify({ data }), optResult: "0" });
|
||||
const results = data.result as listDataItem[];
|
||||
results.forEach((item) => { item.checked = false; });
|
||||
setImportSources(results);
|
||||
}
|
||||
} catch (error) {
|
||||
writeYLog({ optDetail: "笔记检索数据", optType: 3, reqParam: reqBody, url: reqUrl, returnParam: JSON.stringify({ data: error }), optResult: "1" });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function urlSafeBtoa(str: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
@@ -599,7 +561,11 @@ const DocumentUploadPopupContent: FC<{
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4">
|
||||
<DialogContent
|
||||
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加来源</DialogTitle>
|
||||
<DialogDescription className="sr-only">从外部系统搜索并导入来源</DialogDescription>
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import type { SourceItem, StudioNotebook } from "./types";
|
||||
|
||||
export function useStudioNotebooks(): {
|
||||
export function useStudioNotebooks(enabled = true): {
|
||||
notebooks: StudioNotebook[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
@@ -19,7 +19,7 @@ export function useStudioNotebooks(): {
|
||||
const tokenQuery = useQuery({
|
||||
queryKey: ["studio", "token", email],
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
enabled: enabled && Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export function useStudioNotebooks(): {
|
||||
const notebooksQuery = useQuery({
|
||||
queryKey: ["studio", "notebooks", tokenQuery.data],
|
||||
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
|
||||
enabled: Boolean(tokenQuery.data),
|
||||
enabled: enabled && Boolean(tokenQuery.data),
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
@@ -121,33 +121,38 @@ export function useNotebookChat(
|
||||
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
|
||||
}
|
||||
|
||||
export function useNotebookSources(notebookId: number | null): {
|
||||
export function useNotebookSources(
|
||||
notebookId: number | null,
|
||||
overrideToken?: string,
|
||||
): {
|
||||
sources: SourceItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
} {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
|
||||
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
|
||||
// 若外部直接提供 user_token(如 notebook-token 接口返回的),跳过 getTokenByEmail
|
||||
const tokenQuery = useQuery({
|
||||
queryKey: ["studio", "token", email],
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
enabled: !overrideToken && Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const resolvedToken = overrideToken ?? tokenQuery.data;
|
||||
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ["studio", "sources", notebookId],
|
||||
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
|
||||
enabled: notebookId !== null && Boolean(tokenQuery.data),
|
||||
queryKey: ["studio", "sources", notebookId, overrideToken ?? "default"],
|
||||
queryFn: () => fetchNotebookSources(notebookId!, resolvedToken!),
|
||||
enabled: notebookId !== null && Boolean(resolvedToken),
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
sources: sourcesQuery.data ?? [],
|
||||
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
|
||||
isError: tokenQuery.isError || sourcesQuery.isError,
|
||||
isLoading: (!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
|
||||
isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,10 @@ interface AIWritingFormProps {
|
||||
hideAdvancedFields?: boolean;
|
||||
/** 预选笔记本 ID(传入时自动切换素材来源为「我的笔记」) */
|
||||
initialNotebookId?: number;
|
||||
/** 预选文档 ID 列表 */
|
||||
initialDocumentIds?: number[];
|
||||
/** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
@@ -53,6 +57,8 @@ export function AIWritingForm({
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: AIWritingFormProps) {
|
||||
const {
|
||||
intent, setIntent,
|
||||
@@ -69,11 +75,11 @@ export function AIWritingForm({
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
|
||||
initialNotebookId ?? null,
|
||||
);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
@@ -291,22 +297,25 @@ export function AIWritingForm({
|
||||
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
|
||||
{materialSource === 'notebook' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* studioToken 模式:笔记本已锁定,不展示选择器 */}
|
||||
{!studioToken && (
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{selectedNotebookId !== null && (
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -71,11 +71,16 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
export function AIWritingPanel({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
@@ -354,6 +359,8 @@ export function AIWritingPanel({
|
||||
onSubmit={startWriting}
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -115,11 +115,17 @@ const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
|
||||
/**
|
||||
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
|
||||
*/
|
||||
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
interface NotebookProps {
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
return (
|
||||
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +135,7 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
|
||||
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||||
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||||
*/
|
||||
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
@@ -162,7 +168,7 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
@@ -203,9 +209,13 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
|
||||
export default function AIWritingPage({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
@@ -217,7 +227,7 @@ export default function AIWritingPage({
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
search_space_id: number;
|
||||
user_id: string;
|
||||
user_token: string;
|
||||
}
|
||||
|
||||
export default function NotebookAIWritingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const notebookId = searchParams.get('notebook_id');
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [data, setData] = useState<NotebookTokenData | null>(null);
|
||||
const [spaceName, setSpaceName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('缺少 token 参数');
|
||||
return;
|
||||
}
|
||||
const base = getNotebookApiBaseURL();
|
||||
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`请求失败 ${res.status}`);
|
||||
return res.json() as Promise<NotebookTokenData>;
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
return fetch(`${base}/api/v1/searchspaces/${d.search_space_id}`, {
|
||||
headers: { Authorization: `Bearer ${d.user_token}` },
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<{ name: string }>;
|
||||
})
|
||||
.then((space) => {
|
||||
if (space?.name) setSpaceName(space.name);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}, [token]);
|
||||
|
||||
// Keep-alive:每 50 秒 ping 一次 token 接口,防止 Redis TTL 过期
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const base = getNotebookApiBaseURL();
|
||||
const id = setInterval(() => {
|
||||
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`).catch(() => {});
|
||||
}, 50_000);
|
||||
return () => clearInterval(id);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
@@ -20,12 +70,12 @@ export default function NotebookAIWritingPage() {
|
||||
{/* 分隔线 */}
|
||||
<div className="w-px h-4 bg-[#2d4f7a]" />
|
||||
|
||||
{/* 空间 ID */}
|
||||
{notebookId && (
|
||||
{/* 空间名称 */}
|
||||
{data?.search_space_id && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[#7fb3e8] text-xs">空间</span>
|
||||
<span className="text-[#b3d4f5] text-xs font-semibold bg-[#1a3a60] border border-[#2d5080] rounded px-2 py-0.5">
|
||||
{notebookId}
|
||||
{spaceName ?? data.search_space_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -40,12 +90,20 @@ export default function NotebookAIWritingPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:AIWritingPage 完整组件,含 context providers */}
|
||||
{/* 主体 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
initialNotebookId={notebookId ? Number(notebookId) : undefined}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center h-full text-red-400 text-sm">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-full text-[#7fb3e8] text-sm">加载中…</div>
|
||||
) : (
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
initialNotebookId={data.search_space_id}
|
||||
initialDocumentIds={data.doc_ids}
|
||||
studioToken={data.user_token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user