Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
21 changes: 21 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/main/db/queries.ts
Original file line number Diff line number Diff line change
@@ -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 ====================

Expand Down Expand Up @@ -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 ====================

/**
Expand Down
66 changes: 47 additions & 19 deletions src/main/ipc/chatHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AbortController>()

/**
* 构建 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 }
}

/**
Expand Down Expand Up @@ -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()

Expand All @@ -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`
Expand All @@ -147,18 +168,25 @@ export function registerChatHandlers(
// 将 RAG 上下文作为 system message 插入到消息列表开头
messages.unshift({
role: 'system',
content: ragContext
content: context
})
}
}
} else {
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) {
Expand Down
10 changes: 2 additions & 8 deletions src/renderer/src/components/notebook/ProcessPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
29 changes: 21 additions & 8 deletions src/renderer/src/components/notebook/SourcePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -448,13 +465,9 @@ export default function SourcePanel(): ReactElement {

return (
<Card className="flex h-full flex-col overflow-hidden">
{selectedDocument ? (
{openDocument ? (
// 文档预览页面
<DocumentViewerPanel
key={selectedDocument.id}
document={selectedDocument}
onBack={handleBack}
/>
<DocumentViewerPanel key={openDocument.id} document={openDocument} onBack={handleBack} />
) : (
// 文档列表页面
<>
Expand Down
Loading
Loading