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
30 changes: 30 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,33 @@ Rules:
The harness is a main-process entry (`--eval-harness`), like the packaged smoke
test, because the DB layer, vector store and loaders do not exist outside
Electron. See `eval/README.md` for the dataset format and commands.

## Source reader

The reader is not "the PDF component". It is the surface that renders whatever
structure a source has, behind one format-agnostic contract:

```ts
interface ReaderHandle {
openAt(anchor: ReaderAnchor): void
getSelection(): ReaderSelection | null
}
```

`ReaderAnchor` is `{ documentId, page?, blockId?, startOffset?, endOffset? }`,
and `ReaderSelection` is the same shape plus the selected `text`. PDF is the full
implementation in v1.4 (pages, text layer, block overlay); every other format uses
a text reader. Nothing downstream may branch on which one is mounted — #72
(citation click) and #73 (excerpt to note) only use `openAt` / `getSelection`.

Rules:

- **Bytes are served by id, never by path.** `knownote-doc://docs/<documentId>` is
resolved against `documents.localFilePath` in the main process. The renderer
never constructs a filesystem path, and an unknown id is a 404.
- **Blocks travel with the source.** `document_blocks` holds page, char span and
normalized bbox; `openAt({ page, blockId })` uses them to scroll and highlight,
so the reader does not re-derive geometry from chunk text.
- **The text layer is real text.** pdfjs's `TextLayer` is rendered over the canvas
so `getSelection()` can map a DOM selection back to a source location; selecting
the canvas would give nothing.
9 changes: 9 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ import { getStore } from './config/store'
import { migrateProvidersToConnections } from './config/connectionMigration'
import { isSmokeTestRequested, runSmokeTest } from './smokeTest'
import { isEvalRequested, runEvalCli } from './eval/run'
import {
registerDocumentProtocolHandler,
registerDocumentScheme
} from './protocol/documentProtocol'
import Logger from '../shared/utils/logger'

// Scheme privileges must be registered before the app is ready, so this is a
// module-scope call rather than part of the whenReady sequence.
registerDocumentScheme()

let connectionManager: ConnectionManager | null = null
let embeddingService: EmbeddingService | null = null
let sessionAutoSwitchService: SessionAutoSwitchService | null = null
Expand Down Expand Up @@ -69,6 +77,7 @@ app.whenReady().then(async () => {
initDatabase()
runMigrations()
initVectorStore()
registerDocumentProtocolHandler()
Logger.info('Main', 'Database initialized')

// Initialize electron-store
Expand Down
15 changes: 15 additions & 0 deletions src/main/ipc/knowledgeHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,21 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) {
})
)

// 获取文档的结构块(#71)。Reader 需要页码与 bbox 才能把引用落点高亮。
ipcMain.handle(
'knowledge:get-document-blocks',
validate(KnowledgeSchemas.getDocumentBlocks, async (params) => {
Logger.debug('KnowledgeHandlers', 'get-document-blocks:', params.documentId)

try {
return knowledgeService.getDocumentBlocks(params.documentId)
} catch (error) {
Logger.error('KnowledgeHandlers', 'Error getting document blocks:', error)
return []
}
})
)

// 删除文档
ipcMain.handle(
'knowledge:delete-document',
Expand Down
4 changes: 4 additions & 0 deletions src/main/ipc/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export const KnowledgeSchemas = {
documentId: z.string().min(1, '文档 ID 不能为空')
}),

getDocumentBlocks: z.object({
documentId: z.string().min(1, '文档 ID 不能为空')
}),

