From 3039b24952d49ca7ecf23d470a146618755ad833 Mon Sep 17 00:00:00 2001 From: mrsibe Date: Fri, 25 Sep 2026 03:24:32 +0800 Subject: [PATCH] feat(chat): record and show what each answer was built from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differentiator the product record names — a citation — had nowhere to live because the retrieval step threw its own identity away. `buildRAGContext` accepted `{ documentTitle, content, score }` and nothing else, so `documentId`, `chunkId` and `chunkIndex` were discarded between search and prompt. The answer arrived looking identical whether it came from the reader's documents or from nowhere. **Retrieval now leaves a record.** `SearchResult` already carried all of it; the prompt builder returns the sources alongside the context string (prompt text unchanged, so answer behaviour is unchanged), and they are persisted on the assistant message's `metadata` — a JSON column that already existed and was unused, so no migration. The retrieval outcome is recorded too: `used`, `none` or `failed`. Today a failed search is a log line and the model is called with no context at all, which is indistinguishable from a sourced answer. **Each answer shows its evidence.** `AnswerSources` renders a disclosure — "Based on N source passages" → the passages **quoted verbatim**, each with its document title and an in-app "Show in library" action. Three states, deliberately not collapsed into one: `used` (the passages), `none` (a T3 line saying nothing from your sources was used) and `failed` (a T3 line saying the search broke). A message with no recorded status renders nothing — an older message is *unknown*, and calling it ungrounded would accuse a grounded answer. The click travels through `uiStore` to `SourcePanel`, which derives the open document **during render** rather than consuming the request in an effect. Two shapes were tried and rejected: `setState` in an effect fails `react-hooks/set-state-in-effect` and cascades renders, and clearing the request from render writes to a store mid-render. The clear belongs in the event handlers. **Typed and parsed defensively.** `ChatMessageMetadata` replaces a `Record`, and `shared/utils/answerSources.ts` is the only reader. Malformed entries are dropped individually — two usable passages out of three still show two — and "not recorded" is kept distinct from "none". **One conversation per notebook.** The maintainer confirmed a notebook holds one growing conversation. Two things were needed to make that true in the interface: the composer no longer says "select a session first" (that state was a load transient pointing at a picker that does not exist) and the textarea is no longer disabled while `currentSession` is loading. Also: three locale files carried dead English-only copy for surfaces that do not exist (an editor menubar, a notes/trash/tags feature) — 84 keys total, unreachable and making the app read as half-translated. Removed, so all eight namespaces are in parity. Plural forms are the one intentional difference and are kept explicitly: `t('ankiCards', { count })` never renders the literal `ankiCards_one`, so a string-matching prune would have deleted a live translation. Deliberately not here, with reasons in PRODUCT.md and DESIGN.md: page/span-accurate citations need #82 (chunk offsets are computed against a preprocessed string, not `documents.content`, so a span citation would be a false claim), and model-emitted inline `[cite:id]` markers — the contract both Cherry Studio and Open Notebook use — need a system-prompt change and real model testing that this environment cannot provide. What landed is the half both of those implementations also have: the retrieved set is persisted, visible and inspectable. --- DESIGN.md | 84 ++++++++++++ PRODUCT.md | 21 +++ src/main/db/queries.ts | 13 ++ src/main/ipc/chatHandlers.ts | 66 ++++++--- .../src/components/notebook/ProcessPanel.tsx | 10 +- .../src/components/notebook/SourcePanel.tsx | 29 ++-- .../notebook/chat/AnswerSources.tsx | 91 +++++++++++++ .../components/notebook/chat/MessageItem.tsx | 19 +++ src/renderer/src/locales/en-US/chat.json | 30 +--- src/renderer/src/locales/en-US/common.json | 26 ---- src/renderer/src/locales/en-US/notebook.json | 36 ----- src/renderer/src/locales/zh-CN/chat.json | 7 +- src/renderer/src/store/uiStore.ts | 16 ++- src/shared/types/chat.ts | 52 ++++++- src/shared/utils/answerSources.ts | 82 +++++++++++ test/answerSources.test.ts | 128 ++++++++++++++++++ 16 files changed, 586 insertions(+), 124 deletions(-) create mode 100644 src/renderer/src/components/notebook/chat/AnswerSources.tsx create mode 100644 src/shared/utils/answerSources.ts create mode 100644 test/answerSources.test.ts diff --git a/DESIGN.md b/DESIGN.md index 586e176..b392b86 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -216,6 +216,73 @@ Two rules for the composer and for leaving the workspace: through. The in-panel Back button is not special-cased — it uses the same state. A navigation path that skips this guard is a bug. +### An answer shows what it was built from + +Implementation: `components/notebook/chat/AnswerSources.tsx`, fed by +`chat_messages.metadata` (see [message metadata](#message-metadata)). + +Retrieval already knew which passages it used and threw the identity away: the +prompt kept a title and some text, and `chunkId` / `documentId` were dropped. The +answer therefore looked identical whether it came from the reader's documents or +from nowhere, which is the one distinction this product cannot afford to lose. + +The region has three states, and they are three different statements — do not +collapse them: + +| State | Shown | +| ---------------- | ------------------------------------------------------------- | +| `used` | A disclosure: "Based on N source passages" → the passages | +| `none` | One T3 line: nothing from your sources was used | +| `failed` | One T3 line: searching your sources failed | +| no status at all | **Nothing** — an older message is "unknown", not "ungrounded" | + +Rules: + +- **The passage is quoted verbatim**, never summarised or truncated into a + paraphrase. It is the evidence; if it is too long to read, it is still too long + to invent. +- Each passage sits in `bg-muted` — a recessed container inside the panel, per the + surface rules. The region is **not** a `Card`: cards inside panels nest, and this + belongs to the message, not beside it. +- Density is the disclosure's job. The collapsed state is one line; the passages + are behind it. +- Only after the turn ends. Rendering an empty evidence list mid-answer would read + as "nothing was used". +- The ungrounded and failed lines are T3: they must be readable, and they must not + compete with the answer. + +### Message metadata + +The structured part of `chat_messages.metadata` is typed (`ChatMessageMetadata` +in `shared/types/chat.ts`) and read **only** through +`shared/utils/answerSources.ts`. Two rules: + +- **Parse defensively, drop per entry.** The column is an open JSON bag written by + whichever version of the app produced the message, so a reader never assumes a + shape. One malformed entry must not hide the passages that did survive: two + usable sources out of three still show two. +- **"Not recorded" is not "none".** An answer from before this existed has no + status; saying it was ungrounded would accuse a grounded answer. Unknown + renders nothing. + +### Cross-panel requests + +The transcript (centre) and the library (left) are siblings, so a citation click +cannot pass a prop. It writes to `uiStore` and `SourcePanel` derives from it **during +render** — never in an effect: + +```tsx +const openDocument = selectedDocument ?? focusedDocument +``` + +Two things this avoids, both of which were tried and rejected: + +- `setState` inside an effect to consume the request: the lint rule + `react-hooks/set-state-in-effect` fails the build, and it cascades renders. +- Clearing the store request from inside render: writing to a store during render + notifies other components mid-render. The clear happens in the event handlers + instead (list click, Back), which is why the derivation needs no cleanup step. + ### Geometry lives in a module, and it is tested The arithmetic above — `availableFrom`, `maxSideWidth`, the golden-ratio split, the @@ -638,3 +705,20 @@ runs in the `Verify` workflow, before the build matrix. What it does **not** enforce, and therefore relies on review: the surface ladder (which surface a panel uses), accent discipline, spacing and density, and the four interaction states. Those are the rules a reviewer must hold the line on. + +## Locales + +`en-US` and `zh-CN` move together (CONTRIBUTING.md), and the check is **parity**: +neither file may carry a key the other lacks. + +**One exception: plural forms.** English declares `key_one` / `key_other` and +Chinese declares `key` alone with no suffix, which is the correct i18next shape and +not a missing translation. This matters when pruning: a literal search for a key +misses plural forms entirely, because `t('ankiCards', { count })` never renders the +string `ankiCards_one` in source. Prune by hand for those, or keep them +unconditionally. + +Two prunes have already found the same artefact: each locale file carried a block +of English-only keys for surfaces that do not exist (an editor menubar, a notes / +trash / tags feature). They were unreachable, and they made the app read as +half-translated. Delete dead copy rather than translating it. diff --git a/PRODUCT.md b/PRODUCT.md index 639ce98..d332bf8 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -113,6 +113,24 @@ Confirmed by the README and the code: `Verify` workflow is the gate; `en-US` and `zh-CN` locales move together (CONTRIBUTING.md). +- **A notebook holds one growing conversation.** Confirmed by the maintainer. There + is no conversation picker and no "new chat": opening a notebook lands you in its + ongoing thread, and the store already creates the session on demand. When that + thread approaches the model's context budget, `SessionAutoSwitchService` + summarises it, archives the old row and continues in a new one — a rollover, not + a second conversation the user has to manage. Because the user experiences one + thread, a rollover must never be silent. +- **Every answer records the passages it was built from.** Retrieval already knows + the `documentId`, `chunkId` and passage text; these are now persisted on the + assistant message and rendered as quotable evidence, with the retrieval outcome + recorded too (`used` / `none` / `failed`). What this does **not** yet provide is a + page or character span: the indexing path computes chunk offsets against a + preprocessed string rather than `documents.content`, and `parseResult.structure` + is still discarded, so a span-accurate citation would be a false claim until #82 + lands. Inline per-sentence markers (the `[cite:id]` contract Cherry Studio and + Open Notebook both use) also need a system-prompt change and real model testing, + so they are deliberately not in place yet. + Undecided, recorded rather than resolved: - The README says quiz generation, audio transcription and slide generation are @@ -160,6 +178,9 @@ needed, ask for it. 1. **Provenance before fluency.** An answer without a resolvable source location is an unverified claim. The citation is the product, not the chat bubble. + Today the answer carries the passages it used, and that is the first half of + this principle. It becomes the whole of it when a citation resolves to a page + and a span rather than to a passage's text (#82). 2. **Local is the default, not a mode.** Retrieval has to work with the network off, and the only outbound request is the endpoint the user configured. 3. **Say what is not there.** A claim ahead of the code is a bug, and the README diff --git a/src/main/db/queries.ts b/src/main/db/queries.ts index 9e18d3e..4a02736 100644 --- a/src/main/db/queries.ts +++ b/src/main/db/queries.ts @@ -1,6 +1,7 @@ import { eq, desc, and } from 'drizzle-orm' import { getDatabase, executeCheckpoint, dropNotebookVectorTable } from './index' import { chatSessions, chatMessages, notebooks, notes, documents, items } from './schema' +import type { ChatMessageMetadata } from '../../shared/types/chat' // ==================== Chat Sessions ==================== @@ -205,6 +206,18 @@ export function updateMessageContent( db.update(chatMessages).set(updateData).where(eq(chatMessages.id, messageId)).run() } +/** + * 更新消息的结构化元数据。 + * + * 目前唯一的用途是把「这条回答基于哪些段落」写到助手消息上, + * 这样回答交付之后仍然可以回到原文(见 shared/types/chat.ts 的 AnswerSource)。 + */ +export function updateMessageMetadata(messageId: string, metadata: ChatMessageMetadata) { + const db = getDatabase() + + db.update(chatMessages).set({ metadata }).where(eq(chatMessages.id, messageId)).run() +} + // ==================== Notebooks ==================== /** diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 97578bc..3563037 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -2,35 +2,50 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' import * as queries from '../db/queries' import { ConnectionManager } from '../models/ConnectionManager' import { SessionAutoSwitchService } from '../services/SessionAutoSwitchService' -import { KnowledgeService } from '../services/KnowledgeService' +import { KnowledgeService, type SearchResult } from '../services/KnowledgeService' import { validateAndCleanMessages } from '../utils/messageValidator' import Logger from '../../shared/utils/logger' +import type { AnswerSource, RetrievalStatus } from '../../shared/types/chat' import { ChatSchemas, validate } from './validation' // 管理活跃的流式请求 const activeStreams = new Map() /** - * 构建 RAG 上下文 prompt + * 构建 RAG 上下文 prompt,并把「这段回答基于哪些段落」一起交出来。 + * + * 之前这里只取 `documentTitle` / `content` / `score` 三个字段, + * `chunkId`、`documentId`、`chunkIndex` 全部被丢掉 —— 于是回答交付之后, + * 界面上再也没有回到原文的路。prompt 文本保持不变,这里只是不再丢弃身份。 */ -function buildRAGContext( - searchResults: Array<{ - documentTitle: string - content: string - score: number - }> -): string { - if (searchResults.length === 0) return '' - - const contextParts = searchResults.map((result, index) => { - return `[来源 ${index + 1}: ${result.documentTitle}]\n${result.content}` - }) +function buildRAGContext(searchResults: SearchResult[]): { + context: string + sources: AnswerSource[] +} { + if (searchResults.length === 0) return { context: '', sources: [] } + + const sources: AnswerSource[] = searchResults.map((result, index) => ({ + index: index + 1, + documentId: result.documentId, + documentTitle: result.documentTitle, + documentType: result.documentType, + chunkId: result.chunkId, + chunkIndex: result.chunkIndex, + content: result.content, + score: result.score + })) + + const contextParts = sources.map( + (source) => `[来源 ${source.index}: ${source.documentTitle}]\n${source.content}` + ) - return `以下是与用户问题相关的背景知识,请参考这些信息来回答: + const context = `以下是与用户问题相关的背景知识,请参考这些信息来回答: ${contextParts.join('\n\n---\n\n')} 请基于以上背景知识回答用户的问题。如果背景知识不足以回答问题,请说明并尽力提供有帮助的回答。` + + return { context, sources } } /** @@ -125,7 +140,11 @@ export function registerChatHandlers( } // 3.2 RAG 增强:检索相关知识并注入上下文 - // 只有在配置了 embedding connection 时才启用 RAG + // 只有在配置了 embedding connection 时才启用 RAG。 + // 检索结果同时记录到消息上:以前检索失败只留一行日志, + // 于是「没有依据的回答」和「有依据的回答」在界面上完全无法区分。 + let retrieval: RetrievalStatus = 'none' + let answerSources: AnswerSource[] = [] try { const embeddingClient = await connectionManager.getEmbeddingClient() @@ -138,7 +157,9 @@ export function registerChatHandlers( }) if (searchResults.length > 0) { - const ragContext = buildRAGContext(searchResults) + const { context, sources } = buildRAGContext(searchResults) + retrieval = 'used' + answerSources = sources Logger.debug( 'ChatHandlers', `RAG: Found ${searchResults.length} relevant chunks for query` @@ -147,7 +168,7 @@ export function registerChatHandlers( // 将 RAG 上下文作为 system message 插入到消息列表开头 messages.unshift({ role: 'system', - content: ragContext + content: context }) } } @@ -155,10 +176,17 @@ export function registerChatHandlers( Logger.debug('ChatHandlers', 'RAG disabled: No embedding model configured') } } catch (error) { - // RAG 失败不应该阻止对话,只记录警告 + // RAG 失败不应该阻止对话 + retrieval = 'failed' Logger.warn('ChatHandlers', 'RAG search failed:', error) } + queries.updateMessageMetadata(assistantMessage.id, { + ...(assistantMessage.metadata ?? {}), + retrieval, + sources: answerSources + }) + // 4. 调用 Model Connection 流式生成 const client = await connectionManager.getChatClient() if (!client) { diff --git a/src/renderer/src/components/notebook/ProcessPanel.tsx b/src/renderer/src/components/notebook/ProcessPanel.tsx index 788e153..e0433a4 100644 --- a/src/renderer/src/components/notebook/ProcessPanel.tsx +++ b/src/renderer/src/components/notebook/ProcessPanel.tsx @@ -312,14 +312,8 @@ function ProcessPanel({ value={input} onChange={handleInputChange} onKeyDown={handleKeyDown} - placeholder={ - !currentSession - ? t('selectSession') - : !hasChatModel - ? t('noProviderConfigured') - : t('inputMessage') - } - disabled={!currentSession || !hasChatModel} + placeholder={!hasChatModel ? t('noProviderConfigured') : t('inputMessage')} + disabled={!hasChatModel} rows={1} className="w-full bg-transparent border-0 pl-4 pr-14 py-3 text-sm text-foreground placeholder-muted-foreground resize-none focus-visible:ring-0 focus-visible:ring-offset-0 overflow-y-auto min-h-[84px] max-h-[280px] themed-scrollbar select-text" /> diff --git a/src/renderer/src/components/notebook/SourcePanel.tsx b/src/renderer/src/components/notebook/SourcePanel.tsx index 8daae92..4ab2c91 100644 --- a/src/renderer/src/components/notebook/SourcePanel.tsx +++ b/src/renderer/src/components/notebook/SourcePanel.tsx @@ -304,6 +304,20 @@ export default function SourcePanel(): ReactElement { setSelectedDocument(null) } + // 对话里的来源被点击时,在这里把对应文档打开。 + // 右栏的对话和左栏的知识库是兄弟节点,所以这个请求走 uiStore 而不是 props。 + // + // 纯派生,不用 effect:在 effect 里同步 setState 会引发级联渲染(本文件上面 + // 那个 notebook 切换的复位用的是同一套“render 期间运算”的思路),而在 render + // 里写 store 会更糟。列表里的点击会把 store 里这个请求清掉,所以两者不会打架。 + const focusedSourceDocumentId = useUIStore((state) => state.focusedSourceDocumentId) + const focusSourceDocument = useUIStore((state) => state.focusSourceDocument) + + const focusedDocument = focusedSourceDocumentId + ? (documents.find((doc) => doc.id === focusedSourceDocumentId) ?? null) + : null + const openDocument = selectedDocument ?? focusedDocument + // 处理文件上传 const handleFileUpload = useCallback(async () => { if (!notebookId) return @@ -419,18 +433,21 @@ export default function SourcePanel(): ReactElement { // 文本和笔记类型可以预览,直接显示预览页面 if (document.type === 'text' || document.type === 'note') { setSelectedDocument(document) + // 列表里的选择优先,清掉对话那边可能还挂着的请求 + focusSourceDocument(null) } else { // 其他类型(文件、URL)直接打开 handleOpenSource(document.id) } }, - [handleOpenSource] + [handleOpenSource, focusSourceDocument] ) // 返回列表 const handleBack = useCallback(() => { setSelectedDocument(null) - }, []) + focusSourceDocument(null) + }, [focusSourceDocument]) // 处理弹窗提交 const handleModalSubmit = useCallback( @@ -448,13 +465,9 @@ export default function SourcePanel(): ReactElement { return ( - {selectedDocument ? ( + {openDocument ? ( // 文档预览页面 - + ) : ( // 文档列表页面 <> diff --git a/src/renderer/src/components/notebook/chat/AnswerSources.tsx b/src/renderer/src/components/notebook/chat/AnswerSources.tsx new file mode 100644 index 0000000..ffda863 --- /dev/null +++ b/src/renderer/src/components/notebook/chat/AnswerSources.tsx @@ -0,0 +1,91 @@ +import { ReactElement, useState } from 'react' +import { ChevronDown, ChevronRight, FileText } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { AnswerSource, RetrievalStatus } from '../../../../../shared/types/chat' +import { Button } from '../../ui/button' + +interface AnswerSourcesProps { + sources: AnswerSource[] + /** `null` for a message written before this was recorded — claim nothing. */ + retrieval: RetrievalStatus | null + onShowDocument: (documentId: string) => void +} + +/** + * What an answer was built from. + * + * The retrieval step always knew this and used to throw it away: the prompt kept + * a title and some text, and `chunkId` / `documentId` were dropped on the floor. + * So the answer arrived looking identical whether it came from the reader's own + * documents or from nowhere, which is the one thing this product cannot afford. + * + * Three states, and they are not the same statement: + * + * - `used` — these passages, quotable, with a way back to the document. + * - `none` — nothing in the notebook matched; the answer is the model's own. + * - `failed` — the search itself broke, so the answer was never grounded. + * + * A message with no recorded status renders nothing at all: an older message is + * "unknown", not "ungrounded". + */ +export default function AnswerSources({ + sources, + retrieval, + onShowDocument +}: AnswerSourcesProps): ReactElement | null { + const { t } = useTranslation('chat') + const [isOpen, setIsOpen] = useState(false) + + if (retrieval === null) return null + + if (retrieval === 'failed') { + return

