diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 6228c720..0bb1cdba 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -25,6 +25,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn +# 我的笔记前端 +VITE_AIPI_FRONTEND_URL= xxx VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn diff --git a/frontend-web/src/components/ai-elements/artifact.tsx b/frontend-web/src/components/ai-elements/artifact.tsx index b5e89bd7..3acfc5c1 100644 --- a/frontend-web/src/components/ai-elements/artifact.tsx +++ b/frontend-web/src/components/ai-elements/artifact.tsx @@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes; export const Artifact = ({ className, ...props }: ArtifactProps) => (
(
(
diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 2827977d..2deec268 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -371,6 +371,18 @@ function MessageContent_({ onEditResubmit?: (text: string) => void; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); + + // Streamdown splits content into per-block processors; calling the factory + // once here ensures all blocks share the same transformer instance (and thus + // the same citation counter) for this message. + const stableExtraPlugins = useMemo(() => { + if (!extraRehypePlugins?.length) return []; + return extraRehypePlugins.map((factory) => { + const transformer = factory(); + return () => transformer; + }); + }, [extraRehypePlugins]); + const rehypePlugins = useMemo( () => extraRehypePlugins?.length diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index ca241545..f75996d2 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -19,6 +19,9 @@ import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenL import { SidebarCollapseButton } from "./workspace-header"; import { WorkspaceNavStudio } from "./workspace-nav-studio"; import { cn } from "@/lib/utils"; +import { env } from "@/env"; + +const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true"; // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 const NAV_ITEM_CLASS = @@ -165,7 +168,7 @@ export function WorkspaceNavChatList({ )} - {/* {!showCollapseTrigger && } */} + {!showCollapseTrigger && studioEnabled && } ); } diff --git a/frontend-web/src/components/workspace/workspace-nav-studio.tsx b/frontend-web/src/components/workspace/workspace-nav-studio.tsx index e546c39e..01eb649a 100644 --- a/frontend-web/src/components/workspace/workspace-nav-studio.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-studio.tsx @@ -34,8 +34,6 @@ const NAV_ITEM_CLASS = "data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " + "dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground"; -const SIDEBAR_PREVIEW_COUNT = 5; - export function WorkspaceNavStudio() { const { notebooks, isLoading, isError } = useStudioNotebooks(); const { pathname } = useLocation(); @@ -71,6 +69,7 @@ export function WorkspaceNavStudio() { return null; } + const SIDEBAR_PREVIEW_COUNT = 2; const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT); return ( diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 9727e6f6..d6a8ade6 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) { } } -/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_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(/\/+$/, ""); diff --git a/frontend-web/src/core/rehype/index.ts b/frontend-web/src/core/rehype/index.ts index 14d72b11..7ed5be3e 100644 --- a/frontend-web/src/core/rehype/index.ts +++ b/frontend-web/src/core/rehype/index.ts @@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g; export function rehypeCitations(sources: Array<{ id: number; title: string }>) { const sourceMap = new Map(sources.map((s) => [s.id, s.title])); + const citeCounter = new Map(); + let citeIndex = 0; return (tree: Root) => { visit(tree, "text", (node, index, parent) => { @@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) { CITE_RE.lastIndex = 0; const newNodes: ElementContent[] = []; - const citeCounter = new Map(); - let citeIndex = 0; let last = 0; let match: RegExpExecArray | null; diff --git a/frontend-web/src/core/studio/api.ts b/frontend-web/src/core/studio/api.ts index 16e8d90a..5d779a87 100644 --- a/frontend-web/src/core/studio/api.ts +++ b/frontend-web/src/core/studio/api.ts @@ -7,7 +7,7 @@ function base(): string { export async function fetchStudioToken(email: string): Promise { const apiBase = base(); - if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); const res = await fetch( `${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`, @@ -22,7 +22,7 @@ export async function fetchStudioToken(email: string): Promise { export async function fetchStudioNotebooks(token: string): Promise { const apiBase = base(); - if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); const res = await fetch(`${apiBase}/api/v1/searchspaces`, { headers: { Authorization: `Bearer ${token}` }, @@ -36,7 +36,7 @@ export async function createSearchSpace( token: string, ): Promise { const apiBase = base(); - if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); const res = await fetch(`${apiBase}/api/v1/searchspaces`, { method: "POST", @@ -46,7 +46,47 @@ export async function createSearchSpace( }, body: JSON.stringify({ name, description: "", citations_enabled: true }), }); - if (!res.ok) throw new Error(`创建失败:${res.status}`); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail || `创建失败:${res.status}`); + } +} + +export async function uploadSourceText( + notebookId: number, + content: string, + title: string, + token: string, +): Promise { + const apiBase = base(); + 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 || "粘贴的文字" }), + }); + if (!res.ok) throw new Error(`上传失败:${res.status}`); +} + +export async function deleteSearchSpace(id: number, token: string): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); + const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`删除失败:${res.status}`); +} + +export async function renameSearchSpace(id: number, name: string, token: string): Promise { + 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}` }, + body: JSON.stringify({ name }), + }); + if (!res.ok) throw new Error(`重命名失败:${res.status}`); } export interface ChunkRead { @@ -69,7 +109,7 @@ export async function fetchDocumentByChunk( token: string, ): Promise { const apiBase = base(); - if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, { headers: { Authorization: `Bearer ${token}` }, @@ -78,12 +118,34 @@ export async function fetchDocumentByChunk( return res.json() as Promise; } +export async function uploadSourceFiles( + notebookId: number, + files: File[], + token: string, +): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); + + const formData = new FormData(); + formData.append("search_space_id", String(notebookId)); + for (const file of files) { + formData.append("files", file); + } + + const res = await fetch(`${apiBase}/api/v1/documents/fileupload`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!res.ok) throw new Error(`上传失败:${res.status}`); +} + export async function fetchNotebookSources( notebookId: number, token: string, ): Promise { const apiBase = base(); - if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); const res = await fetch( `${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`, diff --git a/frontend-web/src/core/studio/hooks.ts b/frontend-web/src/core/studio/hooks.ts index bb767664..679096af 100644 --- a/frontend-web/src/core/studio/hooks.ts +++ b/frontend-web/src/core/studio/hooks.ts @@ -12,6 +12,7 @@ export function useStudioNotebooks(): { notebooks: StudioNotebook[]; isLoading: boolean; isError: boolean; + token: string | undefined; } { const email = getStoredAuth()?.email ?? ""; @@ -35,6 +36,7 @@ export function useStudioNotebooks(): { notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner), isLoading: tokenQuery.isLoading || notebooksQuery.isLoading, isError: tokenQuery.isError || notebooksQuery.isError, + token: tokenQuery.data, }; } @@ -89,10 +91,15 @@ export function useNotebookChat( const send = async (text: string, selectedSourceIds: number[]) => { if (thread.isLoading || !token) return; + // API 需要 document.id,而不是 source.id + const toDocIds = (ids: number[]) => + ids + .map((id) => sources.find((s) => s.id === id)?.document?.id) + .filter((id): id is number => id !== undefined); const sourceIds = selectedSourceIds.length > 0 - ? selectedSourceIds - : sources.map((s) => s.id).filter(Boolean); + ? toDocIds(selectedSourceIds) + : sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined); await thread.submit( { messages: [{ type: "human", content: text }] }, diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 10b6603c..4628be8c 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -6,9 +6,11 @@ function readEnv( | "VITE_LANGGRAPH_BASE_URL" | "VITE_CANVAS_LANGGRAPH_BASE_URL" | "VITE_NOTE_API_BASE_URL" - | "VITE_NOTEBOOK_API_BASE_URL" + | "VITE_AIPI_BACKENP_URL" + | "VITE_AIPI_FRONTEND_URL" | "VITE_STATIC_WEBSITE_ONLY" - | "VITE_GITHUB_OAUTH_TOKEN", + | "VITE_GITHUB_OAUTH_TOKEN" + | "VITE_STUDIO_ENABLED", ): EnvValue { const value = import.meta.env[key]; return typeof value === "string" && value.trim() ? value : undefined; @@ -21,7 +23,8 @@ export const env = { // routed (e.g. a separate canvas-agent server). Falls back to the main URL. NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"), NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"), - NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"), + NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), + STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"), }; diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 54c20f4a..4c2728fa 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -11,6 +11,7 @@ import { SelectValue, } from '@/components/ui/select'; import { useModels } from '@/core/models/hooks'; +import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks'; import type { AIWritingRequest } from '../../types/ai-writing'; import { useArticleTypes } from '../../hooks/useArticleTypes'; @@ -31,13 +32,14 @@ const INPUT_CLASS = const DEFAULT_KEYWORD_COUNT = 1; const DEFAULT_AUDIENCE = '普通读者'; -const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base'; +type MaterialSource = 'knowledge_base' | 'skill' | 'notebook'; +const DEFAULT_MATERIAL_SOURCE: MaterialSource = 'knowledge_base'; const MAX_INTENT_LENGTH = 500; -// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索 -const MATERIAL_SOURCE_OPTIONS: Array<{ value: 'knowledge_base' | 'skill'; label: string }> = [ - { value: 'knowledge_base', label: '知识库' }, - { value: 'skill', label: '技能检索' }, +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: '从笔记本中检索素材' }, ]; const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]'; @@ -54,10 +56,15 @@ export function AIWritingForm({ const [audience, setAudience] = useState('普通读者'); const [wordCountInput, setWordCountInput] = useState('800'); const [strictMode, setStrictMode] = useState(false); - const [materialSource, setMaterialSource] = - useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE); + const [materialSource, setMaterialSource] = useState(DEFAULT_MATERIAL_SOURCE); const [modelName, setModelName] = useState(''); + // 笔记来源:选中的 search_space_id 和多个 document_id + const [selectedNotebookId, setSelectedNotebookId] = useState(null); + const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); + const { models, isLoading: modelsLoading } = useModels(); + const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(); + const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId); const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); @@ -109,6 +116,25 @@ export function AIWritingForm({ } } + function handleNotebookChange(val: string) { + setSelectedNotebookId(val ? Number(val) : null); + setSelectedDocumentIds([]); + } + + function handleMaterialSourceChange(val: MaterialSource) { + setMaterialSource(val); + if (val !== 'notebook') { + setSelectedNotebookId(null); + setSelectedDocumentIds([]); + } + } + + function toggleDocument(docId: number) { + setSelectedDocumentIds((prev) => + prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId], + ); + } + function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!intent.trim()) return; @@ -124,6 +150,9 @@ export function AIWritingForm({ writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', materialSource, modelName: selectedModel?.name || modelName || undefined, + // 只有选了"我的笔记"才传笔记参数 + noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined, + noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined, }); } @@ -196,7 +225,7 @@ export function AIWritingForm({ -

- 知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。 -

+ + {/* 选了"我的笔记"才展示笔记本 / 文档选择器 */} + {materialSource === 'notebook' && ( +
+ + + {selectedNotebookId !== null && ( +
+

+ {sourcesLoading + ? '文档加载中…' + : `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`} +

+ {!sourcesLoading && ( +
+ {sources.filter((s) => s.document?.id != null).map((s) => { + const docId = s.document!.id; + return ( + + ); + })} +
+ )} +
+ )} +
+ )}
diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index af30a2c7..611a82c5 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -25,6 +25,8 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { useStream } from '@langchain/langgraph-sdk/react'; import { getAPIClient } from '@/core/api'; +import { getStoredAuth } from '@/core/auth'; +import { fetchStudioToken } from '@/core/studio/api'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { saveAIWritingTranscript } from '../api/ai-writing-sessions'; @@ -739,6 +741,19 @@ export function useAIWriting() { if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return; dispatch({ type: 'start', request }); try { + // 如果选择了笔记来源,获取 notebook token 用于后端检索 + let notebookToken = ''; + if (request.noteSearchSpaceId) { + const email = getStoredAuth()?.email ?? ''; + if (email) { + try { + notebookToken = await fetchStudioToken(email); + } catch { + // 忽略 token 获取失败,后端会优雅降级 + } + } + } + await stream.submit( { user_intent: request.userIntent, @@ -758,9 +773,16 @@ export function useAIWriting() { current_draft: null, review_result: null, last_user_intervention: null, + notebook_search_space_id: request.noteSearchSpaceId ?? null, + notebook_source_ids: request.noteSourceIds ?? [], } as Partial, { - config: { configurable: { model_name: request.modelName ?? '' } } as Record, + config: { + configurable: { + model_name: request.modelName ?? '', + ...(notebookToken ? { notebook_token: notebookToken } : {}), + }, + } as Record, streamMode: ['values', 'updates', 'custom', 'messages-tuple'], }, ); diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 3d48aaec..358f3800 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -7,7 +7,7 @@ export interface AIMaterial { source: string; publishedAt: string; relevanceScore: number; - sourceType: 'internet' | 'intranet' | 'skill'; + sourceType: 'internet' | 'intranet' | 'skill' | 'notebook'; url?: string; recUuid?: string; // skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等) @@ -228,8 +228,12 @@ export interface AIWritingRequest { keywordCount: number; /** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */ writingMode: 'strict' | 'loose'; - /** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */ - materialSource: 'knowledge_base' | 'skill'; + /** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */ + materialSource: 'knowledge_base' | 'skill' | 'notebook'; + /** 笔记来源(可选):笔记本 search_space_id */ + noteSearchSpaceId?: number; + /** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */ + noteSourceIds?: number[]; } // ── 流式审核条目 ────────────────────────────────────────────────────────────── diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx index d0e944ba..57ff6d8f 100644 --- a/frontend-web/src/pages/StudioNotebookDetailPage.tsx +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -1,16 +1,15 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AlignLeft, ArrowLeft, - CloudUpload, + BookOpen, Files, Globe, Loader2, Plus, - Send, X, } from "lucide-react"; @@ -23,7 +22,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks"; -import { fetchDocumentByChunk } from "@/core/studio/api"; +import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api"; import type { DocumentWithChunks } from "@/core/studio/api"; import type { SourceItem } from "@/core/studio/types"; import { getStoredAuth } from "@/core/auth"; @@ -36,6 +35,8 @@ import { SelectValue, } from "@/components/ui/select"; import { MessageList } from "@/components/workspace/messages/message-list"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import { ChatBox } from "@/components/workspace/chats/chat-box"; import { rehypeCitations } from "@/core/rehype"; // ── 来源卡片图标 ──────────────────────────────────────── @@ -101,24 +102,74 @@ function SourceCardSkeleton() { ); } +const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm"; + // ── 来源 Dialog ───────────────────────────────────────── function SourcesDialog({ open, onClose, notebookName, + notebookId, + token, sources, isLoadingSources, selectedSourceIds, onToggle, + onUploadSuccess, }: { open: boolean; onClose: () => void; notebookName: string; + notebookId: number; + token: string; sources: SourceItem[]; isLoadingSources: boolean; selectedSourceIds: number[]; onToggle: (id: number) => void; + onUploadSuccess: () => void; }) { + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); + const [panel, setPanel] = useState<"sources" | "text">("sources"); + const [textContent, setTextContent] = useState(""); + const [textTitle, setTextTitle] = useState(""); + const [submittingText, setSubmittingText] = useState(false); + const [textError, setTextError] = useState(""); + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; + setUploading(true); + setUploadError(""); + try { + await uploadSourceFiles(notebookId, files, token); + onUploadSuccess(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : "上传失败"); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleTextSubmit = async () => { + if (!textContent.trim()) return; + setSubmittingText(true); + setTextError(""); + try { + await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token); + setTextContent(""); + setTextTitle(""); + setPanel("sources"); + onUploadSuccess(); + } catch (err) { + setTextError(err instanceof Error ? err.message : "上传失败"); + } finally { + setSubmittingText(false); + } + }; + return ( !v && onClose()}>
+ {/* 左侧导航 */}
- {[ - { icon: Plus, label: "上传文件" }, - { icon: CloudUpload, label: "从云端硬盘添加" }, - { icon: Globe, label: "添加网站" }, - { icon: AlignLeft, label: "复制的文字" }, - ].map(({ icon: Icon, label }) => ( - - ))} -
- -
-
- {isLoadingSources - ? Array.from({ length: 8 }).map((_, i) => ) - : sources.map((src) => ( - - ))} -
- {!isLoadingSources && sources.length === 0 && ( -

暂无来源,请添加文件。

+ { void handleFileChange(e); }} + /> + + + {uploadError && ( +

{uploadError}

)}
+ + {/* 右侧内容 */} + {panel === "sources" ? ( +
+
+ {isLoadingSources + ? Array.from({ length: 8 }).map((_, i) => ) + : sources.map((src) => ( + + ))} +
+ {!isLoadingSources && sources.length === 0 && ( +

暂无来源,请添加文件。

+ )} +
+ ) : ( +
+ setTextTitle(e.target.value)} + placeholder="标题(可选)" + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary" + /> +