deleteDocument: z.object({
documentId: z.string().min(1, '文档 ID 不能为空')
}),
Expand Down
74 changes: 74 additions & 0 deletions src/main/protocol/documentProtocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* `knownote-doc://` 协议(#71)
*
* 渲染进程不应拿到任意文件路径。它只知道 documentId,主进程按 id 查库、只服务
* 这份来源自己的文件。路径永远来自数据库,不来自请求,因此不存在路径穿越。
*
* scheme 必须在 app ready 之前注册为 privileged;handler 在 ready 之后安装。
*/

import { net, protocol } from 'electron'
import { pathToFileURL } from 'url'
import { eq } from 'drizzle-orm'
import { getDatabase } from '../db'
import { documents } from '../db/schema'
import { DOCUMENT_SCHEME, parseDocumentUrl } from '../../shared/utils/documentUrl'
import Logger from '../../shared/utils/logger'

/**
* Origins allowed to read a document. The renderer is `file://` in production
* (serialised as `null`) or the electron-vite dev server in development. A
* wildcard would let any origin that guesses a document id read the file, so the
* request's own origin is reflected only when it is one of these.
*/
const ALLOWED_ORIGINS = new Set(['null', 'http://localhost:5173', 'http://127.0.0.1:5173'])

export { DOCUMENT_SCHEME, documentUrl, parseDocumentUrl } from '../../shared/utils/documentUrl'

/** 必须在 app ready 之前调用一次。 */
export function registerDocumentScheme(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: DOCUMENT_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
// The renderer fetches this scheme from a different origin (`file://` in
// production, the dev server in development), so it is a cross-origin
// request and must be CORS-enabled to succeed.
corsEnabled: true
}
}
])
}

/** 在 app ready 之后调用,安装只读的字节服务 handler。 */
export function registerDocumentProtocolHandler(): void {
protocol.handle(DOCUMENT_SCHEME, async (request) => {
const documentId = parseDocumentUrl(request.url)
if (!documentId) return new Response('Not found', { status: 404 })

const document = getDatabase()
.select({ localFilePath: documents.localFilePath })
.from(documents)
.where(eq(documents.id, documentId))
.get()

if (!document?.localFilePath) return new Response('Not found', { status: 404 })

try {
// net.fetch handles file:// in the main process and streams the response, so
// a large PDF is not read into memory to be served.
const response = await net.fetch(pathToFileURL(document.localFilePath).toString())
const headers = new Headers(response.headers)
const origin = request.headers.get('Origin') ?? 'null'
if (ALLOWED_ORIGINS.has(origin)) headers.set('Access-Control-Allow-Origin', origin)
return new Response(response.body, { status: response.status, headers })
} catch (error) {
Logger.warn('DocumentProtocol', `Failed to serve ${documentId}:`, error)
return new Response('Not found', { status: 404 })
}
})
}
43 changes: 43 additions & 0 deletions src/main/smokeTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@

