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
61 changes: 17 additions & 44 deletions src/main/ipc/chatHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,17 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron'
import * as queries from '../db/queries'
import { ConnectionManager } from '../models/ConnectionManager'
import { SessionAutoSwitchService } from '../services/SessionAutoSwitchService'
import { KnowledgeService, type SearchResult } from '../services/KnowledgeService'
import { KnowledgeService } from '../services/KnowledgeService'
import { buildRAGContext } from '../services/citations'
import { validateAndCleanMessages } from '../utils/messageValidator'
import Logger from '../../shared/utils/logger'
import type { AnswerSource, RetrievalStatus } from '../../shared/types/chat'
import type { AnswerSource, ChatMessageMetadata, RetrievalStatus } from '../../shared/types/chat'
import type { Citation } from '../../shared/types/citation'
import { ChatSchemas, validate } from './validation'

// 管理活跃的流式请求
const activeStreams = new Map<string, AbortController>()

/**
* 构建 RAG 上下文 prompt,并把「这段回答基于哪些段落」一起交出来。
*
* 之前这里只取 `documentTitle` / `content` / `score` 三个字段,
* `chunkId`、`documentId`、`chunkIndex` 全部被丢掉 —— 于是回答交付之后,
* 界面上再也没有回到原文的路。prompt 文本保持不变,这里只是不再丢弃身份。
*/
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}`
)

const context = `以下是与用户问题相关的背景知识,请参考这些信息来回答:

${contextParts.join('\n\n---\n\n')}

请基于以上背景知识回答用户的问题。如果背景知识不足以回答问题,请说明并尽力提供有帮助的回答。`

return { context, sources }
}

/**
* Register chat-related IPC Handlers
*/
Expand Down Expand Up @@ -145,6 +110,7 @@ export function registerChatHandlers(
// 于是「没有依据的回答」和「有依据的回答」在界面上完全无法区分。
let retrieval: RetrievalStatus = 'none'
let answerSources: AnswerSource[] = []
let answerCitations: Citation[] = []
try {
const embeddingClient = await connectionManager.getEmbeddingClient()

Expand All @@ -157,9 +123,10 @@ export function registerChatHandlers(
})

if (searchResults.length > 0) {
const { context, sources } = buildRAGContext(searchResults)
const { context, sources, citations } = buildRAGContext(searchResults)
retrieval = 'used'
answerSources = sources
answerCitations = citations
Logger.debug(
'ChatHandlers',
`RAG: Found ${searchResults.length} relevant chunks for query`
Expand All @@ -181,11 +148,13 @@ export function registerChatHandlers(
Logger.warn('ChatHandlers', 'RAG search failed:', error)
}

queries.updateMessageMetadata(assistantMessage.id, {
const answerMetadata: ChatMessageMetadata = {
...(assistantMessage.metadata ?? {}),
retrieval,
sources: answerSources
})
sources: answerSources,
citations: answerCitations
}
queries.updateMessageMetadata(assistantMessage.id, answerMetadata)

// 4. 调用 Model Connection 流式生成
const client = await connectionManager.getChatClient()
Expand Down Expand Up @@ -257,7 +226,11 @@ export function registerChatHandlers(
event.sender.send('message-chunk', {
messageId: assistantMessage.id,
type: 'finish',
metadata: metadata
metadata: metadata,
// The renderer's in-memory message never sees the DB row written
// before streaming, so the persisted provenance rides along here or
// the answer loses its citations until the session is reloaded.
messageMetadata: answerMetadata
})
}
},
Expand Down
90 changes: 90 additions & 0 deletions src/main/services/citations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Citation } from '../../shared/types/citation'
import type { AnswerSource } from '../../shared/types/chat'
import type { SearchResult } from './KnowledgeService'

/**
* 检索 → 引用 的组装。
*
* 检索层(`RetrievedEvidence`)已经带着来源身份、页码区间和块区间;这里只把它
* 投影成 prompt 与 `chat_messages.metadata` 用的形状,不再回查数据库。保持纯函数
* 是刻意的 —— #70 的解析/校验也走同一份 `Citation`,两条路径必须能看到同样的
* 定位信息。
*/

/** 一个检索结果 → 一条 citation。`index` 是它在 prompt 里的 1-based 位置。 */
export function citationFromSearchResult(result: SearchResult, index: number): Citation {
const { blocks, pageStart, pageEnd } = result.locator
const first = blocks[0]
const last = blocks[blocks.length - 1]

const citation: Citation = {
index,
documentId: result.documentId,
documentTitle: result.documentTitle,
chunkId: result.chunkId,
quote: result.content,
score: result.score
}

if (result.documentType) citation.documentType = result.documentType
if (pageStart !== null) citation.page = pageStart
if (pageEnd !== null) citation.pageEnd = pageEnd

// A chunk can cover several blocks. The jump anchor is the first one; the
// char span is the union of the blocks actually covered, which is what the
// reader highlights.
if (first && last) {
citation.blockId = first.blockId
citation.startOffset = first.startOffset + first.startInBlock
citation.endOffset = last.startOffset + last.endInBlock
}

return citation
}

export function buildCitations(results: SearchResult[]): Citation[] {
return results.map((result, index) => citationFromSearchResult(result, index + 1))
}

/** 检索结果在 prompt / 元数据里共用的「回答基于什么」视图。 */
export interface RAGContext {
context: string
sources: AnswerSource[]
citations: Citation[]
}

/**
* 构建 RAG 上下文 prompt,并把「这段回答基于哪些段落」一起交出来。
*
* 之前这里只取 `documentTitle` / `content` / `score` 三个字段,`chunkId`、
* `documentId`、`chunkIndex` 全部被丢掉 —— 于是回答交付之后,界面上再也没有回到
* 原文的路。prompt 文本保持原有结构,这里只是不再丢弃身份,并显式要求模型用
* `[n]` 标注来源,让回答里的断言可以解析回 citation。
*/
export function buildRAGContext(searchResults: SearchResult[]): RAGContext {
if (searchResults.length === 0) return { context: '', sources: [], citations: [] }

const citations = buildCitations(searchResults)
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}`
)

