diff --git a/src/main/ipc/knowledgeHandlers.ts b/src/main/ipc/knowledgeHandlers.ts index 4bb3c51..088a171 100644 --- a/src/main/ipc/knowledgeHandlers.ts +++ b/src/main/ipc/knowledgeHandlers.ts @@ -41,13 +41,13 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { }) try { - const documentId = await knowledgeService.addDocument( + const documentId = knowledgeService.enqueueContentDocument( params.notebookId, params.options, - (stage, progress) => { - // 发送进度更新 - event.sender.send('knowledge:index-progress', { + (id, stage, progress) => { + broadcastIndexProgress({ notebookId: params.notebookId, + documentId: id, stage, progress }) @@ -101,12 +101,13 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { Logger.debug('KnowledgeHandlers', 'add-document-from-url:', params) try { - const documentId = await knowledgeService.addDocumentFromUrl( + const documentId = knowledgeService.enqueueDocumentFromUrl( params.notebookId, params.url, - (stage, progress) => { - event.sender.send('knowledge:index-progress', { + (id, stage, progress) => { + broadcastIndexProgress({ notebookId: params.notebookId, + documentId: id, stage, progress }) @@ -129,12 +130,13 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { Logger.debug('KnowledgeHandlers', 'add-note:', params) try { - const documentId = await knowledgeService.addNoteToKnowledge( + const documentId = knowledgeService.enqueueNoteDocument( params.notebookId, params.noteId, - (stage, progress) => { - event.sender.send('knowledge:index-progress', { + (id, stage, progress) => { + broadcastIndexProgress({ notebookId: params.notebookId, + documentId: id, stage, progress }) @@ -276,11 +278,7 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { try { await knowledgeService.reindexDocument(params.documentId, (stage, progress) => { - event.sender.send('knowledge:index-progress', { - documentId: params.documentId, - stage, - progress - }) + broadcastIndexProgress({ documentId: params.documentId, stage, progress }) }) return { success: true } } catch (error) { @@ -299,11 +297,7 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { try { await knowledgeService.retryDocument(params.documentId, (stage, progress) => { - event.sender.send('knowledge:index-progress', { - documentId: params.documentId, - stage, - progress - }) + broadcastIndexProgress({ documentId: params.documentId, stage, progress }) }) return { success: true } } catch (error) { @@ -365,8 +359,8 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { const result = await knowledgeService.addFolder( params.notebookId, params.folderPath, - (stage, progress) => { - broadcastIndexProgress({ notebookId: params.notebookId, stage, progress }) + (documentId, stage, progress) => { + broadcastIndexProgress({ notebookId: params.notebookId, documentId, stage, progress }) } ) @@ -416,8 +410,8 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) { const result = knowledgeService.enqueueDocumentsFromPaths( params.notebookId, params.paths, - (stage, progress) => { - broadcastIndexProgress({ notebookId: params.notebookId, stage, progress }) + (documentId, stage, progress) => { + broadcastIndexProgress({ notebookId: params.notebookId, documentId, stage, progress }) } ) return { success: true, ...result } diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index f628b07..9165e2b 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -126,6 +126,18 @@ export interface SearchResult { */ export type IndexProgressCallback = (stage: string, progress: number) => void +/** + * 后台导入的进度回调(#176)。 + * + * 带 `documentId`:一份 source 的进度要能落在它自己的那一行上,而不是只能让整个 + * notebook 共用一个布尔值。 + */ +export type DocumentIndexProgressCallback = ( + documentId: string, + stage: string, + progress: number +) => void + /** 批量导入里被跳过的文件(#98):已经在同一个 notebook 里。 */ export interface BatchImportSkip { path: string @@ -350,7 +362,7 @@ export class KnowledgeService { enqueueDocumentsFromPaths( notebookId: string, paths: readonly string[], - onProgress?: IndexProgressCallback + onProgress?: DocumentIndexProgressCallback ): BatchImportResult { const db = getDatabase() const existing = new Set( @@ -379,7 +391,7 @@ export class KnowledgeService { added.push(documentId) this.ingestionQueue.enqueue({ documentId, - onProgress, + onProgress: (stage, progress) => onProgress?.(documentId, stage, progress), run: async (jobProgress) => { try { await this.ingestPendingDocument(documentId, filePath, jobProgress) @@ -466,7 +478,7 @@ export class KnowledgeService { async addFolder( notebookId: string, folderPath: string, - onProgress?: IndexProgressCallback + onProgress?: DocumentIndexProgressCallback ): Promise { const scanned = await scanFolder(folderPath, this.fileParserService.supportedExtensions()) return this.enqueueDocumentsFromPaths( @@ -920,6 +932,159 @@ export class KnowledgeService { ) } + /** + * 统一后台导入(#176):登记 `pending` 行,解析 / 分块 / 嵌入在队列里继续。 + * + * 内容已经可用的来源(粘贴文本 / 笔记)走这里;URL 要先抓取,见 + * `enqueueDocumentFromUrl`。返回时列表里已经有这一行,Dialog 不必再等索引结束。 + */ + enqueueContentDocument( + notebookId: string, + options: AddDocumentOptions, + onProgress?: DocumentIndexProgressCallback + ): string { + const db = getDatabase() + const documentId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` + const now = new Date() + const contentHash = createHash('md5').update(options.content).digest('hex') + + const newDoc: NewDocument = { + id: documentId, + notebookId, + title: options.title, + type: options.type, + sourceUri: options.sourceUri, + sourceNoteId: options.sourceNoteId, + content: options.content, + contentHash, + mimeType: options.mimeType, + fileSize: options.fileSize, + metadata: options.metadata, + status: 'pending', + chunkCount: 0, + createdAt: now, + updatedAt: now + } + db.insert(documents).values(newDoc).run() + + this.ingestionQueue.enqueue({ + documentId, + onProgress: (stage, progress) => onProgress?.(documentId, stage, progress), + run: async (jobProgress) => { + const runId = startRun(documentId, 'import') + try { + await this.indexDocument( + documentId, + runId, + options.content, + undefined, + { chunkOptions: options.chunkOptions }, + jobProgress + ) + completeRun(runId) + } catch (error) { + failRun(runId, (error as Error).message) + jobProgress('failed', 100) + throw error + } + } + }) + + return documentId + } + + /** + * 从 Note 后台导入。空笔记在登记前就拒绝 —— 那不是延迟反馈,是输入本身不合法。 + */ + enqueueNoteDocument( + notebookId: string, + noteId: string, + onProgress?: DocumentIndexProgressCallback + ): string { + const note = getDatabase().select().from(notes).where(eq(notes.id, noteId)).get() + if (!note) throw new Error(`Note ${noteId} not found`) + + if (note.content.trim().length === 0) { + throw new Error('Note content is empty. Cannot add empty note to knowledge base.') + } + + return this.enqueueContentDocument( + notebookId, + { + title: note.title, + type: 'note', + content: note.content, + sourceNoteId: noteId + }, + onProgress + ) + } + + /** + * 从 URL 后台导入:先登记 `pending` 行,抓取 / 解析 / 嵌入都在队列里。 + * + * URL 校验(协议、可解析)应在调用前完成;这里只负责登记与后台处理。 + */ + enqueueDocumentFromUrl( + notebookId: string, + url: string, + onProgress?: DocumentIndexProgressCallback + ): string { + const db = getDatabase() + const documentId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` + const now = new Date() + + const newDoc: NewDocument = { + id: documentId, + notebookId, + title: url, + type: 'url', + sourceUri: url, + status: 'pending', + chunkCount: 0, + createdAt: now, + updatedAt: now + } + db.insert(documents).values(newDoc).run() + + this.ingestionQueue.enqueue({ + documentId, + onProgress: (stage, progress) => onProgress?.(documentId, stage, progress), + run: async (jobProgress) => { + const runId = startRun(documentId, 'import') + try { + jobProgress('fetching_url', 0) + const fetchResult = await this.webFetchService.fetchUrl(url) + const content = fetchResult.content + + db.update(documents) + .set({ + title: fetchResult.title || url, + content, + contentHash: createHash('md5').update(content).digest('hex'), + mimeType: fetchResult.mimeType, + metadata: { + ...fetchResult.metadata, + description: fetchResult.description + }, + updatedAt: new Date() + }) + .where(eq(documents.id, documentId)) + .run() + + await this.indexDocument(documentId, runId, content, undefined, {}, jobProgress) + completeRun(runId) + } catch (error) { + failRun(runId, (error as Error).message) + jobProgress('failed', 100) + throw error + } + } + }) + + return documentId + } + /** * 索引前把 chunk 转成向量。 * diff --git a/src/renderer/src/components/common/ConfirmActionDialog.tsx b/src/renderer/src/components/common/ConfirmActionDialog.tsx new file mode 100644 index 0000000..9b3bece --- /dev/null +++ b/src/renderer/src/components/common/ConfirmActionDialog.tsx @@ -0,0 +1,99 @@ +import { ReactElement, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2 } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '../ui/alert-dialog' + +/** + * One confirmation dialog for every destructive or discard action. + * + * This used to be four near-copies (`ConfirmDialog`, `DeleteConfirmDialog`, + * `DeleteNoteConfirmDialog`, `UnsavedChangesDialog`) split across two primitives: + * the generic one rendered a normal `Dialog` while the notebook delete used + * `AlertDialog`, so the same "are you sure?" question looked and behaved + * differently depending on what was being deleted. Destructive confirmations are + * always `AlertDialog` — it is the primitive that owns the role, the focus trap, + * and the escape/overlay contract for an interrupting question. + */ +export interface ConfirmActionDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void | Promise + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + confirmVariant?: 'default' | 'destructive' + /** Caller-owned busy state; the dialog also tracks an async `onConfirm` itself. */ + isPending?: boolean +} + +export default function ConfirmActionDialog({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmLabel, + cancelLabel, + confirmVariant = 'destructive', + isPending +}: ConfirmActionDialogProps): ReactElement { + const { t } = useTranslation('common') + const [internalPending, setInternalPending] = useState(false) + const pending = Boolean(isPending) || internalPending + + const handleConfirm = async (event: React.MouseEvent): Promise => { + // Keep the dialog mounted until the work finishes, so a slow delete cannot be + // submitted twice and the reader keeps seeing that something is happening. + event.preventDefault() + if (pending) return + + setInternalPending(true) + try { + await onConfirm() + } finally { + setInternalPending(false) + onClose() + } + } + + return ( + { + if (!open && !pending) onClose() + }} + > + + + {title} + {description ? {description} : null} + + + {cancelLabel ?? t('cancel')} + + {pending && } + {confirmLabel ?? t('confirm')} + + + + + ) +} diff --git a/src/renderer/src/components/common/ConfirmDialog.tsx b/src/renderer/src/components/common/ConfirmDialog.tsx deleted file mode 100644 index 08982c2..0000000 --- a/src/renderer/src/components/common/ConfirmDialog.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { ReactElement } from 'react' -import { useTranslation } from 'react-i18next' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' -import { Button } from '../ui/button' - -interface ConfirmDialogProps { - isOpen: boolean - onClose: () => void - onConfirm: () => void - title: string - message: string -} - -export default function ConfirmDialog({ - isOpen, - onClose, - onConfirm, - title, - message -}: ConfirmDialogProps): ReactElement { - const { t } = useTranslation('common') - - const handleConfirm = (): void => { - onConfirm() - onClose() - } - - return ( - - - - {title} - {message} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/common/DeleteConfirmDialog.tsx b/src/renderer/src/components/common/DeleteConfirmDialog.tsx deleted file mode 100644 index 7946d2b..0000000 --- a/src/renderer/src/components/common/DeleteConfirmDialog.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { ReactElement } from 'react' -import { useTranslation } from 'react-i18next' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '../ui/alert-dialog' - -interface DeleteConfirmDialogProps { - isOpen: boolean - notebookTitle: string - onClose: () => void - onConfirm: () => void -} - -export default function DeleteConfirmDialog({ - isOpen, - notebookTitle, - onClose, - onConfirm -}: DeleteConfirmDialogProps): ReactElement { - const { t } = useTranslation(['common', 'notebook']) - - return ( - !open && onClose()}> - - - - {t('notebook:deleteNotebook')} - - - {t('notebook:deleteConfirm', { name: notebookTitle })} - - - - - {t('common:cancel')} - - - {t('common:delete')} - - - - - ) -} diff --git a/src/renderer/src/components/common/DeleteNoteConfirmDialog.tsx b/src/renderer/src/components/common/DeleteNoteConfirmDialog.tsx deleted file mode 100644 index 55dadb2..0000000 --- a/src/renderer/src/components/common/DeleteNoteConfirmDialog.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ReactElement } from 'react' -import { useTranslation } from 'react-i18next' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '../ui/alert-dialog' - -interface DeleteNoteConfirmDialogProps { - isOpen: boolean - onClose: () => void - onConfirm: () => void -} - -export default function DeleteNoteConfirmDialog({ - isOpen, - onClose, - onConfirm -}: DeleteNoteConfirmDialogProps): ReactElement { - const { t } = useTranslation(['common', 'notebook']) - - const handleConfirm = (): void => { - onConfirm() - onClose() - } - - return ( - !open && onClose()}> - - - {t('notebook:deleteNote')} - {t('notebook:deleteNoteWarning')} - - - {t('common:cancel')} - - {t('common:delete')} - - - - - ) -} diff --git a/src/renderer/src/components/common/UnsavedChangesDialog.tsx b/src/renderer/src/components/common/UnsavedChangesDialog.tsx deleted file mode 100644 index 97a4992..0000000 --- a/src/renderer/src/components/common/UnsavedChangesDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ReactElement } from 'react' -import { useTranslation } from 'react-i18next' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' -import { Button } from '../ui/button' - -interface UnsavedChangesDialogProps { - isOpen: boolean - onClose: () => void - onConfirm: () => void -} - -export default function UnsavedChangesDialog({ - isOpen, - onClose, - onConfirm -}: UnsavedChangesDialogProps): ReactElement { - const { t } = useTranslation(['common', 'notebook']) - - const handleConfirm = (): void => { - onConfirm() - onClose() - } - - return ( - - - - {t('notebook:unsavedChangesTitle')} - {t('notebook:unsavedChangesWarning')} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/notebook/NotePanel.tsx b/src/renderer/src/components/notebook/NotePanel.tsx index 65e35f5..6c285ef 100644 --- a/src/renderer/src/components/notebook/NotePanel.tsx +++ b/src/renderer/src/components/notebook/NotePanel.tsx @@ -14,8 +14,7 @@ import { ScrollArea } from '../ui/scroll-area' import { Button } from '../ui/button' import { Input } from '../ui/input' import { PanelHeader } from '../ui/panel-header' -import UnsavedChangesDialog from '../common/UnsavedChangesDialog' -import DeleteNoteConfirmDialog from '../common/DeleteNoteConfirmDialog' +import ConfirmActionDialog from '../common/ConfirmActionDialog' import type { Note } from '../../../../shared/types' // 编辑器面板子组件 - 管理编辑状态 @@ -426,17 +425,24 @@ export default function NotePanel(): ReactElement { )} {/* 未保存修改确认对话框 */} - setShowUnsavedDialog(false)} onConfirm={confirmLeave} + title={t('unsavedChangesTitle')} + description={t('unsavedChangesWarning')} + confirmLabel={t('leave')} + confirmVariant="default" /> {/* 删除笔记确认对话框 */} - setShowDeleteDialog(false)} onConfirm={confirmDelete} + title={t('deleteNote')} + description={t('deleteNoteWarning')} + confirmLabel={t('common:delete')} /> {/* 答题启动对话框 */} diff --git a/src/renderer/src/components/notebook/NotebookLayout.tsx b/src/renderer/src/components/notebook/NotebookLayout.tsx index df53a63..df84582 100644 --- a/src/renderer/src/components/notebook/NotebookLayout.tsx +++ b/src/renderer/src/components/notebook/NotebookLayout.tsx @@ -11,10 +11,11 @@ import { useNotebookStore } from '../../store/notebookStore' import { useChatStore } from '../../store/chatStore' import { useUIStore } from '../../store/uiStore' import { setupQuizListeners } from '../../store/quizStore' -import UnsavedChangesDialog from '../common/UnsavedChangesDialog' +import ConfirmActionDialog from '../common/ConfirmActionDialog' export default function NotebookLayout(): ReactElement { const { t } = useTranslation('ui') + const { t: tNotebook } = useTranslation('notebook') const navigate = useNavigate() const { id } = useParams() const { notebooks, addNotebook, addOpenedNotebook, setCurrentNotebook, removeOpenedNotebook } = @@ -109,10 +110,14 @@ export default function NotebookLayout(): ReactElement { rightPanel={} /> - {/* Global search (#96): Ctrl/Cmd+K from anywhere in the notebook. */} diff --git a/src/renderer/src/components/notebook/ProcessPanel.tsx b/src/renderer/src/components/notebook/ProcessPanel.tsx index 0b09646..45e7058 100644 --- a/src/renderer/src/components/notebook/ProcessPanel.tsx +++ b/src/renderer/src/components/notebook/ProcessPanel.tsx @@ -285,12 +285,15 @@ function ProcessPanel({ /> - {/* 底部渐变遮罩 - 独立于消息区域,避免堆叠上下文问题 */} + {/* 底部渐变遮罩 - 独立于消息区域,避免堆叠上下文问题。 + `right-2` 让出右侧 8px:Radix 的滚动条正好占这条轨道(`w-2`), + 全宽的遮罩会把它的下半段盖住。内容本身有 `px-4` 内边距,这 8px 里没有 + 正文,所以让开它不会让任何一行失去淡出。 */}
{ + if (type === 'url') return t('importUrlDesc') + if (type === 'text') return t('pasteTextDesc') + if (type === 'note') return t('importNoteDesc') + return '' + } + return ( {getTitle()} + {getDescription()}
@@ -171,15 +187,20 @@ function AddSourceModal({ // 索引进度组件 function IndexingProgress() { const { t } = useTranslation('ui') - const { indexProgress, isIndexing } = useKnowledgeStore() + const { indexingJobs, activeNotebookId } = useKnowledgeStore() + + const jobs = Object.values(indexingJobs).filter( + (job) => !activeNotebookId || !job.notebookId || job.notebookId === activeNotebookId + ) + if (jobs.length === 0) return null - if (!isIndexing || !indexProgress) return null + const progress = Math.round(jobs.reduce((sum, job) => sum + job.progress, 0) / jobs.length) return (
- {t('indexing')} ({indexProgress.progress}%) + {t('indexingSources', { count: jobs.length, progress })}
) @@ -313,16 +334,20 @@ export default function SourcePanel(): ReactElement { const [isDragging, setIsDragging] = useState(false) const [modalType, setModalType] = useState(null) const [hasEmbeddingModel, setHasEmbeddingModel] = useState(false) + // Short-lived: only while a source is being *registered*. Indexing itself is + // background work (#176), so the dialog must not stay disabled until embedding ends. + const [isSubmitting, setIsSubmitting] = useState(false) const [selectedDocument, setSelectedDocument] = useState(null) // The source list is a full-panel view that unmounts while a source is open. // Focus returns here after “Back” so a keyboard user does not land on . const listRef = useRef(null) + // The Add menu and its trigger: the layer that closes on an outside press. + const addMenuRef = useRef(null) const { documents, documentsLoaded, isLoading, - isIndexing, loadDocuments, loadStats, addDocument, @@ -341,6 +366,8 @@ export default function SourcePanel(): ReactElement { const { openSettings } = useUIStore() + useDismissOnOutsidePointer(showAddMenu, [addMenuRef], () => setShowAddMenu(false)) + // Embedding 是基础设施:没有远程 connection 时由内置本地模型承担(首次 RAG 使用按需下载)。 // 因此可用性取决于真实后端状态,而不是 connections.embedding 是否存在——内置模式不会写入 connection。 useEffect(() => { @@ -498,13 +525,25 @@ export default function SourcePanel(): ReactElement { [t] ) + /** + * Missing embedding model is an actionable state, not an error to interrupt with a + * native `alert()`: the toast carries the way to fix it (the same guidance the + * empty library already shows), and matches the rest of the app's notifications. + */ + const warnNoEmbeddingModel = useCallback((): void => { + toast.error(t('noEmbeddingModelConfigured'), { + id: 'no-embedding-model', + action: { label: t('goToSettings'), onClick: openSettings } + }) + }, [t, openSettings]) + // 处理文件上传(多选 → 批量导入,#98) const handleFileUpload = useCallback(async () => { if (!notebookId) return // 检查是否配置了默认嵌入模型 if (!hasEmbeddingModel) { - alert(t('noEmbeddingModelConfigured')) + warnNoEmbeddingModel() return } @@ -513,14 +552,14 @@ export default function SourcePanel(): ReactElement { if (files.length === 0) return // 一次批量导入:重复路径被跳过并报告,一个失败不中止其余。 reportBatch(await addFiles(notebookId, files)) - }, [notebookId, hasEmbeddingModel, selectFiles, addFiles, reportBatch, t]) + }, [notebookId, hasEmbeddingModel, selectFiles, addFiles, reportBatch, warnNoEmbeddingModel]) // 处理文件夹导入(#98 快照 / #158 监听) const handleFolderUpload = useCallback( async (watch: boolean) => { if (!notebookId) return if (!hasEmbeddingModel) { - alert(t('noEmbeddingModelConfigured')) + warnNoEmbeddingModel() return } @@ -530,7 +569,7 @@ export default function SourcePanel(): ReactElement { reportBatch(await addFolder(notebookId, folder, watch)) } }, - [notebookId, hasEmbeddingModel, selectFolder, addFolder, reportBatch, t] + [notebookId, hasEmbeddingModel, selectFolder, addFolder, reportBatch, warnNoEmbeddingModel] ) // 拖放文件/文件夹(#98)。Electron 39 下路径必须由 preload 的 webUtils 给出。 @@ -550,23 +589,28 @@ export default function SourcePanel(): ReactElement { [notebookId, hasEmbeddingModel, addFiles, reportBatch] ) - // 处理 URL 导入 + // 处理 URL 导入:只等 source 登记,抓取/解析/嵌入在后台(#176) const handleUrlImport = useCallback( async (data: { url?: string }) => { if (!notebookId || !data.url) return // 检查是否配置了默认嵌入模型 if (!hasEmbeddingModel) { - alert(t('noEmbeddingModelConfigured')) + warnNoEmbeddingModel() setModalType(null) return } - const result = await addDocumentFromUrl(notebookId, data.url) - reportImportFailure(data.url, result) - setModalType(null) + setIsSubmitting(true) + try { + const result = await addDocumentFromUrl(notebookId, data.url) + reportImportFailure(data.url, result) + } finally { + setIsSubmitting(false) + setModalType(null) + } }, - [notebookId, hasEmbeddingModel, addDocumentFromUrl, reportImportFailure, t] + [notebookId, hasEmbeddingModel, addDocumentFromUrl, reportImportFailure, warnNoEmbeddingModel] ) // 处理文本粘贴 @@ -576,20 +620,25 @@ export default function SourcePanel(): ReactElement { // 检查是否配置了默认嵌入模型 if (!hasEmbeddingModel) { - alert(t('noEmbeddingModelConfigured')) + warnNoEmbeddingModel() setModalType(null) return } - const result = await addDocument(notebookId, { - title: data.title, - type: 'text', - content: data.content - }) - reportImportFailure(data.title, result) - setModalType(null) + setIsSubmitting(true) + try { + const result = await addDocument(notebookId, { + title: data.title, + type: 'text', + content: data.content + }) + reportImportFailure(data.title, result) + } finally { + setIsSubmitting(false) + setModalType(null) + } }, - [notebookId, hasEmbeddingModel, addDocument, reportImportFailure, t] + [notebookId, hasEmbeddingModel, addDocument, reportImportFailure, warnNoEmbeddingModel] ) // 处理笔记导入 @@ -599,20 +648,32 @@ export default function SourcePanel(): ReactElement { // 检查是否配置了默认嵌入模型 if (!hasEmbeddingModel) { - alert(t('noEmbeddingModelConfigured')) + warnNoEmbeddingModel() setModalType(null) return } - const noteTitle = notes.find((note) => note.id === data.noteId)?.title ?? data.noteId - const result = await addNoteToKnowledge(notebookId, data.noteId) - // 空笔记是唯一需要换文案的原因,其余由主进程给出 - const isEmpty = (result.error ?? '').toLowerCase().includes('empty') - reportImportFailure(noteTitle, result, isEmpty ? t('emptyNoteCannotImport') : undefined) - - setModalType(null) + setIsSubmitting(true) + try { + const noteTitle = notes.find((note) => note.id === data.noteId)?.title ?? data.noteId + const result = await addNoteToKnowledge(notebookId, data.noteId) + // 空笔记是唯一需要换文案的原因,其余由主进程给出 + const isEmpty = (result.error ?? '').toLowerCase().includes('empty') + reportImportFailure(noteTitle, result, isEmpty ? t('emptyNoteCannotImport') : undefined) + } finally { + setIsSubmitting(false) + setModalType(null) + } }, - [notebookId, hasEmbeddingModel, addNoteToKnowledge, notes, reportImportFailure, t] + [ + notebookId, + hasEmbeddingModel, + addNoteToKnowledge, + notes, + reportImportFailure, + warnNoEmbeddingModel, + t + ] ) // 处理删除文档 @@ -745,6 +806,7 @@ export default function SourcePanel(): ReactElement { } right={
@@ -857,15 +919,16 @@ export default function SourcePanel(): ReactElement { isOpen={true} onClose={() => setModalType(null)} onSubmit={handleModalSubmit} - isLoading={isIndexing} + isLoading={isSubmitting} notes={notes .filter((n) => n.content.trim().length > 0) .map((n) => ({ id: n.id, title: n.title }))} /> )} - {/* 点击外部关闭菜单 */} - {showAddMenu &&
setShowAddMenu(false)} />} + {/* 点击外部关闭菜单由 `useDismissOnOutsidePointer` 负责:全屏遮罩在 + 兄弟面板(且是 `position: relative` 的卡片)之上会排到它们下面,点不到的 + 区域就关不掉了。 */} ) } diff --git a/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx b/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx index 7737f69..bfc4bf2 100644 --- a/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx +++ b/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx @@ -1,7 +1,14 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useAnkiStore } from '../../../store/ankiStore' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter +} from '../../ui/dialog' import { Button } from '../../ui/button' import { Label } from '../../ui/label' import { Slider } from '../../ui/slider' @@ -28,8 +35,25 @@ export default function AnkiConfigDialog({ const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium') const [customPrompt, setCustomPrompt] = useState('') + /** Restore the defaults: what a closed dialog should never carry back in. */ + const reset = (): void => { + setCardCount(20) + setDifficulty('medium') + setCustomPrompt('') + } + + /** + * Closing without generating discards the temporary input, so X / Esc / the + * overlay all mean Cancel rather than "remember what I typed". + */ + const handleOpenChange = (open: boolean): void => { + if (!open) reset() + onOpenChange(open) + } + const handleGenerate = async () => { // 立即关闭对话框 + reset() onOpenChange(false) // 在后台开始生成(不等待完成) @@ -48,10 +72,11 @@ export default function AnkiConfigDialog({ } return ( - + {t('generateCards')} + {t('generateCardsDesc')}
diff --git a/src/renderer/src/components/notebook/chat/SessionSwitcher.tsx b/src/renderer/src/components/notebook/chat/SessionSwitcher.tsx index a13caf2..f474208 100644 --- a/src/renderer/src/components/notebook/chat/SessionSwitcher.tsx +++ b/src/renderer/src/components/notebook/chat/SessionSwitcher.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import type { ChatMessageSearchHit } from '../../../../../shared/types/chat' import { useChatStore } from '../../../store/chatStore' import { useNotebookStore } from '../../../store/notebookStore' +import { useDismissOnOutsidePointer } from '../../../hooks/useDismissOnOutsidePointer' import { formatRelativeDate } from '../../../lib/relativeDate' import { Button } from '../../ui/button' import { Input } from '../../ui/input' @@ -41,6 +42,8 @@ export default function SessionSwitcher(): ReactElement { const [renameValue, setRenameValue] = useState('') const [confirmingId, setConfirmingId] = useState(null) const searchRef = useRef(null) + const triggerRef = useRef(null) + const panelRef = useRef(null) const notebookId = currentNotebook?.id ?? currentSession?.notebookId const hasQuery = query.trim().length > 0 @@ -63,6 +66,10 @@ export default function SessionSwitcher(): ReactElement { if (isOpen) searchRef.current?.focus() }, [isOpen]) + // A press anywhere outside the button or the panel closes it. This replaces the + // full-viewport overlay, whose clicks the draggable panel header could swallow. + useDismissOnOutsidePointer(isOpen, [triggerRef, panelRef], close) + // Escape closes the panel. The rename input handles Escape itself and stops // nothing else, so this is the one place the whole panel closes. useEffect(() => { @@ -133,6 +140,7 @@ export default function SessionSwitcher(): ReactElement { return ( <>