import Logger from '../shared/utils/logger'
import { readFile } from 'fs/promises'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { net } from 'electron'
import { eq } from 'drizzle-orm'
import {
closeDatabase,
Expand All @@ -48,6 +51,8 @@ import { ChunkingService } from './services/ChunkingService'
import { insertChunkBlocks, resolveChunkProvenance } from './services/chunkProvenance'
import { KnowledgeService } from './services/KnowledgeService'
import { DenseRetriever } from './services/retrieval'
import { documentUrl } from '../shared/utils/documentUrl'
import { registerDocumentProtocolHandler } from './protocol/documentProtocol'
import type { EmbeddingService } from './services/EmbeddingService'

export const SMOKE_TEST_FLAG = '--smoke-test'
Expand Down Expand Up @@ -215,6 +220,44 @@ async function runChecks(): Promise<string[]> {
)
pass('legacy global vec_embeddings migrates into a searchable per-notebook table')

// --- document protocol (#71): bytes by id, never by path -------------------
// The renderer asks for `knownote-doc://docs/<id>`; only a document that the
// database actually owns is served. This runs in the real packaged main
// process, which is the only place `protocol.handle` exists.
const protocolDir = mkdtempSync(join(tmpdir(), 'knownote-smoke-doc-'))
const protocolFilePath = join(protocolDir, 'payload.pdf')
const protocolPayload = 'knownote-protocol-payload'
const protocolDocumentId = 'smoke-protocol-doc'
writeFileSync(protocolFilePath, protocolPayload)

database
.prepare(
'INSERT INTO documents (id, notebook_id, title, type, content, local_file_path, status, chunk_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.run(
protocolDocumentId,
LEGACY_NOTEBOOK,
'Protocol smoke document',
'file',
protocolPayload,
protocolFilePath,
'indexed',
0,
Date.now(),
Date.now()
)

registerDocumentProtocolHandler()
const served = await net.fetch(documentUrl(protocolDocumentId))
assert(served.ok, `an owned document did not serve (status ${served.status})`)
assert((await served.text()) === protocolPayload, 'the document protocol served the wrong bytes')
const unknown = await net.fetch(documentUrl('smoke-missing-doc'))
assert(unknown.status === 404, `an unknown document id returned ${unknown.status}, not 404`)

database.prepare('DELETE FROM documents WHERE id = ?').run(protocolDocumentId)
rmSync(protocolDir, { recursive: true, force: true })
pass('knownote-doc:// serves an owned document and 404s an unknown id')

const { version } = database.prepare('SELECT vec_version() AS version').get() as {
version: string
}
Expand Down
4 changes: 3 additions & 1 deletion src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type {
ModelConnection,
LocalEmbeddingModelInfo,
EmbeddingDownloadProgress,
EmbeddingSourceInfo
EmbeddingSourceInfo,
SourceBlock
} from '../shared/types'
import type {
KnowledgeDocument,
Expand Down Expand Up @@ -282,6 +283,7 @@ declare global {
getDocuments: (notebookId: string) => Promise<KnowledgeDocument[]>
getDocument: (documentId: string) => Promise<KnowledgeDocument | null>
getDocumentChunks: (documentId: string) => Promise<KnowledgeChunk[]>
getDocumentBlocks: (documentId: string) => Promise<SourceBlock[]>
deleteDocument: (documentId: string) => Promise<{ success: boolean; error?: string }>
reindexDocument: (documentId: string) => Promise<{ success: boolean; error?: string }>

Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ const api = {
ipcRenderer.invoke('knowledge:get-document', { documentId }),
getDocumentChunks: (documentId: string) =>
ipcRenderer.invoke('knowledge:get-document-chunks', { documentId }),
getDocumentBlocks: (documentId: string) =>
ipcRenderer.invoke('knowledge:get-document-blocks', { documentId }),
deleteDocument: (documentId: string) =>
ipcRenderer.invoke('knowledge:delete-document', { documentId }),
reindexDocument: (documentId: string) =>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; font-src https://fonts.gstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://fonts.googleapis.com https://fonts.gstatic.com; font-src https://fonts.gstatic.com; connect-src 'self' knownote-doc:; worker-src 'self' blob:;"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Expand Down
40 changes: 5 additions & 35 deletions src/renderer/src/components/notebook/SourcePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { Card } from '../ui/card'
import { PanelHeader } from '../ui/panel-header'
import DocumentList from './source/DocumentList'
import SourceReader from './source/reader/SourceReader'
import type { KnowledgeDocument } from '../../../../shared/types/knowledge'

// 添加来源类型
Expand Down Expand Up @@ -168,27 +169,6 @@ interface DocumentViewerPanelProps {

function DocumentViewerPanel({ document, onBack }: DocumentViewerPanelProps) {
const { t } = useTranslation('ui')
const [content, setContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)

// 加载文档内容
useEffect(() => {
const loadContent = async () => {
setIsLoading(true)
try {
const chunks = await window.api.knowledge.getDocumentChunks(document.id)
// 合并所有 chunk 的内容
const fullContent = chunks.map((chunk) => chunk.content).join('\n\n')
setContent(fullContent)
} catch (error) {
console.error('Error loading document content:', error)
} finally {
setIsLoading(false)
}
}

loadContent()
}, [document.id])

return (
<>
Expand All @@ -209,20 +189,10 @@ function DocumentViewerPanel({ document, onBack }: DocumentViewerPanelProps) {
center={<span className="text-sm font-medium truncate">{document.title}</span>}
/>

{/* 文档内容 */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="p-6">
<div className="prose prose-sm dark:prose-invert max-w-none">
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed">{content}</pre>
</div>
</div>
)}
</ScrollArea>
{/* 来源阅读器(#71):PDF 渲染原页,其余格式走文本回退,两者同一契约。 */}
<div className="flex min-h-0 flex-1 flex-col">
<SourceReader document={document} />
</div>
</>
)
}
Expand Down
Loading
Loading