feat(storage): move desktop SQLite ownership into a storage utility process

Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
mesalogo
2026-10-06 15:40:18 +08:00
parent bc677a3718
commit d80d42b032
229 changed files with 13632 additions and 3800 deletions
@@ -134,6 +134,44 @@ function provider(
}
describe('EmbeddingIndexCoordinator', () => {
it('holds the rebuild slot until the final status commit settles', async () => {
const repository = new MemoryRepository()
let commit!: () => void
vi.spyOn(repository, 'saveStatus').mockImplementation(async status => {
if (status.job?.status === 'completed') {
await new Promise<void>(resolve => { commit = resolve })
}
})
const coordinator = new EmbeddingIndexCoordinator(repository)
const model = provider(async inputs => inputs.map(() => [1, 0]))
coordinator.startRebuild(model)
await vi.waitFor(() => expect(commit).toBeTypeOf('function'))
expect(() => coordinator.startRebuild(model)).toThrow('already active')
commit()
await expect(coordinator.waitForCompletion()).resolves.toMatchObject({ status: 'completed' })
})
it('retains success when the final document commits before cancellation', async () => {
const repository = new MemoryRepository()
const coordinator = new EmbeddingIndexCoordinator(repository)
const finish = repository.finishDocumentReplacement.bind(repository)
vi.spyOn(repository, 'finishDocumentReplacement').mockImplementation(async (replacement, document) => {
await finish(replacement, document)
if (document === 'document-2') coordinator.cancel()
})
coordinator.startRebuild(provider(async inputs => inputs.map(() => [1, 0])))
await expect(coordinator.waitForCompletion()).resolves.toMatchObject({ status: 'completed' })
expect(repository.pendingRecords.size).toBe(0)
})
it('surfaces a failed status commit at the completion barrier', async () => {
const repository = new MemoryRepository()
vi.spyOn(repository, 'saveStatus').mockRejectedValue(new Error('storage exited'))
const coordinator = new EmbeddingIndexCoordinator(repository)
coordinator.startRebuild(provider(async inputs => inputs.map(() => [1, 0])))
await expect(coordinator.waitForCompletion()).rejects.toThrow('storage exited')
})
it('performs a real embedding request for diagnostics', async () => {
const repository = new MemoryRepository()
const embed = vi.fn(async () => [[0.25, 0.5, 0.75]])
@@ -241,6 +241,7 @@ export class EmbeddingIndexCoordinator {
private controller: AbortController | null = null
private completion: Promise<EmbeddingIndexJob> | null = null
private persistenceTail: Promise<void> = Promise.resolve()
private persistenceError?: unknown
constructor(
repository: EmbeddingIndexRepository,
@@ -307,6 +308,7 @@ export class EmbeddingIndexCoordinator {
options: EmbeddingRebuildOptions = {}
): EmbeddingIndexJob {
if (
this.controller ||
this.job?.status === 'queued' ||
this.job?.status === 'running'
) {
@@ -315,6 +317,7 @@ export class EmbeddingIndexCoordinator {
const providerName = validatedLabel(provider.provider, 'provider')
const model = validatedLabel(provider.model, 'model')
const controller = new AbortController()
this.persistenceError = undefined
const createdAt = this.now()
this.job = {
id: this.createId(),
@@ -347,6 +350,7 @@ export class EmbeddingIndexCoordinator {
this.controller = null
}
})
void this.completion.catch(() => undefined)
return this.job
}
@@ -502,7 +506,6 @@ export class EmbeddingIndexCoordinator {
})
}
signal.throwIfAborted()
this.updateJob({
status: 'completed',
completedAt: this.now(),
@@ -534,6 +537,7 @@ export class EmbeddingIndexCoordinator {
)
}
await this.persistenceTail
if (this.persistenceError) throw this.persistenceError
if (!this.job) {
throw new Error('Embedding index job state was lost')
}
@@ -556,8 +560,8 @@ export class EmbeddingIndexCoordinator {
this.persistenceTail = this.persistenceTail.then(async () => {
try {
await this.repository.saveStatus?.(status)
} catch {
// Persistence failure must not interrupt an active provider operation.
} catch (error) {
this.persistenceError ??= error
}
})
for (const listener of this.listeners) {
@@ -570,10 +574,11 @@ export class EmbeddingIndexCoordinator {
this.persistenceTail = this.persistenceTail.then(async () => {
try {
await this.repository.saveStatus?.(status)
} catch {
// Persistence failure must not interrupt initialization.
} catch (error) {
this.persistenceError ??= error
}
})
await this.persistenceTail
if (this.persistenceError) throw this.persistenceError
}
}
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, expect, it, vi } from 'vitest'
import { KnowledgeService } from '../knowledge-service'
import { TestKnowledgeService as KnowledgeService } from '../../../../tests/support/knowledge-test-service'
import { KnowledgeMcpGateway } from '../../agent/knowledge-mcp-gateway'
import { knowledgeReferenceKey, toKnowledgeReference } from '../../../shared/knowledge-reference'
import { conversationMessageSchema } from '../../../shared/assistant-contracts'
@@ -36,8 +36,8 @@ const records = (count = 1, size = 16) => ({ records: Array.from({ length: count
score: 0.8, segment: { id: `chunk-${index}`, content: 'e'.repeat(size), document: { id: 'remote-doc', name: 'Handbook' } }
})) })
function instance(service: KnowledgeService, baseUrl = 'https://kb.example/v1') {
return service.external.saveInstance({ name: 'Dify', provider: 'dify', baseUrl, enabled: true, credential: { action: 'replace', value: 'test-key' } })
async function instance(service: KnowledgeService, baseUrl = 'https://kb.example/v1') {
return (await service.external.saveInstance({ name: 'Dify', provider: 'dify', baseUrl, enabled: true, credential: { action: 'replace', value: 'test-key' } }))
}
function bindingInput(instanceId: string, remoteKnowledgeBaseId = 'dataset') {
@@ -61,7 +61,7 @@ it('routes the real HTTP client through directory, detail, binding and scoped MC
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const { service } = await setup()
const saved = instance(service, `http://127.0.0.1:${address.port}/v1`)
const saved = await instance(service, `http://127.0.0.1:${address.port}/v1`)
expect(saved).not.toHaveProperty('credential')
expect((await service.external.listCatalog({ instanceId: saved.id })).items).toHaveLength(1)
expect(await service.external.getCatalog({ instanceId: saved.id, remoteKnowledgeBaseId: 'dataset' })).toMatchObject({ id: 'dataset' })
@@ -73,7 +73,7 @@ it('routes the real HTTP client through directory, detail, binding and scoped MC
expect(references).toHaveLength(1)
expect(references[0]).toMatchObject({ external: { remoteDocumentId: 'remote-doc', remoteChunkId: 'chunk-0', providerScore: 0.8 } })
expect(references[0]?.documentId).toBeUndefined()
expect(service.database.listDocuments(binding.knowledgeBaseId)).toEqual([])
expect((await service.database.listDocuments(binding.knowledgeBaseId))).toEqual([])
expect(requests).toEqual(['GET /v1/datasets?page=1&limit=100', 'GET /v1/datasets/dataset', 'POST /v1/datasets/dataset/retrieve', 'POST /v1/datasets/dataset/retrieve'])
} finally { await gateway.dispose() }
})
@@ -81,7 +81,7 @@ it('routes the real HTTP client through directory, detail, binding and scoped MC
it('keeps actual failures, bounds mixed retrieval to 48k, and rejects local mutations', async () => {
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records(20, 8000)))
const { service } = await setup(fetcher)
const saved = instance(service)
const saved = await instance(service)
const first = await service.external.saveBinding(bindingInput(saved.id))
const second = await service.external.saveBinding(bindingInput(saved.id, 'second'))
const results = await service.retrieveMany([first.knowledgeBaseId, second.knowledgeBaseId], 'policy')
@@ -91,50 +91,52 @@ it('keeps actual failures, bounds mixed retrieval to 48k, and rejects local muta
expect(failed[0]?.response.diagnostics.failure).toBe('EXTERNAL_KB_AUTH')
await expect(service.importPaths(first.knowledgeBaseId, [])).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
await expect(service.rebuildLibrary({ knowledgeBaseId: first.knowledgeBaseId })).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
expect(() => service.updateSettings({ knowledgeBaseId: first.knowledgeBaseId, retrieval: service.database.getKnowledgeBase(first.knowledgeBaseId)!.retrievalSettings })).toThrow('EXTERNAL_KB_READ_ONLY')
expect(() => service.database.updateKnowledgeBase(first.knowledgeBaseId, { graphEnabled: true })).toThrow('EXTERNAL_KB_READ_ONLY')
await expect((async () => (await service.updateSettings({ knowledgeBaseId: first.knowledgeBaseId, retrieval: (await service.database.getKnowledgeBase(first.knowledgeBaseId))!.retrievalSettings })))()).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
await expect((async () => (await service.database.updateKnowledgeBase(first.knowledgeBaseId, { graphEnabled: true })))()).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
})
it('rolls metadata back with binding failures and rejects concurrent duplicate creates', async () => {
const { service, path } = await setup(async () => Response.json(records()))
const saved = instance(service)
const saved = await instance(service)
const first = await service.external.saveBinding(bindingInput(saved.id))
const inspection = new DatabaseSync(path)
inspection.exec("CREATE TRIGGER reject_binding BEFORE UPDATE ON external_knowledge_bindings BEGIN SELECT RAISE(ABORT, 'binding failed'); END")
try {
await expect(service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: first.knowledgeBaseId, name: 'Changed' })).rejects.toThrow('binding failed')
expect(service.database.getKnowledgeBase(first.knowledgeBaseId)?.name).toBe('Handbook')
expect((await service.database.getKnowledgeBase(first.knowledgeBaseId))?.name).toBe('Handbook')
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ user_version: 13 })
} finally { inspection.close() }
const outcomes = await Promise.allSettled([service.external.saveBinding(bindingInput(saved.id, 'new')), service.external.saveBinding(bindingInput(saved.id, 'new'))])
expect(outcomes.filter(item => item.status === 'fulfilled')).toHaveLength(1)
expect(service.database.listKnowledgeBases()).toHaveLength(2)
expect((await service.database.listKnowledgeBases())).toHaveLength(2)
})
it('cancels pending probes on disable and prevents shutdown writeback', async () => {
let release!: (value: Response) => void
const fetcher = vi.fn<typeof fetch>(() => new Promise<Response>(resolve => { release = resolve }))
const { service } = await setup(fetcher)
const saved = instance(service)
const saved = await instance(service)
const probe = service.external.testInstance(saved.id)
service.external.setEnabled(saved.id, false)
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1))
await service.external.setEnabled(saved.id, false)
release(Response.json({ data: [], has_more: false }))
await expect(probe).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
expect(service.external.listInstances()[0]).toMatchObject({ enabled: false, probeStatus: 'untested' })
expect(service.database.externalStore.getInstance(saved.id)?.lastTestedAt).toBeUndefined()
service.external.setEnabled(saved.id, true)
expect((await service.external.listInstances())[0]).toMatchObject({ enabled: false, probeStatus: 'untested' })
expect((await service.database.externalStore.getInstance(saved.id))?.lastTestedAt).toBeUndefined()
await service.external.setEnabled(saved.id, true)
const pending = service.external.saveBinding(bindingInput(saved.id))
const rejected = expect(pending).rejects.toThrow()
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2))
await service.dispose()
services.splice(services.indexOf(service), 1)
release(Response.json(records()))
await rejected
expect(() => service.external.listInstances()).toThrow('EXTERNAL_KB_CANCELLED')
await expect((async () => (await service.external.listInstances()))()).rejects.toThrow('EXTERNAL_KB_CANCELLED')
})
it('persists external locators without local IDs and distinguishes remote chunks', async () => {
const { service } = await setup(async () => Response.json(records(2)))
const binding = await service.external.saveBinding(bindingInput(instance(service).id))
const binding = await service.external.saveBinding(bindingInput((await instance(service)).id))
const response = await service.retrieve({ knowledgeBaseId: binding.knowledgeBaseId, query: 'policy' })
const references = response.results.map(result => toKnowledgeReference(result, 'Handbook'))
expect(knowledgeReferenceKey(references[0]!)).not.toBe(knowledgeReferenceKey(references[1]!))
@@ -145,43 +147,43 @@ it('persists external locators without local IDs and distinguishes remote chunks
it('reports unusable credentials and preserves the stored key when encryption fails', async () => {
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records()))
const { service, cipher } = await setup(fetcher)
const saved = instance(service)
const before = service.database.externalStore.getInstance(saved.id)
const saved = await instance(service)
const before = (await service.database.externalStore.getInstance(saved.id))
const decrypt = vi.spyOn(cipher, 'decrypt').mockImplementation(() => { throw new Error('OS key unavailable') })
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
expect((await service.external.listInstances())[0]?.credentialStatus).toBe('unavailable')
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
decrypt.mockReturnValue(JSON.stringify(' '))
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
expect((await service.external.listInstances())[0]?.credentialStatus).toBe('unavailable')
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
expect(fetcher).not.toHaveBeenCalled()
decrypt.mockRestore()
vi.spyOn(cipher, 'encrypt').mockImplementation(() => { throw new Error('OS encryption failed') })
expect(() => service.external.saveInstance({ id: saved.id, name: 'Changed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true,
credential: { action: 'replace', value: 'new-key' } })).toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
expect(service.database.externalStore.getInstance(saved.id)).toEqual(before)
expect(service.external.listInstances()[0]?.credentialStatus).toBe('configured')
await expect((async () => (await service.external.saveInstance({ id: saved.id, name: 'Changed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true,
credential: { action: 'replace', value: 'new-key' } })))()).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
expect((await service.database.externalStore.getInstance(saved.id))).toEqual(before)
expect((await service.external.listInstances())[0]?.credentialStatus).toBe('configured')
})
it('persists keep and clear credential mutations and normalized binding updates across restart', async () => {
const { service, options } = await setup(async () => Response.json(records()))
const saved = instance(service)
const saved = await instance(service)
const binding = await service.external.saveBinding(bindingInput(saved.id))
const credential = service.database.externalStore.getInstance(saved.id)?.credential
service.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'keep' } })
const credential = (await service.database.externalStore.getInstance(saved.id))?.credential
await service.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'keep' } })
const updated = await service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: ` ${binding.knowledgeBaseId} `, name: 'Updated' })
expect(updated.knowledgeBaseId).toBe(binding.knowledgeBaseId)
expect(service.database.externalStore.getInstance(saved.id)?.credential).toEqual(credential)
expect((await service.database.externalStore.getInstance(saved.id))?.credential).toEqual(credential)
await service.dispose()
services.splice(services.indexOf(service), 1)
const reopened = new KnowledgeService(options)
services.push(reopened)
await reopened.initialize()
expect(reopened.external.listInstances()[0]).toMatchObject({ name: 'Renamed', credentialStatus: 'configured', bindingCount: 1 })
expect(reopened.database.getKnowledgeBase(binding.knowledgeBaseId)?.name).toBe('Updated')
expect(reopened.database.externalStore.listBindings()).toEqual([updated])
expect((await reopened.external.listInstances())[0]).toMatchObject({ name: 'Renamed', credentialStatus: 'configured', bindingCount: 1 })
expect((await reopened.database.getKnowledgeBase(binding.knowledgeBaseId))?.name).toBe('Updated')
expect((await reopened.database.externalStore.listBindings())).toEqual([updated])
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).resolves.toMatchObject({ results: [expect.anything()] })
reopened.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'clear' } })
expect(reopened.external.listInstances()[0]).toMatchObject({ credentialStatus: 'missing', bindingCount: 1 })
await reopened.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'clear' } })
expect((await reopened.external.listInstances())[0]).toMatchObject({ credentialStatus: 'missing', bindingCount: 1 })
const inspection = new DatabaseSync(options.databasePath)
try {
const row = inspection.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(saved.id)!
@@ -191,25 +193,25 @@ it('persists keep and clear credential mutations and normalized binding updates
})
it('does not invalidate retrieval or binding verification when a concurrent probe persists its result', async () => {
const releases: ((response: Response) => void)[] = []
const { service } = await setup(() => new Promise<Response>(resolve => releases.push(resolve)))
const saved = instance(service)
const releases: { url: string; resolve: (response: Response) => void }[] = []
const { service } = await setup(url => new Promise<Response>(resolve => releases.push({ url: String(url), resolve })))
const saved = await instance(service)
const binding = service.external.saveBinding(bindingInput(saved.id))
const retrieval = service.external.testRetrieval(retrievalInput(saved.id))
const probe = service.external.testInstance(saved.id)
releases[2]!(Response.json({ data: [], has_more: false }))
await vi.waitFor(() => expect(releases).toHaveLength(3))
releases.find(item => !item.url.includes('/retrieve'))!.resolve(Response.json({ data: [], has_more: false }))
await expect(probe).resolves.toMatchObject({ probeStatus: 'catalog-ready' })
releases[0]!(Response.json(records()))
releases[1]!(Response.json(records()))
for (const release of releases.filter(item => item.url.includes('/retrieve'))) release.resolve(Response.json(records()))
await expect(binding).resolves.toMatchObject({ instanceId: saved.id })
await expect(retrieval).resolves.toMatchObject({ results: [expect.anything()] })
expect(service.external.listInstances()[0]?.probeStatus).toBe('catalog-ready')
expect((await service.external.listInstances())[0]?.probeStatus).toBe('catalog-ready')
})
it('normalizes pre-request cancellation and persists network probe failures as unreachable', async () => {
const fetcher = vi.fn<typeof fetch>(async () => { throw new TypeError('fetch failed') })
const { service } = await setup(fetcher)
const saved = instance(service)
const saved = await instance(service)
await expect(service.external.testRetrieval(retrievalInput(saved.id), AbortSignal.abort())).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
expect(fetcher).not.toHaveBeenCalled()
await expect(service.external.testInstance(saved.id)).resolves.toMatchObject({ probeStatus: 'unreachable', lastErrorCode: 'EXTERNAL_KB_NETWORK' })
@@ -223,7 +225,7 @@ it('includes the RAGFlow capability check in the retrieval timeout budget', asyn
: { code: 0, data: { chunks: [] } })
})
const { service } = await setup(fetcher)
const saved = service.external.saveInstance({ name: 'RAGFlow', provider: 'ragflow', baseUrl: 'https://kb.example', enabled: true, credential: { action: 'replace', value: 'test-key' } })
const saved = (await service.external.saveInstance({ name: 'RAGFlow', provider: 'ragflow', baseUrl: 'https://kb.example', enabled: true, credential: { action: 'replace', value: 'test-key' } }))
await expect(service.external.testRetrieval({ ...retrievalInput(saved.id), providerConfig: {
provider: 'ragflow', similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 10, useKg: true, includeKnowledgeCompilation: false
} })).rejects.toMatchObject({ code: 'EXTERNAL_KB_TIMEOUT' })
+57 -43
View File
@@ -20,7 +20,7 @@ import {
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../../settings-credential-cipher'
import type { KnowledgeDatabase } from '../knowledge-database'
import type { KnowledgeStoragePort } from '../knowledge-storage-port'
import { ExternalKnowledgeClient, ExternalKnowledgeError } from './external-knowledge-client'
import type { StoredExternalInstance } from './external-knowledge-store'
@@ -31,7 +31,7 @@ export class ExternalKnowledgeService {
private readonly controllers = new Map<AbortController, string>()
constructor(
private readonly database: KnowledgeDatabase,
private readonly database: Pick<KnowledgeStoragePort, 'externalStore' | 'saveExternalBinding'>,
private readonly cipher?: SettingsCredentialCipher,
private readonly fetcher?: typeof fetch
) {}
@@ -58,9 +58,10 @@ export class ExternalKnowledgeService {
}
}
private instance(id: string): StoredExternalInstance {
private async instance(id: string): Promise<StoredExternalInstance> {
this.checkActive()
const value = await this.database.externalStore.getInstance(id)
this.checkActive()
const value = this.database.externalStore.getInstance(id)
if (!value) {
throw new Error('EXTERNAL_KB_NOT_FOUND')
}
@@ -82,13 +83,13 @@ export class ExternalKnowledgeService {
}
}
listInstances(): ExternalKnowledgeInstanceSummary[] {
async listInstances(): Promise<ExternalKnowledgeInstanceSummary[]> {
this.checkActive()
const bindingCounts = new Map<string, number>()
for (const binding of this.database.externalStore.listBindings()) {
for (const binding of (await this.database.externalStore.listBindings())) {
bindingCounts.set(binding.instanceId, (bindingCounts.get(binding.instanceId) ?? 0) + 1)
}
return this.database.externalStore.listInstances().map(({ credential, ...value }) => {
return (await this.database.externalStore.listInstances()).map(({ credential, ...value }) => {
let credentialStatus: ExternalKnowledgeInstanceSummary['credentialStatus'] = 'missing'
if (credential) {
try {
@@ -102,22 +103,22 @@ export class ExternalKnowledgeService {
})
}
private summary(id: string): ExternalKnowledgeInstanceSummary {
const summary = this.listInstances().find((item) => item.id === id)
private async summary(id: string): Promise<ExternalKnowledgeInstanceSummary> {
const summary = (await this.listInstances()).find((item) => item.id === id)
if (!summary) {
throw new Error('EXTERNAL_KB_NOT_FOUND')
}
return summary
}
saveInstance(raw: ExternalKnowledgeInstanceSaveInput): ExternalKnowledgeInstanceSummary {
async saveInstance(raw: ExternalKnowledgeInstanceSaveInput): Promise<ExternalKnowledgeInstanceSummary> {
this.checkActive()
const input = externalKnowledgeInstanceSaveInputSchema.parse(raw)
const previous = input.id ? this.instance(input.id) : undefined
const previous = input.id ? (await this.instance(input.id)) : undefined
if (
previous &&
previous.provider !== input.provider &&
this.database.externalStore.getBindingsForInstance(previous.id).length > 0
(await this.database.externalStore.getBindingsForInstance(previous.id)).length > 0
) {
throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
}
@@ -144,28 +145,31 @@ export class ExternalKnowledgeService {
credential,
probeStatus: 'untested'
}
this.database.externalStore.saveInstance(value)
// In-flight requests were authorized against the replaced configuration.
this.cancelInstance(value.id)
return this.summary(value.id)
await this.database.externalStore.saveInstance(value)
this.cancelInstance(value.id)
return await this.summary(value.id)
}
setEnabled(id: string, enabled: boolean): ExternalKnowledgeInstanceSummary {
const instance = this.instance(id)
this.database.externalStore.saveInstance({ ...instance, enabled })
async setEnabled(id: string, enabled: boolean): Promise<ExternalKnowledgeInstanceSummary> {
const instance = await this.instance(id)
if (!enabled) {
this.cancelInstance(id)
}
return this.summary(id)
await this.database.externalStore.saveInstance({ ...instance, enabled })
if (!enabled) this.cancelInstance(id)
return await this.summary(id)
}
deleteInstance(id: string): void {
this.instance(id)
if (this.database.externalStore.getBindingsForInstance(id).length > 0) {
async deleteInstance(id: string): Promise<void> {
await this.instance(id)
if ((await this.database.externalStore.getBindingsForInstance(id)).length > 0) {
throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
}
this.cancelInstance(id)
this.database.externalStore.deleteInstance(id)
await this.database.externalStore.deleteInstance(id)
this.cancelInstance(id)
}
private async request<T>(
@@ -174,11 +178,7 @@ export class ExternalKnowledgeService {
signal: AbortSignal | undefined,
operation: (client: ExternalKnowledgeClient, signal: AbortSignal) => Promise<T>
): Promise<T> {
const instance = this.instance(id)
if (!instance.enabled) {
throw new Error('EXTERNAL_KB_DISABLED')
}
const key = this.credential(instance)
this.checkActive()
const controller = new AbortController()
this.controllers.set(controller, id)
const timeoutSignal = AbortSignal.timeout(timeout)
@@ -188,6 +188,10 @@ export class ExternalKnowledgeService {
...(signal ? [signal] : [])
])
try {
const instance = await this.instance(id)
combined.throwIfAborted()
if (!instance.enabled) throw new Error('EXTERNAL_KB_DISABLED')
const key = this.credential(instance)
combined.throwIfAborted()
return await operation(
new ExternalKnowledgeClient({
@@ -216,11 +220,15 @@ export class ExternalKnowledgeService {
}
async testInstance(id: string): Promise<ExternalKnowledgeInstanceSummary> {
this.instance(id)
this.checkActive()
const controller = new AbortController()
this.controllers.set(controller, id)
try {
await this.instance(id)
let probeStatus: StoredExternalInstance['probeStatus'] = 'catalog-ready'
let lastErrorCode: string | undefined
try {
await this.listCatalog({ instanceId: id })
await this.listCatalog({ instanceId: id }, controller.signal)
} catch (error) {
lastErrorCode =
error instanceof ExternalKnowledgeError
@@ -242,17 +250,22 @@ export class ExternalKnowledgeService {
? 'unreachable'
: 'failed'
}
const current = this.database.externalStore.getInstance(id)
const current = await this.database.externalStore.getInstance(id)
this.checkActive()
if (controller.signal.aborted) throw new ExternalKnowledgeError('EXTERNAL_KB_CANCELLED', 'External knowledge request was cancelled')
if (!current) {
throw new Error('EXTERNAL_KB_NOT_FOUND')
}
this.database.externalStore.saveInstance({
await this.database.externalStore.saveInstance({
...current,
probeStatus,
lastErrorCode,
lastTestedAt: new Date().toISOString()
})
return this.summary(id)
return await this.summary(id)
} finally {
this.controllers.delete(controller)
}
}
listCatalog(
@@ -300,7 +313,7 @@ export class ExternalKnowledgeService {
signal?: AbortSignal
): Promise<ExternalKnowledgeTestResult> {
const input = externalKnowledgeRetrievalInputSchema.parse(raw)
if (this.instance(input.instanceId).provider !== input.providerConfig.provider) {
if ((await this.instance(input.instanceId)).provider !== input.providerConfig.provider) {
throw new Error('EXTERNAL_KB_CONFIG_INVALID')
}
const started = Date.now()
@@ -329,7 +342,7 @@ export class ExternalKnowledgeService {
): Promise<ExternalKnowledgeTestResult> {
const input = externalKnowledgeBindingTestInputSchema.parse(raw)
const config = input.providerConfig
if (this.instance(input.instanceId).provider !== config.provider) {
if ((await this.instance(input.instanceId)).provider !== config.provider) {
throw new Error('EXTERNAL_KB_CONFIG_INVALID')
}
const started = Date.now()
@@ -366,22 +379,22 @@ export class ExternalKnowledgeService {
: undefined
const input = update ?? externalKnowledgeBindingSaveInputSchema.parse(raw)
const existing = update
? this.database.externalStore.getBinding(update.knowledgeBaseId)
? (await this.database.externalStore.getBinding(update.knowledgeBaseId))
: undefined
if (update && !existing) {
throw new Error('EXTERNAL_KB_NOT_FOUND')
}
this.instance(input.instanceId)
const duplicate = (): boolean =>
this.database.externalStore
.listBindings()
await this.instance(input.instanceId)
const duplicate = async (): Promise<boolean> =>
((await this.database.externalStore
.listBindings())
.some(
(item) =>
item.instanceId === input.instanceId &&
item.remoteKnowledgeBaseId === input.remoteKnowledgeBaseId &&
item.knowledgeBaseId !== existing?.knowledgeBaseId
)
if (duplicate()) {
))
if (await duplicate()) {
throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
}
await this.testRetrieval({
@@ -393,10 +406,11 @@ export class ExternalKnowledgeService {
})
// Another binding for the same remote target may have landed while the
// verification request was in flight; the unique index is the final guard.
if (duplicate()) {
if (await duplicate()) {
throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
}
return this.database.saveExternalBinding(
this.checkActive()
return await this.database.saveExternalBinding(
{
instanceId: input.instanceId,
provider: input.providerConfig.provider,
+1 -37
View File
@@ -47,7 +47,6 @@ import {
knowledgeRetrievalTerms
} from './retrieval-text'
import { normalizeEntityAlias } from './graph-extractor'
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
import type {
Chunk,
ChunkEmbeddingInput,
@@ -743,7 +742,6 @@ function mapKnowledgeTask(row: Row): KnowledgeTaskItem {
export class KnowledgeDatabase {
externalStore!: ExternalKnowledgeStore
private database?: DatabaseSync
private readonlyReader?: ReadonlyQueryReader
private readonly taskPrunedLibraries = new Set<string>()
private readonly taskPrunePending = new Set<string>()
@@ -783,7 +781,7 @@ export class KnowledgeDatabase {
/**
* Opens a read-only connection for the readonly query worker. No migration,
* no task recovery: the Main connection owns the schema and all writes.
* no task recovery: the storage owner owns the schema and all writes.
*/
openReadOnly(): void {
if (this.database) {
@@ -797,41 +795,7 @@ export class KnowledgeDatabase {
this.externalStore = new ExternalKnowledgeStore(database)
}
/**
* Routes the async search methods through a read-only worker thread. In-memory
* databases cannot be shared with a worker and keep the synchronous path.
*/
enableReadonlyWorker(workerPath: string): void {
if (this.databasePath === ':memory:' || this.readonlyReader) return
this.readonlyReader = new ReadonlyQueryReader('knowledge', this.databasePath, workerPath)
}
/** Test hook for the worker crash and cancellation paths. */
get readonlyWorkerForTest(): ReadonlyQueryReader | undefined {
return this.readonlyReader
}
searchAsync(options: SearchOptions, signal?: AbortSignal): Promise<SearchResult[]> {
this.requireDatabase()
return readWithFallback(this.readonlyReader, 'search', [options], signal,
() => this.search(options))
}
hybridSearchAsync(options: HybridSearchOptions): Promise<HybridSearchResult[]> {
return this.hybridSearchWithDiagnosticsAsync(options).then(page => page.results)
}
hybridSearchWithDiagnosticsAsync(options: HybridSearchOptions): Promise<HybridSearchResultPage> {
this.requireDatabase()
// AbortSignal is not cloneable; the reader forwards it as a shared cancel flag.
const { signal, ...cloneable } = options
return readWithFallback(this.readonlyReader, 'hybridSearchWithDiagnostics', [cloneable], signal,
() => this.hybridSearchWithDiagnostics(options))
}
close(): void {
this.readonlyReader?.close()
this.readonlyReader = undefined
if (!this.database) {
return
}
@@ -0,0 +1,66 @@
import { expect, it, vi } from 'vitest'
import { KnowledgeEmbeddingIndexRepository } from './knowledge-embedding-index-repository'
import type { EmbeddingIndexState } from './types'
function fixture() {
const state: EmbeddingIndexState = {
documentId: 'document', knowledgeBaseId: 'library', provider: 'provider',
model: 'model', contentChecksum: 'checksum', status: 'ready',
updatedAt: '2026-10-05T00:00:00.000Z'
}
const database = {
getLastEmbeddingIndexJob: () => null,
saveEmbeddingIndexJob: vi.fn(async () => undefined),
listEmbeddingIndexDocumentIds: () => ['document'],
getEmbeddingIndexDocument: () => ({ id: 'document', items: [] }),
beginDocumentEmbeddingReplacement: vi.fn(async () => 'replacement'),
appendDocumentEmbeddingBatch: vi.fn(async () => undefined),
finishDocumentEmbeddingReplacement: vi.fn(async () => state),
discardDocumentEmbeddingReplacement: vi.fn(async () => undefined),
recordEmbeddingIndexError: vi.fn(async (): Promise<EmbeddingIndexState> => ({ ...state, status: 'error' }))
}
return { database, state, repository: new KnowledgeEmbeddingIndexRepository(database, 'library') }
}
it('does not acknowledge a batch before its asynchronous storage commit', async () => {
const { database, repository } = fixture()
let commit!: () => void
database.appendDocumentEmbeddingBatch.mockImplementation(() => new Promise(resolve => { commit = () => resolve(undefined) }))
const settled = vi.fn()
const pending = repository.appendDocumentReplacement('replacement', 'document', 'provider', 'model', [
{ itemId: 'chunk', contentChecksum: 'checksum', vector: [1, 0] }
], new AbortController().signal).then(settled)
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
expect(database.appendDocumentEmbeddingBatch).toHaveBeenCalledWith('replacement', 'document', 'provider', 'model', [
{ chunkId: 'chunk', contentChecksum: 'checksum', vector: [1, 0] }
])
commit()
await pending
expect(settled).toHaveBeenCalledOnce()
})
it('returns an admitted replacement for cleanup even when cancellation arrives with its reply', async () => {
const { database, repository } = fixture()
const controller = new AbortController()
database.beginDocumentEmbeddingReplacement.mockImplementation(async () => {
controller.abort()
return 'replacement'
})
await expect(repository.beginDocumentReplacement('document', 'provider', 'model', controller.signal)).resolves.toBe('replacement')
await repository.discardDocumentReplacement('replacement')
expect(database.discardDocumentEmbeddingReplacement).toHaveBeenCalledWith('replacement')
})
it('reports a committed finish as success when cancellation arrives too late', async () => {
const { database, state, repository } = fixture()
const controller = new AbortController()
database.finishDocumentEmbeddingReplacement.mockImplementation(async () => { controller.abort(); return state })
await expect(repository.finishDocumentReplacement('replacement', 'document', 'provider', 'model', controller.signal)).resolves.toBeUndefined()
})
it('propagates storage exit failures instead of acknowledging the status write', async () => {
const { database, repository } = fixture()
database.saveEmbeddingIndexJob.mockRejectedValue(new Error('storage exited'))
await expect(repository.saveStatus({ job: null })).rejects.toThrow('storage exited')
})
@@ -3,21 +3,26 @@ import type {
EmbeddingIndexRecord,
EmbeddingIndexRepository
} from './embedding-index-coordinator'
import type { KnowledgeDatabase } from './knowledge-database'
import type { KnowledgeStoragePort } from './knowledge-storage-port'
export class KnowledgeEmbeddingIndexRepository
implements EmbeddingIndexRepository {
constructor(
private readonly database: KnowledgeDatabase,
private readonly database: Pick<KnowledgeStoragePort,
'getLastEmbeddingIndexJob' | 'saveEmbeddingIndexJob' |
'listEmbeddingIndexDocumentIds' | 'getEmbeddingIndexDocument' |
'beginDocumentEmbeddingReplacement' | 'appendDocumentEmbeddingBatch' |
'finishDocumentEmbeddingReplacement' | 'discardDocumentEmbeddingReplacement' |
'recordEmbeddingIndexError'>,
private readonly knowledgeBaseId: string
) {}
async getLastJob(): Promise<EmbeddingIndexStatus['job']> {
return this.database.getLastEmbeddingIndexJob(this.knowledgeBaseId)
return await this.database.getLastEmbeddingIndexJob(this.knowledgeBaseId)
}
async saveStatus(status: EmbeddingIndexStatus): Promise<void> {
this.database.saveEmbeddingIndexJob(
await this.database.saveEmbeddingIndexJob(
this.knowledgeBaseId,
status.job
)
@@ -26,7 +31,7 @@ implements EmbeddingIndexRepository {
async listIndexDocumentIds(signal: AbortSignal) {
signal.throwIfAborted()
const documentIds =
this.database.listEmbeddingIndexDocumentIds(
await this.database.listEmbeddingIndexDocumentIds(
this.knowledgeBaseId
)
signal.throwIfAborted()
@@ -39,7 +44,7 @@ implements EmbeddingIndexRepository {
) {
signal.throwIfAborted()
const document =
this.database.getEmbeddingIndexDocument(documentId)
await this.database.getEmbeddingIndexDocument(documentId)
signal.throwIfAborted()
return document
}
@@ -52,12 +57,11 @@ implements EmbeddingIndexRepository {
): Promise<string> {
signal.throwIfAborted()
const replacementId =
this.database.beginDocumentEmbeddingReplacement(
await this.database.beginDocumentEmbeddingReplacement(
documentId,
provider,
model
)
signal.throwIfAborted()
return replacementId
}
@@ -70,7 +74,7 @@ implements EmbeddingIndexRepository {
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
this.database.appendDocumentEmbeddingBatch(
await this.database.appendDocumentEmbeddingBatch(
replacementId,
documentId,
provider,
@@ -92,19 +96,18 @@ implements EmbeddingIndexRepository {
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
this.database.finishDocumentEmbeddingReplacement(
await this.database.finishDocumentEmbeddingReplacement(
replacementId,
documentId,
provider,
model
)
signal.throwIfAborted()
}
async discardDocumentReplacement(
replacementId: string
): Promise<void> {
this.database.discardDocumentEmbeddingReplacement(
await this.database.discardDocumentEmbeddingReplacement(
replacementId
)
}
@@ -115,7 +118,7 @@ implements EmbeddingIndexRepository {
model: string,
error: string
): Promise<void> {
this.database.recordEmbeddingIndexError(
await this.database.recordEmbeddingIndexError(
documentId,
provider,
model,
+52 -52
View File
@@ -107,11 +107,11 @@ const knowledgeUpdateRelationSchema = z
})
.strict()
function getKnowledgeSnapshot(
async function getKnowledgeSnapshot(
service: KnowledgeService,
selectedLibraryId?: string
): KnowledgeSnapshot {
const snapshot = service.snapshot(selectedLibraryId)
): Promise<KnowledgeSnapshot> {
const snapshot = await service.snapshot(selectedLibraryId)
const activeLibraryId =
selectedLibraryId ?? snapshot.libraries[0]?.id
const documentsById = new Map(
@@ -134,9 +134,9 @@ function getKnowledgeSnapshot(
}
}
const bindingsByLibraryId = new Map(
service.database.externalStore
.listBindings()
.map((binding) => [binding.knowledgeBaseId, binding])
((await service.database.externalStore
.listBindings())
.map((binding) => [binding.knowledgeBaseId, binding]))
)
return {
libraries: snapshot.libraries.map((library) => ({
@@ -239,33 +239,33 @@ export function registerKnowledgeIpcHandlers(
knowledgeService: KnowledgeService,
settingsStore: RuntimeSettingsStore
): void {
registerHandler(ipcChannels.externalInstancesList, (event) => {assertTrustedSender(event,window);return knowledgeService.external.listInstances()})
registerHandler(ipcChannels.externalInstancesSave, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.saveInstance(externalKnowledgeInstanceSaveInputSchema.parse(input))})
registerHandler(ipcChannels.externalInstancesList, async (event) => {assertTrustedSender(event,window);return await knowledgeService.external.listInstances()})
registerHandler(ipcChannels.externalInstancesSave, async (event,input:unknown) => {assertTrustedSender(event,window);return await knowledgeService.external.saveInstance(externalKnowledgeInstanceSaveInputSchema.parse(input))})
registerHandler(ipcChannels.externalInstancesTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
registerHandler(ipcChannels.externalInstancesSetEnabled, (event,input:unknown) => {assertTrustedSender(event,window);const value=externalKnowledgeInstanceEnabledInputSchema.parse(input);return knowledgeService.external.setEnabled(value.instanceId,value.enabled)})
registerHandler(ipcChannels.externalInstancesDelete, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.deleteInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
registerHandler(ipcChannels.externalInstancesSetEnabled, async (event,input:unknown) => {assertTrustedSender(event,window);const value=externalKnowledgeInstanceEnabledInputSchema.parse(input);return await knowledgeService.external.setEnabled(value.instanceId,value.enabled)})
registerHandler(ipcChannels.externalInstancesDelete, async (event,input:unknown) => {assertTrustedSender(event,window);return await knowledgeService.external.deleteInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
registerHandler(ipcChannels.externalCatalogList, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.listCatalog(externalKnowledgeCatalogListInputSchema.parse(input))})
registerHandler(ipcChannels.externalCatalogGet, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.getCatalog(externalKnowledgeCatalogGetInputSchema.parse(input))})
registerHandler(ipcChannels.externalRetrievalTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testRetrieval(externalKnowledgeBindingTestInputSchema.parse(input))})
registerHandler(ipcChannels.externalBindingsCreate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingSaveInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
registerHandler(ipcChannels.externalBindingsUpdate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingUpdateInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
registerHandler(ipcChannels.knowledgeSnapshot, (event, input: unknown) => {
registerHandler(ipcChannels.externalBindingsCreate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingSaveInputSchema.parse(input));return await getKnowledgeSnapshot(knowledgeService)})
registerHandler(ipcChannels.externalBindingsUpdate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingUpdateInputSchema.parse(input));return await getKnowledgeSnapshot(knowledgeService)})
registerHandler(ipcChannels.knowledgeSnapshot, async (event, input: unknown) => {
assertTrustedSender(event, window)
const libraryId =
input === undefined ? undefined : knowledgeIdSchema.parse(input)
return getKnowledgeSnapshot(knowledgeService, libraryId)
return await getKnowledgeSnapshot(knowledgeService, libraryId)
})
registerHandler(
ipcChannels.knowledgeCreateLibrary,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeCreateSchema.parse(input)
const library = knowledgeService.createLibrary(value)
const created = getKnowledgeSnapshot(
const library = await knowledgeService.createLibrary(value)
const created = (await getKnowledgeSnapshot(
knowledgeService,
library.id
).libraries.find((item) => item.id === library.id)
)).libraries.find((item) => item.id === library.id)
if (!created) {
throw new Error('知识库创建失败')
}
@@ -283,10 +283,10 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeUpdateLibrary,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeUpdateLibrarySchema.parse(input)
knowledgeService.database.updateKnowledgeBase(value.libraryId, {
await knowledgeService.database.updateKnowledgeBase(value.libraryId, {
name: value.name,
description: value.description,
graphEnabled: value.graphEnabled,
@@ -382,7 +382,7 @@ export function registerKnowledgeIpcHandlers(
],
[
ipcChannels.knowledgePauseSource,
(id: string) => knowledgeService.pauseSource(id)
async (id: string) => (await knowledgeService.pauseSource(id))
],
[
ipcChannels.knowledgeRetrySource,
@@ -406,17 +406,17 @@ export function registerKnowledgeIpcHandlers(
return []
}
const availableLibraries =
knowledgeService.database.listKnowledgeBases(100)
await knowledgeService.database.listKnowledgeBases(100)
const libraries = [...new Set(value.libraryIds)]
const names = new Map(
availableLibraries.map((library) => [library.id, library.name])
)
const results = (
const results =
await knowledgeService.retrieveMany(
libraries,
value.query
)
)
const failures = results.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
if (failures.length === results.length) throw new Error(failures.join('; '))
const references = results.flatMap(({ knowledgeBaseId, response }) => response.results.map(result => ({
@@ -442,14 +442,14 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeUpdateSettings,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeSettingsUpdateInputSchema.parse(input)
knowledgeService.updateSettings(value)
const library = getKnowledgeSnapshot(
await knowledgeService.updateSettings(value)
const library = (await getKnowledgeSnapshot(
knowledgeService,
value.knowledgeBaseId
).libraries.find((item) => item.id === value.knowledgeBaseId)
)).libraries.find((item) => item.id === value.knowledgeBaseId)
if (!library) {
throw new Error('知识库不存在')
}
@@ -459,9 +459,9 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeListChunks,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const page = knowledgeService.listChunks(
const page = await knowledgeService.listChunks(
knowledgeChunksListInputSchema.parse(input)
)
return knowledgeChunkPageSchema.parse({
@@ -514,7 +514,7 @@ export function registerKnowledgeIpcHandlers(
assertTrustedSender(event, window)
const value = knowledgeDocumentRebuildInputSchema.parse(input)
await knowledgeService.rebuildDocument(value)
return getKnowledgeSnapshot(
return await getKnowledgeSnapshot(
knowledgeService,
value.knowledgeBaseId
)
@@ -633,7 +633,7 @@ export function registerKnowledgeIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeDocumentOpenInputSchema.parse(input)
const reference = knowledgeService.getDocumentSource(value)
const reference = await knowledgeService.getDocumentSource(value)
if (!reference) {
throw new Error('文档来源不存在')
}
@@ -666,10 +666,10 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeReferenceContext,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeReferenceContextInputSchema.parse(input)
const reference = knowledgeService.getReferenceContext(value)
const reference = await knowledgeService.getReferenceContext(value)
if (!reference) {
throw new Error('引用上下文不存在或已停用')
}
@@ -698,7 +698,7 @@ export function registerKnowledgeIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeReferenceOpenInputSchema.parse(input)
const reference = knowledgeService.getReferenceContext(value)
const reference = await knowledgeService.getReferenceContext(value)
if (!reference) {
throw new Error('引用来源不存在或已停用')
}
@@ -732,10 +732,10 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeCreateEntity,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeCreateEntitySchema.parse(input)
knowledgeService.database.createEntity({
await knowledgeService.database.createEntity({
knowledgeBaseId: value.libraryId,
name: value.input.label,
type: value.input.type,
@@ -748,10 +748,10 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeUpdateEntity,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeEntityPayloadSchema.parse(input)
knowledgeService.database.updateEntity(value.entityId, {
await knowledgeService.database.updateEntity(value.entityId, {
name: value.update.label,
type: value.update.type,
description: value.update.description || null,
@@ -763,14 +763,14 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeMoveEntity,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeMoveEntitySchema.parse(input)
const entity = knowledgeService.database.getEntity(value.entityId)
const entity = await knowledgeService.database.getEntity(value.entityId)
if (!entity) {
throw new Error('图谱实体不存在')
}
knowledgeService.database.updateEntity(entity.id, {
await knowledgeService.database.updateEntity(entity.id, {
properties: {
...entity.properties,
x: value.position.x,
@@ -782,18 +782,18 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeDeleteEntity,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
knowledgeService.database.deleteEntity(knowledgeIdSchema.parse(input))
await knowledgeService.database.deleteEntity(knowledgeIdSchema.parse(input))
}
)
registerHandler(
ipcChannels.knowledgeMergeEntities,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeMergeSchema.parse(input)
knowledgeService.database.mergeEntities(
await knowledgeService.database.mergeEntities(
value.targetEntityId,
value.sourceEntityId
)
@@ -802,10 +802,10 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeCreateRelation,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeCreateRelationSchema.parse(input)
knowledgeService.database.createRelation({
await knowledgeService.database.createRelation({
knowledgeBaseId: value.libraryId,
sourceEntityId: value.input.sourceId,
targetEntityId: value.input.targetId,
@@ -818,10 +818,10 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeUpdateRelation,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeUpdateRelationSchema.parse(input)
knowledgeService.database.updateRelation(value.relationId, {
await knowledgeService.database.updateRelation(value.relationId, {
sourceEntityId: value.input.sourceId,
targetEntityId: value.input.targetId,
type: value.input.type,
@@ -833,9 +833,9 @@ export function registerKnowledgeIpcHandlers(
registerHandler(
ipcChannels.knowledgeDeleteRelation,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
knowledgeService.database.deleteRelation(knowledgeIdSchema.parse(input))
await knowledgeService.database.deleteRelation(knowledgeIdSchema.parse(input))
}
)
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { KnowledgeService } from './knowledge-service'
import { TestKnowledgeService as KnowledgeService } from '../../../tests/support/knowledge-test-service'
import { OpenAIEmbeddingClient } from './openai-embedding-client'
const endpoint =
@@ -38,11 +38,11 @@ describe('live knowledge embeddings', () => {
'在没有网络的环境中,先准备经过校验的安装包,再导入本地部署。',
'utf8'
)
const library = service.createLibrary({
const library = (await service.createLibrary({
name: 'Live embedding test',
storageMode: 'reference',
graphEnabled: false
})
}))
await service.importPaths(library.id, [sourcePath])
const response = await service.retrieve({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import type { DesktopStorageClient } from '../desktop-storage-client'
import type { DesktopStorageDomains } from '../desktop-storage-contracts'
import type { replaceDocumentGraph } from '../desktop-storage-knowledge-operations'
export type Awaitable<T> = T | Promise<T>
type DomainPort<T> = { [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Awaitable<Awaited<R>> : never }
export type KnowledgeStoragePort = DomainPort<Omit<DesktopStorageDomains['knowledge'], 'replaceEvidenceForDocument'>> & {
externalStore: DomainPort<DesktopStorageDomains['external']>
replaceDocumentGraph(...args: Parameters<typeof replaceDocumentGraph> extends [unknown, ...infer A] ? A : never): Awaitable<void>
}
/** Explicit domain calls; no live database or transaction callback crosses Main. */
export function createKnowledgeStoragePort(storage: Pick<DesktopStorageClient, 'call'>): KnowledgeStoragePort {
return {
createKnowledgeBase: (...args) => storage.call('knowledge', 'createKnowledgeBase', args),
listKnowledgeBases: (...args) => storage.call('knowledge', 'listKnowledgeBases', args),
getKnowledgeBaseCounts: (...args) => storage.call('knowledge', 'getKnowledgeBaseCounts', args),
getKnowledgeBase: (...args) => storage.call('knowledge', 'getKnowledgeBase', args),
saveExternalBinding: (...args) => storage.call('knowledge', 'saveExternalBinding', args),
updateKnowledgeBase: (...args) => storage.call('knowledge', 'updateKnowledgeBase', args),
updateKnowledgeSettings: (...args) => storage.call('knowledge', 'updateKnowledgeSettings', args),
markKnowledgeChunkingRebuilt: (...args) => storage.call('knowledge', 'markKnowledgeChunkingRebuilt', args),
markKnowledgeOntologyRebuilt: (...args) => storage.call('knowledge', 'markKnowledgeOntologyRebuilt', args),
deleteKnowledgeBase: (...args) => storage.call('knowledge', 'deleteKnowledgeBase', args),
createKnowledgeTask: (...args) => storage.call('knowledge', 'createKnowledgeTask', args),
getKnowledgeTask: (...args) => storage.call('knowledge', 'getKnowledgeTask', args),
getActiveKnowledgeTaskByDedupeKey: (...args) => storage.call('knowledge', 'getActiveKnowledgeTaskByDedupeKey', args),
getKnowledgeTaskByEmbeddingJobId: (...args) => storage.call('knowledge', 'getKnowledgeTaskByEmbeddingJobId', args),
listKnowledgeTasks: (...args) => storage.call('knowledge', 'listKnowledgeTasks', args),
listActiveKnowledgeTasks: (...args) => storage.call('knowledge', 'listActiveKnowledgeTasks', args),
updateKnowledgeTask: (...args) => storage.call('knowledge', 'updateKnowledgeTask', args),
cancelKnowledgeTask: (...args) => storage.call('knowledge', 'cancelKnowledgeTask', args),
interruptActiveKnowledgeTasks: (...args) => storage.call('knowledge', 'interruptActiveKnowledgeTasks', args),
pruneKnowledgeTasks: (...args) => storage.call('knowledge', 'pruneKnowledgeTasks', args),
upsertSource: (...args) => storage.call('knowledge', 'upsertSource', args),
listSources: (...args) => storage.call('knowledge', 'listSources', args),
listSourcesForSnapshot: (...args) => storage.call('knowledge', 'listSourcesForSnapshot', args),
getSource: (...args) => storage.call('knowledge', 'getSource', args),
removeSource: (...args) => storage.call('knowledge', 'removeSource', args),
upsertDocument: (...args) => storage.call('knowledge', 'upsertDocument', args),
getDocumentByParsedResultId: (...args) => storage.call('knowledge', 'getDocumentByParsedResultId', args),
getDocument: (...args) => storage.call('knowledge', 'getDocument', args),
listDocuments: (...args) => storage.call('knowledge', 'listDocuments', args),
listDocumentsForSource: (...args) => storage.call('knowledge', 'listDocumentsForSource', args),
listDocumentsForLibraryRebuild: (...args) => storage.call('knowledge', 'listDocumentsForLibraryRebuild', args),
listDocumentsForSnapshot: (...args) => storage.call('knowledge', 'listDocumentsForSnapshot', args),
getDocumentChunkCounts: (...args) => storage.call('knowledge', 'getDocumentChunkCounts', args),
removeDocument: (...args) => storage.call('knowledge', 'removeDocument', args),
removeEvidenceForDocument: (...args) => storage.call('knowledge', 'removeEvidenceForDocument', args),
pruneUnreferencedGeneratedGraph: (...args) => storage.call('knowledge', 'pruneUnreferencedGeneratedGraph', args),
listChunks: (...args) => storage.call('knowledge', 'listChunks', args),
listChunksPage: (...args) => storage.call('knowledge', 'listChunksPage', args),
getChunkForReference: (...args) => storage.call('knowledge', 'getChunkForReference', args),
updateChunk: (...args) => storage.call('knowledge', 'updateChunk', args),
deleteChunk: (...args) => storage.call('knowledge', 'deleteChunk', args),
listContextChunks: (...args) => storage.call('knowledge', 'listContextChunks', args),
replaceDocumentEmbeddings: (...args) => storage.call('knowledge', 'replaceDocumentEmbeddings', args),
beginDocumentEmbeddingReplacement: (...args) => storage.call('knowledge', 'beginDocumentEmbeddingReplacement', args),
beginPreparedDocumentEmbeddingReplacement: (...args) => storage.call('knowledge', 'beginPreparedDocumentEmbeddingReplacement', args),
appendDocumentEmbeddingBatch: (...args) => storage.call('knowledge', 'appendDocumentEmbeddingBatch', args),
appendPreparedDocumentEmbeddingBatch: (...args) => storage.call('knowledge', 'appendPreparedDocumentEmbeddingBatch', args),
finishDocumentEmbeddingReplacement: (...args) => storage.call('knowledge', 'finishDocumentEmbeddingReplacement', args),
discardDocumentEmbeddingReplacement: (...args) => storage.call('knowledge', 'discardDocumentEmbeddingReplacement', args),
recordEmbeddingIndexError: (...args) => storage.call('knowledge', 'recordEmbeddingIndexError', args),
getEmbeddingIndexState: (...args) => storage.call('knowledge', 'getEmbeddingIndexState', args),
getLastEmbeddingIndexJob: (...args) => storage.call('knowledge', 'getLastEmbeddingIndexJob', args),
saveEmbeddingIndexJob: (...args) => storage.call('knowledge', 'saveEmbeddingIndexJob', args),
listEmbeddingIndexDocumentIds: (...args) => storage.call('knowledge', 'listEmbeddingIndexDocumentIds', args),
countEmbeddingIndexDocuments: (...args) => storage.call('knowledge', 'countEmbeddingIndexDocuments', args),
getEmbeddingIndexCoverage: (...args) => storage.call('knowledge', 'getEmbeddingIndexCoverage', args),
getEmbeddingIndexDocument: (...args) => storage.call('knowledge', 'getEmbeddingIndexDocument', args),
vectorSearch: ({ signal, ...input }) => storage.call('knowledge', 'vectorSearch', [input], { signal }),
graphSearch: (...args) => storage.call('knowledge', 'graphSearch', args),
hybridSearch: ({ signal, ...input }) => storage.call('knowledge', 'hybridSearch', [input], { signal }),
hybridSearchWithDiagnostics: ({ signal, ...input }) => storage.call('knowledge', 'hybridSearchWithDiagnostics', [input], { signal }),
search: (...args) => storage.call('knowledge', 'search', args),
createEntity: (...args) => storage.call('knowledge', 'createEntity', args),
getEntity: (...args) => storage.call('knowledge', 'getEntity', args),
listEntities: (...args) => storage.call('knowledge', 'listEntities', args),
findEntityByCanonicalName: (...args) => storage.call('knowledge', 'findEntityByCanonicalName', args),
listEntitiesForIdentity: (...args) => storage.call('knowledge', 'listEntitiesForIdentity', args),
updateEntity: (...args) => storage.call('knowledge', 'updateEntity', args),
deleteEntity: (...args) => storage.call('knowledge', 'deleteEntity', args),
createRelation: (...args) => storage.call('knowledge', 'createRelation', args),
getRelation: (...args) => storage.call('knowledge', 'getRelation', args),
listRelations: (...args) => storage.call('knowledge', 'listRelations', args),
findRelationByIdentity: (...args) => storage.call('knowledge', 'findRelationByIdentity', args),
updateRelation: (...args) => storage.call('knowledge', 'updateRelation', args),
deleteRelation: (...args) => storage.call('knowledge', 'deleteRelation', args),
createEvidence: (...args) => storage.call('knowledge', 'createEvidence', args),
listEvidence: (...args) => storage.call('knowledge', 'listEvidence', args),
listGraphSnapshot: (...args) => storage.call('knowledge', 'listGraphSnapshot', args),
updateEvidence: (...args) => storage.call('knowledge', 'updateEvidence', args),
deleteEvidence: (...args) => storage.call('knowledge', 'deleteEvidence', args),
mergeEntities: (...args) => storage.call('knowledge', 'mergeEntities', args),
publishDocument: (...args) => storage.call('knowledge', 'publishDocument', args),
replaceDocumentGraph: (...args) => storage.call('knowledge', 'replaceDocumentGraph', args),
externalStore: {
getInstance: (...args) => storage.call('external', 'getInstance', args),
listInstances: (...args) => storage.call('external', 'listInstances', args),
saveInstance: (...args) => storage.call('external', 'saveInstance', args),
deleteInstance: (...args) => storage.call('external', 'deleteInstance', args),
getBinding: (...args) => storage.call('external', 'getBinding', args),
hasBinding: (...args) => storage.call('external', 'hasBinding', args),
getBindingsForInstance: (...args) => storage.call('external', 'getBindingsForInstance', args),
listBindings: (...args) => storage.call('external', 'listBindings', args),
saveBinding: (...args) => storage.call('external', 'saveBinding', args),
}
}
}