feat: expand secure assistant workflows

Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 10:04:59 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6ef1795b81
commit b3fdf96962
82 changed files with 17608 additions and 825 deletions
+230 -2
View File
@@ -31,6 +31,8 @@ import type {
GraphStrategy,
GraphEntity,
GraphRelation,
EmbeddingProvider,
HybridSearchResult,
KnowledgeBase,
KnowledgeSource,
SearchResult
@@ -76,12 +78,15 @@ export type KnowledgeServiceOptions = {
managedRoot: string
extractStructured?: ExtractStructured
urlImporter?: UrlImporter
embeddingProvider?: EmbeddingProvider
embeddingBatchSize?: number
}
const supportedExtensions = new Set<string>(supportedDocumentExtensions)
const maximumFileBytes = 20 * 1024 * 1024
const maximumSourceBytes = 500 * 1024 * 1024
const maximumFilesPerSource = 2_000
const maximumEmbeddingChunksPerBatch = 32
function isInside(root: string, candidate: string): boolean {
const path = relative(resolve(root), resolve(candidate))
@@ -114,15 +119,30 @@ export class KnowledgeService {
private readonly managedRoot: string
private readonly extractStructured?: ExtractStructured
private readonly urlImporter: UrlImporter
private embeddingProvider?: EmbeddingProvider
private readonly embeddingBatchSize: number
private readonly watchers = new Map<string, FSWatcher>()
private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>()
private readonly activeSyncs = new Map<string, Promise<void>>()
private readonly lifecycleController = new AbortController()
constructor(options: KnowledgeServiceOptions) {
this.database = new KnowledgeDatabase(options.databasePath)
this.managedRoot = resolve(options.managedRoot)
this.extractStructured = options.extractStructured
this.urlImporter = options.urlImporter ?? new UrlImporter()
this.embeddingProvider = options.embeddingProvider
const embeddingBatchSize = options.embeddingBatchSize ?? 16
if (
!Number.isSafeInteger(embeddingBatchSize) ||
embeddingBatchSize < 1 ||
embeddingBatchSize > maximumEmbeddingChunksPerBatch
) {
throw new RangeError(
`embeddingBatchSize must be between 1 and ${maximumEmbeddingChunksPerBatch}`
)
}
this.embeddingBatchSize = embeddingBatchSize
}
async initialize(): Promise<void> {
@@ -142,6 +162,9 @@ export class KnowledgeService {
}
async dispose(): Promise<void> {
this.lifecycleController.abort(
new Error('Knowledge service is shutting down')
)
for (const timer of this.syncTimers.values()) {
clearTimeout(timer)
}
@@ -154,6 +177,57 @@ export class KnowledgeService {
this.database.close()
}
setEmbeddingProvider(provider?: EmbeddingProvider): Promise<void> {
if (
this.embeddingProvider === provider ||
(this.embeddingProvider?.fingerprint !== undefined &&
this.embeddingProvider.fingerprint === provider?.fingerprint)
) {
this.embeddingProvider = provider
return Promise.resolve()
}
this.embeddingProvider = provider
if (!provider) {
return Promise.resolve()
}
const reindex = this.reindexEmbeddings(provider)
this.activeSyncs.set('embedding-reindex', reindex)
void reindex.then(
() => {
if (this.activeSyncs.get('embedding-reindex') === reindex) {
this.activeSyncs.delete('embedding-reindex')
}
},
() => {
if (this.activeSyncs.get('embedding-reindex') === reindex) {
this.activeSyncs.delete('embedding-reindex')
}
}
)
return reindex
}
private async reindexEmbeddings(
provider: EmbeddingProvider
): Promise<void> {
for (const library of this.database.listKnowledgeBases(100)) {
if (this.embeddingProvider !== provider) {
return
}
for (const document of this.database.listDocuments(
library.id,
500
)) {
if (this.embeddingProvider !== provider) {
return
}
if (document.metadata.status === 'ready') {
await this.indexDocumentEmbeddings(document, provider)
}
}
}
}
createLibrary(input: CreateKnowledgeBaseInput): KnowledgeBase {
return this.database.createKnowledgeBase(input)
}
@@ -256,6 +330,76 @@ export class KnowledgeService {
})
}
async searchHybrid(
knowledgeBaseId: string,
query: string,
limit = 6,
signal?: AbortSignal
): Promise<HybridSearchResult[]> {
const library = this.requireLibrary(knowledgeBaseId)
const vector = await this.embedQuery(query, signal)
return this.database.hybridSearch({
knowledgeBaseId,
query,
limit,
provider: vector ? this.embeddingProvider?.provider : undefined,
model: vector ? this.embeddingProvider?.model : undefined,
vector,
graphEnabled: library.graphEnabled
})
}
async searchHybridMany(
knowledgeBaseIds: readonly string[],
query: string,
limitPerLibrary = 6,
signal?: AbortSignal
): Promise<
Array<{ knowledgeBaseId: string; result: HybridSearchResult }>
> {
const vector = await this.embedQuery(query, signal)
return knowledgeBaseIds.flatMap((knowledgeBaseId) => {
const library = this.requireLibrary(knowledgeBaseId)
return this.database
.hybridSearch({
knowledgeBaseId,
query,
limit: limitPerLibrary,
provider: vector
? this.embeddingProvider?.provider
: undefined,
model: vector ? this.embeddingProvider?.model : undefined,
vector,
graphEnabled: library.graphEnabled
})
.map((result) => ({ knowledgeBaseId, result }))
})
}
private async embedQuery(
query: string,
signal?: AbortSignal
): Promise<readonly number[] | undefined> {
if (!this.embeddingProvider) {
return undefined
}
const effectiveSignal = signal
? AbortSignal.any([signal, this.lifecycleController.signal])
: this.lifecycleController.signal
try {
const result = await this.embeddingProvider.embed(
[query],
effectiveSignal
)
return result.length === 1 ? result[0] : undefined
} catch {
if (effectiveSignal.aborted) {
throw effectiveSignal.reason
}
return undefined
}
}
async importPaths(
knowledgeBaseId: string,
selectedPaths: string[],
@@ -343,7 +487,12 @@ export class KnowledgeService {
const effectiveLibrary = graphStrategy
? { ...library, graphStrategy }
: library
const result = await this.urlImporter.import(input, signal)
const effectiveSignal = AbortSignal.any([
signal,
this.lifecycleController.signal,
AbortSignal.timeout(60_000)
])
const result = await this.urlImporter.import(input, effectiveSignal)
let source = this.database.upsertSource({
id: sourceId,
knowledgeBaseId,
@@ -381,6 +530,7 @@ export class KnowledgeService {
location: chunk.locator
}))
)
await this.indexDocumentEmbeddings(document)
await this.extractGraph(effectiveLibrary, document)
source = this.database.upsertSource({
...source,
@@ -452,7 +602,7 @@ export class KnowledgeService {
await this.importUrl(
library.id,
source.location,
new AbortController().signal,
this.lifecycleController.signal,
source.id
)
return
@@ -543,6 +693,7 @@ export class KnowledgeService {
}))
)
this.database.removeEvidenceForDocument(document.id)
await this.indexDocumentEmbeddings(document)
await this.extractGraph(library, document)
} catch (error) {
failures.push(
@@ -567,6 +718,83 @@ export class KnowledgeService {
}
}
private async indexDocumentEmbeddings(
document: Document,
requestedProvider?: EmbeddingProvider
): Promise<void> {
const provider = requestedProvider ?? this.embeddingProvider
if (!provider) {
return
}
try {
const chunks = this.database.listChunks(document.id, 10_000)
const embeddings: Array<{
chunkId: string
contentChecksum: string
vector: readonly number[]
}> = []
let expectedDimensions: number | undefined
for (
let offset = 0;
offset < chunks.length;
offset += this.embeddingBatchSize
) {
const batch = chunks.slice(offset, offset + this.embeddingBatchSize)
const vectors = await provider.embed(
batch.map((chunk) => chunk.content),
this.lifecycleController.signal
)
if (vectors.length !== batch.length) {
throw new Error('Embedding provider returned an invalid result count')
}
for (let index = 0; index < batch.length; index += 1) {
const chunk = batch[index]
const vector = vectors[index]
if (!chunk || !vector) {
throw new Error('Embedding provider returned an incomplete batch')
}
if (expectedDimensions === undefined) {
expectedDimensions = vector.length
} else if (vector.length !== expectedDimensions) {
throw new Error('Embedding provider returned inconsistent dimensions')
}
embeddings.push({
chunkId: chunk.id,
contentChecksum: createHash('sha256')
.update(chunk.content)
.digest('hex'),
vector
})
}
}
if (this.embeddingProvider !== provider) {
return
}
this.database.replaceDocumentEmbeddings(
document.id,
provider.provider,
provider.model,
embeddings
)
} catch (error) {
if (this.lifecycleController.signal.aborted) {
return
}
const message =
error instanceof Error ? error.message : 'Embedding indexing failed'
try {
this.database.recordEmbeddingIndexError(
document.id,
provider.provider,
provider.model,
message.slice(0, 2_000)
)
} catch {
// FTS indexing is authoritative; embedding diagnostics are best effort.
}
}
}
private async extractGraph(
library: KnowledgeBase,
document: Document