From 856a02b94401fa1d1542424def5130af51a8b5f6 Mon Sep 17 00:00:00 2001 From: MrSibe Date: Wed, 30 Sep 2026 16:15:22 +0800 Subject: [PATCH 1/2] fix(ui): roll back refused actions, own background work, unify dialogs Several surfaces held state that no longer matched what the main process had actually done, or kept it past the point where it was meaningful. - chat: `continueMessage` set the message to `pending` and registered the live turn before the IPC. A `continuation-limit` refusal left it spinning forever; roll the optimistic state back on refusal or a rejected invoke. - settings: X / Esc / overlay closed without discarding `pendingSettings` / `pendingConnections`, so unsaved edits reappeared on reopen. Route every close through one `handleClose`, and give the dialog the Radix title/description. - knowledge: replace the single `isIndexing` + `indexProgress` with `indexingJobs` keyed by documentId and fed by per-document progress. Refreshing after an import no longer clears the queue, so the top progress stays up for the whole background index. - knowledge: URL, pasted text and notes now register a `pending` source and index in the background like file imports; the Add Source dialog only tracks the short `isSubmitting` registration. - dialogs: collapse `ConfirmDialog`, `DeleteConfirmDialog`, `DeleteNoteConfirmDialog` and `UnsavedChangesDialog` into one `ConfirmActionDialog` on `AlertDialog`. Replace the remaining native `alert()` with Sonner, and make Quiz/Anki config close-on-cancel discard temporary input. - floating panels: dismiss the session switcher and the library add menu on an outside press via a document listener. The full-viewport overlays lost to sibling `position: relative` cards, and the one inside a `-webkit-app-region: drag` header had its clicks eaten by window dragging. - chat: inset the bottom scroll fade by the scrollbar track so it no longer paints over the scrollbar. --- src/main/ipc/knowledgeHandlers.ts | 42 ++--- src/main/services/KnowledgeService.ts | 171 +++++++++++++++++- .../components/common/ConfirmActionDialog.tsx | 99 ++++++++++ .../src/components/common/ConfirmDialog.tsx | 54 ------ .../components/common/DeleteConfirmDialog.tsx | 54 ------ .../common/DeleteNoteConfirmDialog.tsx | 51 ------ .../common/UnsavedChangesDialog.tsx | 50 ----- .../src/components/notebook/NotePanel.tsx | 14 +- .../components/notebook/NotebookLayout.tsx | 9 +- .../src/components/notebook/ProcessPanel.tsx | 7 +- .../src/components/notebook/SourcePanel.tsx | 135 ++++++++++---- .../notebook/anki/AnkiConfigDialog.tsx | 29 ++- .../notebook/chat/SessionSwitcher.tsx | 10 +- .../src/components/notebook/item/ItemList.tsx | 7 +- .../notebook/quiz/QuizStartDialog.tsx | 28 ++- .../notebook/source/DocumentList.tsx | 7 +- .../src/components/pages/AnkiPage.tsx | 5 +- .../src/components/pages/NotebookListPage.tsx | 8 +- .../components/settings/SettingsDialog.tsx | 20 +- .../src/hooks/useDismissOnOutsidePointer.ts | 42 +++++ src/renderer/src/locales/en-US/anki.json | 1 + src/renderer/src/locales/en-US/settings.json | 1 + src/renderer/src/locales/en-US/ui.json | 5 + src/renderer/src/locales/zh-CN/anki.json | 1 + src/renderer/src/locales/zh-CN/settings.json | 1 + src/renderer/src/locales/zh-CN/ui.json | 4 + src/renderer/src/store/chatStore.ts | 45 ++++- src/renderer/src/store/knowledgeStore.ts | 111 ++++++++---- test/knowledgeStore.test.ts | 9 +- 29 files changed, 678 insertions(+), 342 deletions(-) create mode 100644 src/renderer/src/components/common/ConfirmActionDialog.tsx delete mode 100644 src/renderer/src/components/common/ConfirmDialog.tsx delete mode 100644 src/renderer/src/components/common/DeleteConfirmDialog.tsx delete mode 100644 src/renderer/src/components/common/DeleteNoteConfirmDialog.tsx delete mode 100644 src/renderer/src/components/common/UnsavedChangesDialog.tsx create mode 100644 src/renderer/src/hooks/useDismissOnOutsidePointer.ts 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 ( <>