const context = `以下是与用户问题相关的背景知识,请参考这些信息来回答:

${contextParts.join('\n\n---\n\n')}

请基于以上背景知识回答用户的问题。引用某个来源时,请在相应句子后使用 [n] 标注来源编号(n 为上面的来源编号,例如 [1])。如果背景知识不足以回答问题,请说明并尽力提供有帮助的回答。`

return { context, sources, citations }
}
4 changes: 3 additions & 1 deletion src/preload/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { ChatSession, ChatMessage } from '../shared/types/chat'
import type { ChatSession, ChatMessage, ChatMessageMetadata } from '../shared/types/chat'
import type {
Notebook,
Note,
Expand Down Expand Up @@ -209,6 +209,8 @@ declare global {
content?: string
reasoningId?: string
metadata?: any
/** Persisted `chat_messages.metadata` for the finished answer. */
messageMetadata?: ChatMessageMetadata
}) => void
) => () => void
onMessageError: (callback: (data: { messageId: string; error: string }) => void) => () => void
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/src/store/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export const useChatStore = create<ChatStore>()((set, get) => ({
export function setupChatListeners() {
// 监听流式消息片段(AI SDK fullStream 格式)
const cleanupChunk = window.api.onMessageChunk((data) => {
const { messageId, type, content } = data
const { messageId, type, content, messageMetadata } = data
const store = useChatStore.getState()

const cached = store.messageToNotebook[messageId]
Expand Down Expand Up @@ -306,6 +306,17 @@ export function setupChatListeners() {
// 流式传输完成
store.setStreamingMessage(cached.notebookId, null)

// Provenance (retrieval status, sources, citations) is written to the DB
// before the model call, so the in-memory message never saw it. Apply it
// now, or the answer stays un-citable until the session is reloaded.
if (messageMetadata) {
useChatStore.setState((state) => ({
messages: state.messages.map((msg) =>
msg.id === messageId ? { ...msg, metadata: messageMetadata } : msg
)
}))
}

// 清理缓存
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [messageId]: _removed, ...rest } = store.messageToNotebook
Expand Down
7 changes: 7 additions & 0 deletions src/shared/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ChatSession as DBChatSession,
ChatMessage as DBChatMessage
} from '../../main/db/schema'
import type { Citation } from './citation'

/**
* 聊天会话接口(完整版)
Expand Down Expand Up @@ -63,6 +64,12 @@ export type RetrievalStatus = 'used' | 'none' | 'failed'
export interface ChatMessageMetadata {
sources?: AnswerSource[]
retrieval?: RetrievalStatus
/**
* The structured provenance of this answer (#69). `sources` says *what* the
* answer was built from; `citations` says *where* in the source each marker
* points. Both are snapshots written when the answer was produced.
*/
citations?: Citation[]
[key: string]: unknown
}

Expand Down
31 changes: 31 additions & 0 deletions src/shared/types/citation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* 结构化引用
*
* 一条 citation 是「回答里的一句断言」与「来源里支持它的一处文字」之间的连线。
* 它刻意是一个**快照**:文字、页码、块 id 在回答生成时一并写入,而不是以后再去
* 查库。来源文档可以在回答产生之后被重新索引甚至删除,citation 必须在这两件事
* 之后依然存在 —— 它降级展示,而不是变成一个悬空的 id。
*
* `quote` 同样是快照:它是检索到的证据本身,不是指向 chunks 表的外键。重新分块
* 或重新嵌入不能改变一条已经交付的回答里显示的原文。
*/
export interface Citation {
/** 来源在 prompt 里的 1-based 位置,回答里的 `[n]` 据此解析。 */
index: number
documentId: string
documentTitle: string
documentType?: string
/** 引用区间起始页;不分页的来源为 `undefined`。 */
page?: number
/** 引用跨页时的结束页。 */
pageEnd?: number
/** 跳转应落在的块(区间覆盖的第一个块)。 */
blockId?: string
chunkId: string
/** 引用区间在 `documents.content` 里的字符偏移。 */
startOffset?: number
endOffset?: number
/** 检索到的原文,逐字保留。 */
quote: string
score: number
}
3 changes: 3 additions & 0 deletions src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export * from './knowledge'
// 导出聊天类型
export * from './chat'

// 导出引用类型
export * from './citation'

// 导出来源阅读器契约类型
export * from './source'

Expand Down
73 changes: 73 additions & 0 deletions src/shared/utils/citations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Citation } from '../types/citation'
import type { ChatMessageMetadata } from '../types/chat'

/**
* Readers for `chat_messages.metadata.citations`.
*
* 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. `parseCitations`
* is the only place that decides what is usable, so a component can render a
* message from a year ago without a guard of its own.
*
* Malformed entries are **dropped individually**: one unreadable citation from an
* older writer is not a reason to hide the citations that did survive.
*
* Dropping is not the same as deleting. A citation whose document no longer
* exists is still a valid, parseable record — the source was real when the answer
* was written. The UI disables the jump; the parser does not pretend it never
* happened.
*/

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 parseCitation = (value: unknown): Citation | null => {
if (!value || typeof value !== 'object') return null
const candidate = value as Record<string, unknown>

// The four fields a citation cannot be rendered or resolved without.
if (!isNonEmptyString(candidate.documentId)) return null
if (!isNonEmptyString(candidate.documentTitle)) return null
if (!isNonEmptyString(candidate.chunkId)) return null
if (!isNonEmptyString(candidate.quote)) return null

const citation: Citation = {
index: toFiniteNumber(candidate.index) ?? 0,
documentId: candidate.documentId,
documentTitle: candidate.documentTitle,
chunkId: candidate.chunkId,
quote: candidate.quote,
// An unrecorded score is not "irrelevant"; it is unknown. 0 keeps the field a
// number without claiming anything a reader relies on.
score: toFiniteNumber(candidate.score) ?? 0
}

if (isNonEmptyString(candidate.documentType)) citation.documentType = candidate.documentType
if (isNonEmptyString(candidate.blockId)) citation.blockId = candidate.blockId

const page = toFiniteNumber(candidate.page)
if (page !== undefined) citation.page = page
const pageEnd = toFiniteNumber(candidate.pageEnd)
if (pageEnd !== undefined) citation.pageEnd = pageEnd
const startOffset = toFiniteNumber(candidate.startOffset)
if (startOffset !== undefined) citation.startOffset = startOffset
const endOffset = toFiniteNumber(candidate.endOffset)
if (endOffset !== undefined) citation.endOffset = endOffset

return citation
}

/** Every usable citation on a message, ordered by the marker the prompt used. */
export const parseCitations = (metadata: unknown): Citation[] => {
if (!metadata || typeof metadata !== 'object') return []
const raw = (metadata as ChatMessageMetadata).citations
if (!Array.isArray(raw)) return []

return raw
.map(parseCitation)
.filter((citation): citation is Citation => citation !== null)
.sort((a, b) => a.index - b.index)
}
Loading
Loading