From ee3c766770c02c0eeb1576b54cc95439dfb36782 Mon Sep 17 00:00:00 2001 From: mrsibe Date: Thu, 1 Oct 2026 23:06:10 +0800 Subject: [PATCH] fix: surface generation errors and guide initial model setup --- .../src/components/notebook/NotePanel.tsx | 44 ++++-- .../src/components/notebook/ProcessPanel.tsx | 8 +- .../notebook/anki/AnkiConfigDialog.tsx | 7 + .../components/notebook/chat/MessageItem.tsx | 33 +++-- .../notebook/mindmap/NodeDetailPanel.tsx | 15 +- .../src/components/pages/OnboardingPage.tsx | 134 ++++++++++++++---- .../components/settings/EmbeddingSettings.tsx | 2 + src/renderer/src/locales/en-US/anki.json | 3 +- src/renderer/src/locales/en-US/ui.json | 9 +- src/renderer/src/locales/zh-CN/anki.json | 3 +- src/renderer/src/locales/zh-CN/ui.json | 9 +- src/renderer/src/store/chatStore.ts | 12 +- test/onboardingGenerationFeedback.test.ts | 51 +++++++ 13 files changed, 271 insertions(+), 59 deletions(-) create mode 100644 test/onboardingGenerationFeedback.test.ts diff --git a/src/renderer/src/components/notebook/NotePanel.tsx b/src/renderer/src/components/notebook/NotePanel.tsx index 6c285ef..780fee7 100644 --- a/src/renderer/src/components/notebook/NotePanel.tsx +++ b/src/renderer/src/components/notebook/NotePanel.tsx @@ -283,12 +283,20 @@ export default function NotePanel(): ReactElement { if (notebookId) { try { // 立即开始生成(异步) - window.api.mindmap.generate(notebookId).then((result) => { - if (result.success) { - // 生成完成后重新加载列表 - loadItems(notebookId) - } - }) + void window.api.mindmap + .generate(notebookId) + .then((result) => { + if (!result.success) throw new Error(result.error || t('ui:unknownError')) + return loadItems(notebookId) + }) + .catch((error) => { + toast.error( + t('ui:generationFailed', { + error: error instanceof Error ? error.message : String(error) + }), + { duration: 10000 } + ) + }) // 等待一小段时间后刷新列表,以显示"正在生成"的 item setTimeout(() => { @@ -296,6 +304,12 @@ export default function NotePanel(): ReactElement { }, 500) } catch (error) { console.error('[NotePanel] Failed to generate mind map:', error) + toast.error( + t('ui:generationFailed', { + error: error instanceof Error ? error.message : String(error) + }), + { duration: 10000 } + ) } } } @@ -322,10 +336,16 @@ export default function NotePanel(): ReactElement { customPrompt: params.customPrompt }) .then((result) => { - if (result.success) { - // 生成完成后重新加载列表 - loadItems(notebookId) - } + if (!result.success) throw new Error(result.error || t('ui:unknownError')) + return loadItems(notebookId) + }) + .catch((error) => { + toast.error( + t('ui:generationFailed', { + error: error instanceof Error ? error.message : String(error) + }), + { duration: 10000 } + ) }) // 等待一小段时间后刷新列表,以显示"正在生成"的 item @@ -334,6 +354,10 @@ export default function NotePanel(): ReactElement { }, 500) } catch (error) { console.error('[NotePanel] Failed to start quiz:', error) + toast.error( + t('ui:generationFailed', { error: error instanceof Error ? error.message : String(error) }), + { duration: 10000 } + ) } } diff --git a/src/renderer/src/components/notebook/ProcessPanel.tsx b/src/renderer/src/components/notebook/ProcessPanel.tsx index 45e7058..ec7ef4c 100644 --- a/src/renderer/src/components/notebook/ProcessPanel.tsx +++ b/src/renderer/src/components/notebook/ProcessPanel.tsx @@ -1,4 +1,5 @@ import { memo, useState, useEffect, useRef, ReactElement } from 'react' +import { toast } from 'sonner' import { Send, StopCircle, @@ -97,7 +98,12 @@ function ProcessPanel({ const handleSend = (): void => { if (!canSend) return - sendMessage(currentSession.id, input.trim()) + void sendMessage(currentSession.id, input.trim()).catch((error) => { + toast.error( + t('generationFailed', { error: error instanceof Error ? error.message : String(error) }), + { duration: 10000 } + ) + }) setInput('') // The reader may have scrolled up meanwhile: their own message must always // land in view, regardless of the follow state. diff --git a/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx b/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx index bfc4bf2..94be51e 100644 --- a/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx +++ b/src/renderer/src/components/notebook/anki/AnkiConfigDialog.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { toast } from 'sonner' import { useTranslation } from 'react-i18next' import { useAnkiStore } from '../../../store/ankiStore' import { @@ -63,6 +64,12 @@ export default function AnkiConfigDialog({ customPrompt: customPrompt || undefined }).catch((error) => { console.error('Failed to generate anki cards:', error) + toast.error( + t('generateFailed', { + error: error instanceof Error ? error.message : String(error) + }), + { duration: 10000 } + ) }) // 等待一小段时间后触发回调,以显示"正在生成"的 item diff --git a/src/renderer/src/components/notebook/chat/MessageItem.tsx b/src/renderer/src/components/notebook/chat/MessageItem.tsx index edd0e09..7ebe9bc 100644 --- a/src/renderer/src/components/notebook/chat/MessageItem.tsx +++ b/src/renderer/src/components/notebook/chat/MessageItem.tsx @@ -105,16 +105,33 @@ function MessageItem({ message }: MessageItemProps): ReactElement { const recoveryNotebookId = message.notebookId ?? currentNotebook?.id const handleContinue = (): void => { if (!recoveryNotebookId) return - void continueMessage(recoveryNotebookId, message.id).then((result) => { - // The main process refuses past the continuation bound (#179); say why the - // button did nothing instead of leaving it silent. - if (!result.started && result.reason === 'continuation-limit') { - toast.error(t('ui:continueLimitReached')) - } - }) + void continueMessage(recoveryNotebookId, message.id) + .then((result) => { + // The main process refuses past the continuation bound (#179); say why the + // button did nothing instead of leaving it silent. + if (!result.started && result.reason === 'continuation-limit') { + toast.error(t('ui:continueLimitReached')) + } + }) + .catch((error) => { + toast.error( + t('ui:generationFailed', { + error: error instanceof Error ? error.message : String(error) + }), + { duration: 10000 } + ) + }) } const handleRetry = (): void => { - if (recoveryNotebookId) void retryMessage(recoveryNotebookId, message.id) + if (recoveryNotebookId) + void retryMessage(recoveryNotebookId, message.id).catch((error) => { + toast.error( + t('ui:generationFailed', { + error: error instanceof Error ? error.message : String(error) + }), + { duration: 10000 } + ) + }) } const handleCopy = async () => { diff --git a/src/renderer/src/components/notebook/mindmap/NodeDetailPanel.tsx b/src/renderer/src/components/notebook/mindmap/NodeDetailPanel.tsx index 6bfc1c3..3389eb2 100644 --- a/src/renderer/src/components/notebook/mindmap/NodeDetailPanel.tsx +++ b/src/renderer/src/components/notebook/mindmap/NodeDetailPanel.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { useMindMapStore } from '../../../store/mindmapStore' import { useChatStore } from '../../../store/chatStore' import ReactMarkdown from 'react-markdown' +import { toast } from 'sonner' export default function NodeDetailPanel() { const { t } = useTranslation('notebook') @@ -23,10 +24,16 @@ export default function NodeDetailPanel() { if (!currentSession) return const question = `基于以下内容,请详细解释:\n\n${chunkContent}` - await sendMessage(currentSession.id, question) - - // 关闭思维导图Dialog,聚焦到对话面板 - setDialogOpen(false) + try { + await sendMessage(currentSession.id, question) + // 关闭思维导图Dialog,聚焦到对话面板 + setDialogOpen(false) + } catch (error) { + toast.error( + t('ui:generationFailed', { error: error instanceof Error ? error.message : String(error) }), + { duration: 10000 } + ) + } } return ( diff --git a/src/renderer/src/components/pages/OnboardingPage.tsx b/src/renderer/src/components/pages/OnboardingPage.tsx index 73b8c9d..2a63d2e 100644 --- a/src/renderer/src/components/pages/OnboardingPage.tsx +++ b/src/renderer/src/components/pages/OnboardingPage.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' +import type { ConnectionMap } from '../../../../shared/types' +import ModelsSettings from '../settings/ModelsSettings' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useOnboardingStore } from '../../store/onboardingStore' @@ -20,19 +22,58 @@ export default function OnboardingPage() { const { language, changeLanguage } = useI18nStore() const [selectedLanguage, setSelectedLanguage] = useState(language) const [isCompleting, setIsCompleting] = useState(false) + const [step, setStep] = useState(0) + const [connections, setConnections] = useState(null) + const [error, setError] = useState('') - const handleComplete = async () => { + useEffect(() => { + window.api.connections + .getAll() + .then(setConnections) + .catch((err) => { + setError(err instanceof Error ? err.message : String(err)) + }) + }, []) + + const handleNext = async (skip = false) => { setIsCompleting(true) - await changeLanguage(selectedLanguage) - await completeOnboarding() - navigate('/') + setError('') + try { + if (step === 0) { + await changeLanguage(selectedLanguage) + } else if (!skip && connections) { + const capability = step === 1 ? 'embedding' : 'chat' + const connection = connections[capability] + if (connection) { + if (!connection.baseUrl.trim() || !connection.modelId.trim()) { + setError(t('onboardingIncompleteConnection')) + return + } + await window.api.connections.save(capability, connection) + } else { + await window.api.connections.remove(capability) + } + } + if (step < 2) { + // Reload persisted connections so skipped edits do not leak into later steps. + setConnections(await window.api.connections.getAll()) + setStep(step + 1) + } else { + await completeOnboarding() + navigate('/') + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setIsCompleting(false) + } } const selectedLangName = languages.find((l) => l.code === selectedLanguage)?.name return (
-
+
{/* Logo */}
KnowNote @@ -44,29 +85,68 @@ export default function OnboardingPage() {

{t('onboardingTagline')}

- {/* 语言选择 */} -
- setSelectedLanguage(value as Language)} + > + + {selectedLangName} + + + {languages.map((lang) => ( + + {lang.name} + + ))} + + +
+ ) : ( +
+

+ {t(step === 1 ? 'onboardingEmbeddingHint' : 'onboardingChatHint')} +

+ {connections && ( + + )} +
+ )} + {error && ( +

+ {error} +

+ )} +
+ {step > 0 && ( + + )} +
- - {/* 确认按钮 */} -
) diff --git a/src/renderer/src/components/settings/EmbeddingSettings.tsx b/src/renderer/src/components/settings/EmbeddingSettings.tsx index 6917e8d..7b686bc 100644 --- a/src/renderer/src/components/settings/EmbeddingSettings.tsx +++ b/src/renderer/src/components/settings/EmbeddingSettings.tsx @@ -110,6 +110,8 @@ export default function EmbeddingSettings({ if (!result.success) { setError(result.error || 'Download failed') } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) } finally { setBusy(false) await refreshStatus() diff --git a/src/renderer/src/locales/en-US/anki.json b/src/renderer/src/locales/en-US/anki.json index 5136e90..c304f5e 100644 --- a/src/renderer/src/locales/en-US/anki.json +++ b/src/renderer/src/locales/en-US/anki.json @@ -52,5 +52,6 @@ "validatingData": "Validating data", "savingCards": "Saving cards", "completed": "Completed" - } + }, + "generateFailed": "Card generation failed: {{error}}" } diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json index 89a9648..de5ed3d 100644 --- a/src/renderer/src/locales/en-US/ui.json +++ b/src/renderer/src/locales/en-US/ui.json @@ -125,5 +125,12 @@ "searchMatchingText": "Matching text", "searchRelatedPassages": "Related passages", "searchPage": "p. {{page}}", - "continueLimitReached": "This answer has reached its continuation limit." + "continueLimitReached": "This answer has reached its continuation limit.", + "onboardingStep": "Step {{step}} of {{total}}", + "onboardingIncompleteConnection": "Enter a base URL and model name, or choose Set up later.", + "onboardingEmbeddingHint": "Download the built-in embedding model to index and search documents without an API key. You can also configure a remote model or set it up later in Settings.", + "onboardingChatHint": "A chat model powers answers, cards and quizzes; the embedding model cannot do this. Configure a compatible service or local model now, or later in Settings.", + "onboardingSkip": "Set up later", + "onboardingNext": "Save and continue", + "generationFailed": "Generation failed: {{error}}" } diff --git a/src/renderer/src/locales/zh-CN/anki.json b/src/renderer/src/locales/zh-CN/anki.json index e6d18f4..b3cde1e 100644 --- a/src/renderer/src/locales/zh-CN/anki.json +++ b/src/renderer/src/locales/zh-CN/anki.json @@ -52,5 +52,6 @@ "validatingData": "验证数据", "savingCards": "保存卡片", "completed": "完成" - } + }, + "generateFailed": "卡片生成失败:{{error}}" } diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json index 3e5a1b1..cb5ca14 100644 --- a/src/renderer/src/locales/zh-CN/ui.json +++ b/src/renderer/src/locales/zh-CN/ui.json @@ -124,5 +124,12 @@ "searchMatchingText": "匹配的文本", "searchRelatedPassages": "相关段落", "searchPage": "第 {{page}} 页", - "continueLimitReached": "这条回答已达到继续次数上限。" + "continueLimitReached": "这条回答已达到继续次数上限。", + "onboardingStep": "第 {{step}} / {{total}} 步", + "onboardingIncompleteConnection": "请填写服务地址和模型名称,或选择稍后配置。", + "onboardingEmbeddingHint": "下载内置嵌入模型后即可索引和检索文档,无需 API Key。也可以配置远程模型,或稍后在设置中完成。", + "onboardingChatHint": "对话模型用于问答、卡片和测验生成,不包含在嵌入模型中。可配置兼容服务或本地模型;稍后也可在设置中完成。", + "onboardingSkip": "稍后配置", + "onboardingNext": "保存并继续", + "generationFailed": "生成失败:{{error}}" } diff --git a/src/renderer/src/store/chatStore.ts b/src/renderer/src/store/chatStore.ts index f9c4551..058e589 100644 --- a/src/renderer/src/store/chatStore.ts +++ b/src/renderer/src/store/chatStore.ts @@ -550,8 +550,7 @@ export const useChatStore = create()((set, get) => ({ const result = await window.api.retryMessage(messageId) if (!result.success || !result.messageId) { - console.error('[ChatStore] Failed to retry the answer:', result.error) - return + throw new Error(result.error || 'Failed to retry the answer') } // A sibling: a new answer to the same question, next to the one that stopped. It @@ -630,14 +629,17 @@ export const useChatStore = create()((set, get) => ({ // of leaving the spinner running. rollback() console.error('[ChatStore] Failed to continue the answer:', error) - return { started: false } + throw error } if (!result.success) { rollback() console.error('[ChatStore] Failed to continue the answer:', result.error) - // The caller owns the wording (#179): the store does not import i18n, so the - // reason is handed back rather than translated here. + // The continuation bound has dedicated UI wording; other failures preserve + // the main process's actual error for the caller to display. + if (result.reason !== 'continuation-limit') { + throw new Error(result.error || 'Failed to continue the answer') + } return { started: false, reason: result.reason } } diff --git a/test/onboardingGenerationFeedback.test.ts b/test/onboardingGenerationFeedback.test.ts new file mode 100644 index 0000000..c4c293b --- /dev/null +++ b/test/onboardingGenerationFeedback.test.ts @@ -0,0 +1,51 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const read = (path: string): string => readFileSync(`src/renderer/src/${path}`, 'utf8') + +test('background card generation surfaces the actual error to the user', () => { + const source = read('components/notebook/anki/AnkiConfigDialog.tsx') + assert.match(source, /toast\.error\(/) + assert.match(source, /error instanceof Error \? error\.message : String\(error\)/) + for (const locale of ['zh-CN', 'en-US']) { + const messages = JSON.parse(read(`locales/${locale}/anki.json`)) + assert.ok(messages.generateFailed.includes('{{error}}')) + } +}) + +test('quiz and mind map background failures are caught and surfaced', () => { + const source = read('components/notebook/NotePanel.tsx') + assert.equal((source.match(/if \(!result.success\) throw new Error/g) || []).length, 2) + assert.equal((source.match(/\.catch\(\(error\) =>/g) || []).length, 2) + assert.ok(source.includes("t('ui:generationFailed'")) +}) + +test('question entry points and recovery actions surface rejected calls', () => { + for (const path of [ + 'components/notebook/ProcessPanel.tsx', + 'components/notebook/mindmap/NodeDetailPanel.tsx', + 'components/notebook/chat/MessageItem.tsx' + ]) { + const source = read(path) + assert.match(source, /toast\.error\(/) + assert.match(source, /error instanceof Error \? error.message : String\(error\)/) + } + const store = read('store/chatStore.ts') + assert.ok(store.includes("throw new Error(result.error || 'Failed to retry the answer')")) + assert.ok(store.includes("throw new Error(result.error || 'Failed to continue the answer')")) +}) + +test('onboarding offers embedding and chat setup with an explicit skip path', () => { + const source = read('components/pages/OnboardingPage.tsx') + assert.match(source, /capability=\{step === 1 \? 'embedding' : 'chat'\}/) + assert.match(source, /handleNext\(true\)/) + assert.match(source, /else if \(!skip && connections\)/) + assert.match(source, /role="alert"/) + for (const locale of ['zh-CN', 'en-US']) { + const messages = JSON.parse(read(`locales/${locale}/ui.json`)) + for (const key of ['onboardingEmbeddingHint', 'onboardingChatHint', 'onboardingSkip']) { + assert.ok(messages[key]) + } + } +})