{t('answerRetrievalFailed')}

+ } + + if (sources.length === 0) { + return

{t('answerNotGrounded')}

+ } + + return ( +
+ + + {isOpen && ( +
    + {sources.map((source) => ( +
  • +
    + + + {source.documentTitle} + + +
    + {/* The passage as it was retrieved, quoted rather than summarised: + this is the evidence, so it is shown verbatim. */} +

    + {source.content} +

    +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/notebook/chat/MessageItem.tsx b/src/renderer/src/components/notebook/chat/MessageItem.tsx index 0cf57be..c3fef4a 100644 --- a/src/renderer/src/components/notebook/chat/MessageItem.tsx +++ b/src/renderer/src/components/notebook/chat/MessageItem.tsx @@ -8,8 +8,11 @@ import { BookPlus } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { ChatMessage } from '../../../types/notebook' import ReasoningContent from './ReasoningContent' +import AnswerSources from './AnswerSources' import { useItemStore } from '../../../store/itemStore' import { useNotebookStore } from '../../../store/notebookStore' +import { useUIStore } from '../../../store/uiStore' +import { parseRetrievalStatus, sourcesForDisplay } from '../../../../../shared/utils/answerSources' import { Button } from '../../ui/button' import { ScrollArea, ScrollBar } from '../../ui/scroll-area' import 'highlight.js/styles/github-dark.css' @@ -30,6 +33,12 @@ export default function MessageItem({ message }: MessageItemProps): ReactElement const { createNote } = useItemStore() const { currentNotebook } = useNotebookStore() + const focusSourceDocument = useUIStore((state) => state.focusSourceDocument) + + // What this answer was built from. Read defensively: the metadata comes from the + // database and may predate the shape (see shared/utils/answerSources.ts). + const answerSources = sourcesForDisplay(message.metadata) + const retrieval = parseRetrievalStatus(message.metadata) // Copy message content const handleCopy = async () => { @@ -186,6 +195,16 @@ export default function MessageItem({ message }: MessageItemProps): ReactElement ) )} + {/* What the answer was built from. Only after the turn ends: during + streaming there is nothing to show yet, and an empty evidence list + mid-answer would read as "nothing was used". */} + {message.content && !isStreaming && ( + + )} {/* Action buttons - only shown when reply is complete and has content */} {message.content && !isStreaming && (
diff --git a/src/renderer/src/locales/en-US/chat.json b/src/renderer/src/locales/en-US/chat.json index 6a88387..de11ace 100644 --- a/src/renderer/src/locales/en-US/chat.json +++ b/src/renderer/src/locales/en-US/chat.json @@ -1,33 +1,15 @@ { - "newChat": "Start New Chat", - "inputPlaceholder": "Type your message in the input box below to start chatting", "thinking": "Thinking", "thinkingInProgress": "Thinking in progress...", "thinkingProcess": "Thinking Process", "thinkingShort": "Thinking", - "addToNotes": "Add to Notes", - "copyMessage": "Copy Message", - "regenerateResponse": "Regenerate Response", - "deleteMessage": "Delete Message", - "editMessage": "Edit Message", - "sendMessage": "Send Message", "stopGenerating": "Stop Generating", - "chatHistory": "Chat History", - "clearHistory": "Clear History", - "confirmClearHistory": "Are you sure you want to clear all chat history?", - "noMessages": "No messages yet", - "typing": "Typing...", - "aiResponse": "AI Response", - "userMessage": "User Message", - "model": "Model", - "maxTokens": "Max Tokens", - "temperature": "Temperature", - "topP": "Top P", - "frequencyPenalty": "Frequency Penalty", - "presencePenalty": "Presence Penalty", - "systemPrompt": "System Prompt", - "advancedSettings": "Advanced Settings", "addToNote": "Add to Note", "addedToNote": "Added to Note", - "added": "Added" + "added": "Added", + "sourcesUsed": "Based on {{count}} source passages", + "hideSources": "Hide sources", + "showDocument": "Show in library", + "answerNotGrounded": "No passages from your sources were used in this answer.", + "answerRetrievalFailed": "Searching your sources failed, so this answer is not based on them." } diff --git a/src/renderer/src/locales/en-US/common.json b/src/renderer/src/locales/en-US/common.json index 699e7fd..dc6bf9c 100644 --- a/src/renderer/src/locales/en-US/common.json +++ b/src/renderer/src/locales/en-US/common.json @@ -4,39 +4,13 @@ "cancel": "Cancel", "copy": "Copy", "delete": "Delete", - "rename": "Rename", "copied": "Copied", - "added": "Added", "save": "Save", "edit": "Edit", - "close": "Close", - "back": "Back", - "next": "Next", - "previous": "Previous", "loading": "Loading...", - "search": "Search", - "clear": "Clear", - "select": "Select", - "all": "All", - "none": "None", - "yes": "Yes", - "no": "No", - "ok": "OK", - "retry": "Retry", - "refresh": "Refresh", - "download": "Download", - "upload": "Upload", - "export": "Export", - "import": "Import", - "settings": "Settings", - "help": "Help", - "about": "About", - "version": "Version", "error": "Error", "errorTitle": "Something went wrong", "errorDescription": "An unexpected error occurred. Reload to continue.", "reload": "Reload", - "warning": "Warning", - "info": "Info", "success": "Success" } diff --git a/src/renderer/src/locales/en-US/notebook.json b/src/renderer/src/locales/en-US/notebook.json index 1c55617..07690be 100644 --- a/src/renderer/src/locales/en-US/notebook.json +++ b/src/renderer/src/locales/en-US/notebook.json @@ -1,50 +1,15 @@ { - "notebook": "Notebook", - "notebooks": "Notebooks", - "createNotebook": "Create Notebook", "notebookName": "Notebook Name", - "notebookNamePlaceholder": "Enter notebook name", "deleteNotebook": "Delete Notebook", - "confirmDeleteNotebook": "Are you sure you want to delete this notebook? This action cannot be undone.", "renameNotebook": "Rename Notebook", - "note": "Note", "notes": "Notes", "createNote": "Create Note", "noteTitle": "Note Title", "noteTitlePlaceholder": "Enter note title", - "noteContent": "Note Content", - "noteContentPlaceholder": "Start typing your note content...", "deleteNote": "Delete Note", "deleteNoteWarning": "Are you sure you want to delete this note? This action cannot be undone.", "confirmDeleteNote": "Are you sure you want to delete this note? This action cannot be undone.", "renameNote": "Rename Note", - "searchNotes": "Search Notes", - "searchPlaceholder": "Search note titles or content...", - "noNotesFound": "No notes found", - "noNotebooks": "No notebooks yet", - "createFirstNotebook": "Create your first notebook", - "lastModified": "Last Modified", - "created": "Created", - "wordCount": "Word Count", - "readingTime": "Reading Time", - "exportNote": "Export Note", - "exportNotebook": "Export Notebook", - "importNotes": "Import Notes", - "tag": "Tag", - "tags": "Tags", - "addTag": "Add Tag", - "removeTag": "Remove Tag", - "noTags": "No tags", - "pinNote": "Pin Note", - "unpinNote": "Unpin Note", - "archived": "Archived", - "archiveNote": "Archive Note", - "unarchiveNote": "Unarchive Note", - "trash": "Trash", - "moveToTrash": "Move to Trash", - "restoreFromTrash": "Restore from Trash", - "emptyTrash": "Empty Trash", - "confirmEmptyTrash": "Are you sure you want to empty the trash? This action cannot be undone.", "enterNotebookName": "Enter notebook name", "deleteConfirm": "Are you sure you want to delete notebook \"{{name}}\"?\nThis action cannot be undone.", "newNote": "New Note", @@ -55,7 +20,6 @@ "noteSaved": "Saved", "noNotesYet": "No notes yet", "noNotesYetDesc": "Click the \"+\" button in the top right to create a note", - "deleteNote": "Delete Note", "unsavedChangesWarning": "You have unsaved changes. Are you sure you want to leave?", "unsavedChangesTitle": "Unsaved Changes", "unsavedChangesMessage": "You have unsaved changes. Are you sure you want to switch to another Notebook?", diff --git a/src/renderer/src/locales/zh-CN/chat.json b/src/renderer/src/locales/zh-CN/chat.json index bf0079d..e96f8da 100644 --- a/src/renderer/src/locales/zh-CN/chat.json +++ b/src/renderer/src/locales/zh-CN/chat.json @@ -6,5 +6,10 @@ "thinkingShort": "思考中", "addToNote": "添加到笔记", "addedToNote": "已添加到笔记", - "added": "已添加" + "added": "已添加", + "sourcesUsed": "基于 {{count}} 段来源原文", + "hideSources": "隐藏来源", + "showDocument": "在知识库中查看", + "answerNotGrounded": "这条回答没有使用你来源中的任何段落。", + "answerRetrievalFailed": "检索来源失败,这条回答没有依据你的来源。" } diff --git a/src/renderer/src/store/uiStore.ts b/src/renderer/src/store/uiStore.ts index 00e59e4..3939e18 100644 --- a/src/renderer/src/store/uiStore.ts +++ b/src/renderer/src/store/uiStore.ts @@ -15,6 +15,17 @@ interface UIStore { */ hasUnsavedNoteChanges: boolean setHasUnsavedNoteChanges: (dirty: boolean) => void + + /** + * A document the reader asked to see, set when a source in the transcript is + * clicked. The transcript (centre panel) and the library (left panel) are + * siblings, so the request travels through the store rather than through props. + * + * `SourcePanel` consumes it once the document is in its list, so returning to + * the library later does not re-open it. + */ + focusedSourceDocumentId: string | null + focusSourceDocument: (documentId: string | null) => void } export const useUIStore = create()((set) => ({ @@ -23,5 +34,8 @@ export const useUIStore = create()((set) => ({ closeSettings: () => set({ isSettingsOpen: false }), hasUnsavedNoteChanges: false, - setHasUnsavedNoteChanges: (dirty) => set({ hasUnsavedNoteChanges: dirty }) + setHasUnsavedNoteChanges: (dirty) => set({ hasUnsavedNoteChanges: dirty }), + + focusedSourceDocumentId: null, + focusSourceDocument: (documentId) => set({ focusedSourceDocumentId: documentId }) })) diff --git a/src/shared/types/chat.ts b/src/shared/types/chat.ts index a91007c..8f64005 100644 --- a/src/shared/types/chat.ts +++ b/src/shared/types/chat.ts @@ -16,6 +16,56 @@ import type { */ export type ChatSession = DBChatSession +/** + * One passage an answer was built from. + * + * The retrieval step already knows all of this and used to discard everything + * except the title and the text: `buildRAGContext` accepted only + * `{ documentTitle, content, score }`, so by the time an answer existed there was + * no way back to the document it came from. + * + * `content` is the passage **as retrieved**, stored rather than re-derived. That + * is deliberate: re-chunking or re-embedding the notebook must not invalidate the + * quote that is already shown in a delivered answer. The stored text is the + * evidence; it is not an id into a table that a re-index would rewrite. + */ +export interface AnswerSource { + /** 1-based position in the prompt, so a marker in the answer can resolve. */ + index: number + documentId: string + documentTitle: string + documentType?: string + chunkId: string + chunkIndex?: number + /** The passage text as retrieved. */ + content: string + score?: number +} + +/** + * Whether retrieval contributed to an answer. + * + * This exists so an ungrounded answer cannot be mistaken for a sourced one. Today + * a failed search is a log line: the model is called with no context and the + * answer arrives looking exactly like a sourced one. + */ +export type RetrievalStatus = 'used' | 'none' | 'failed' + +/** + * The structured part of `chat_messages.metadata`. + * + * The column is an open JSON bag, so the index signature is honest rather than a + * workaround: anything else a future feature stores here survives a round-trip. + * Everything a reader depends on is parsed defensively — see + * `shared/utils/answerSources.ts` — because the contents come from the database + * and may predate this shape. + */ +export interface ChatMessageMetadata { + sources?: AnswerSource[] + retrieval?: RetrievalStatus + [key: string]: unknown +} + /** * 聊天消息接口(完整版) * 基于 Drizzle 推导的数据库类型,并添加前端扩展字段 @@ -23,7 +73,7 @@ export type ChatSession = DBChatSession export interface ChatMessage extends Omit { notebookId?: string // 前端扩展字段,用于并发消息管理 reasoningContent?: string | null // 可选的推理内容字段 - metadata?: Record + metadata?: ChatMessageMetadata isStreaming?: boolean // 前端扩展字段,标识流式消息 isReasoningStreaming?: boolean // 前端扩展字段,推理过程是否在流式传输 } diff --git a/src/shared/utils/answerSources.ts b/src/shared/utils/answerSources.ts new file mode 100644 index 0000000..1b6305c --- /dev/null +++ b/src/shared/utils/answerSources.ts @@ -0,0 +1,82 @@ +import type { AnswerSource, ChatMessageMetadata, RetrievalStatus } from '../types/chat' + +/** + * Readers for `chat_messages.metadata`. + * + * The metadata column is an open JSON bag written by whichever version of the app + * produced the message, so a reader can never assume the shape. These parsers are + * the only place that decides what is usable, so the UI can render a message from + * a year ago without a guard in every component. + * + * Malformed entries are **dropped individually**, not treated as a reason to + * discard the whole set: an answer with two usable sources out of three should + * still show the two. + */ + +const RETRIEVAL_STATUSES: readonly RetrievalStatus[] = ['used', 'none', 'failed'] + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 + +const toFiniteNumber = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined + +const parseSource = (value: unknown): AnswerSource | null => { + if (!value || typeof value !== 'object') return null + const candidate = value as Record + + // The four fields a source cannot be rendered without. `chunkIndex`, `score` + // and `documentType` stay optional so an older writer is still readable. + if (!isNonEmptyString(candidate.documentId)) return null + if (!isNonEmptyString(candidate.documentTitle)) return null + if (!isNonEmptyString(candidate.chunkId)) return null + if (!isNonEmptyString(candidate.content)) return null + + const index = toFiniteNumber(candidate.index) + + return { + index: index ?? 0, + documentId: candidate.documentId, + documentTitle: candidate.documentTitle, + documentType: isNonEmptyString(candidate.documentType) ? candidate.documentType : undefined, + chunkId: candidate.chunkId, + chunkIndex: toFiniteNumber(candidate.chunkIndex), + content: candidate.content, + score: toFiniteNumber(candidate.score) + } +} + +/** Every usable source on a message, in the order the prompt presented them. */ +export const parseAnswerSources = (metadata: unknown): AnswerSource[] => { + if (!metadata || typeof metadata !== 'object') return [] + const raw = (metadata as ChatMessageMetadata).sources + if (!Array.isArray(raw)) return [] + + return raw + .map(parseSource) + .filter((source): source is AnswerSource => source !== null) + .sort((a, b) => a.index - b.index) +} + +/** + * How retrieval went for this answer, or `null` when the message predates the + * field. `null` is not `'none'`: "we did not record it" is a different statement + * from "nothing was retrieved", and only the second one is about the answer. + */ +export const parseRetrievalStatus = (metadata: unknown): RetrievalStatus | null => { + if (!metadata || typeof metadata !== 'object') return null + const raw = (metadata as ChatMessageMetadata).retrieval + return RETRIEVAL_STATUSES.includes(raw as RetrievalStatus) ? (raw as RetrievalStatus) : null +} + +/** + * The passages to show for an answer. + * + * A `failed` search is reported as no sources rather than as a partial set, so the + * UI cannot present a half-complete evidence list as if it were the whole story. + */ +export const sourcesForDisplay = (metadata: unknown): AnswerSource[] => { + const status = parseRetrievalStatus(metadata) + if (status === 'failed') return [] + return parseAnswerSources(metadata) +} diff --git a/test/answerSources.test.ts b/test/answerSources.test.ts new file mode 100644 index 0000000..3ef339f --- /dev/null +++ b/test/answerSources.test.ts @@ -0,0 +1,128 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + parseAnswerSources, + parseRetrievalStatus, + sourcesForDisplay +} from '../src/shared/utils/answerSources.ts' + +/** + * `chat_messages.metadata` is an open JSON bag written by whichever version of the + * app produced the message, so these parsers are the only place that decides what + * is usable. If they are wrong, the transcript either hides real evidence or + * renders a broken one — and both look like a working screen. + */ + +const source = (over: Record = {}) => ({ + index: 1, + documentId: 'doc_1', + documentTitle: 'Beijing guide', + chunkId: 'chunk_1', + content: 'The Forbidden City opens at 08:30.', + ...over +}) + +test('a well-formed source survives the round trip', () => { + const parsed = parseAnswerSources({ sources: [source()] }) + assert.equal(parsed.length, 1) + assert.deepEqual(parsed[0], { + index: 1, + documentId: 'doc_1', + documentTitle: 'Beijing guide', + documentType: undefined, + chunkId: 'chunk_1', + chunkIndex: undefined, + content: 'The Forbidden City opens at 08:30.', + score: undefined + }) +}) + +test('optional fields are kept when present and omitted when not', () => { + const [parsed] = parseAnswerSources({ + sources: [source({ documentType: 'pdf', chunkIndex: 7, score: 0.83 })] + }) + assert.equal(parsed.documentType, 'pdf') + assert.equal(parsed.chunkIndex, 7) + assert.equal(parsed.score, 0.83) +}) + +test('a source missing a field it cannot be rendered without is dropped', () => { + for (const missing of ['documentId', 'documentTitle', 'chunkId', 'content']) { + const broken = source() + delete (broken as Record)[missing] + assert.deepEqual(parseAnswerSources({ sources: [broken] }), [], missing) + } +}) + +test('empty strings count as missing, not as values', () => { + assert.deepEqual(parseAnswerSources({ sources: [source({ content: '' })] }), []) + assert.deepEqual(parseAnswerSources({ sources: [source({ documentTitle: '' })] }), []) +}) + +test('a malformed entry is dropped without discarding its siblings', () => { + // Two usable sources out of three should still show the two: one bad row from an + // older writer is not a reason to hide the evidence that did survive. + const parsed = parseAnswerSources({ + sources: [source({ index: 1 }), { nope: true }, source({ index: 2, chunkId: 'chunk_2' })] + }) + assert.equal(parsed.length, 2) + assert.deepEqual( + parsed.map((s) => s.chunkId), + ['chunk_1', 'chunk_2'] + ) +}) + +test('sources come back ordered by the index the prompt used', () => { + const parsed = parseAnswerSources({ + sources: [ + source({ index: 3, chunkId: 'c3' }), + source({ index: 1, chunkId: 'c1' }), + source({ index: 2, chunkId: 'c2' }) + ] + }) + assert.deepEqual( + parsed.map((s) => s.index), + [1, 2, 3] + ) +}) + +test('a non-numeric index degrades to 0 rather than dropping the source', () => { + const [parsed] = parseAnswerSources({ sources: [source({ index: 'first' })] }) + assert.equal(parsed.index, 0) +}) + +test('metadata that is absent, null or the wrong shape yields no sources', () => { + for (const metadata of [undefined, null, 'text', 42, {}, { sources: null }, { sources: 'x' }]) { + assert.deepEqual(parseAnswerSources(metadata), [], JSON.stringify(metadata)) + } +}) + +test('retrieval status accepts exactly the three recorded values', () => { + assert.equal(parseRetrievalStatus({ retrieval: 'used' }), 'used') + assert.equal(parseRetrievalStatus({ retrieval: 'none' }), 'none') + assert.equal(parseRetrievalStatus({ retrieval: 'failed' }), 'failed') +}) + +test('an unrecorded retrieval status is null, which is not the same as "none"', () => { + // "We did not record it" is a statement about an old message; "none" is a + // statement about the answer. Rendering the first as the second would accuse a + // grounded answer of being ungrounded. + for (const metadata of [undefined, null, {}, { retrieval: 'success' }, { retrieval: 1 }]) { + assert.equal(parseRetrievalStatus(metadata), null, JSON.stringify(metadata)) + } +}) + +test('what to display per status', () => { + const used = { retrieval: 'used', sources: [source()] } + assert.equal(sourcesForDisplay(used).length, 1) + + // A failed search is reported as no evidence, never as a partial list: a + // half-complete evidence set presented as the whole story is worse than none. + assert.deepEqual(sourcesForDisplay({ retrieval: 'failed', sources: [source()] }), []) + assert.deepEqual(sourcesForDisplay({ retrieval: 'none', sources: [] }), []) + + // Unknown status: show whatever sources exist, and let the UI decide (it renders + // nothing when the status is unknown). + assert.equal(sourcesForDisplay({ sources: [source()] }).length, 1) + assert.deepEqual(sourcesForDisplay(undefined), []) +})