forked from github/quartz
183 lines
4.6 KiB
TypeScript
183 lines
4.6 KiB
TypeScript
export type SemanticResult = { id: number; score: number }
|
|
|
|
type ProgressMessage = {
|
|
type: "progress"
|
|
loadedRows: number
|
|
totalRows: number
|
|
}
|
|
|
|
type ReadyMessage = { type: "ready" }
|
|
|
|
type ResultMessage = {
|
|
type: "search-result"
|
|
seq: number
|
|
semantic: SemanticResult[]
|
|
}
|
|
|
|
type ErrorMessage = { type: "error"; seq?: number; message: string }
|
|
|
|
type SearchPayload = {
|
|
semantic: SemanticResult[]
|
|
}
|
|
|
|
type PendingResolver = {
|
|
resolve: (payload: SearchPayload) => void
|
|
reject: (err: Error) => void
|
|
}
|
|
|
|
export class SemanticClient {
|
|
private ready: Promise<void>
|
|
private resolveReady!: () => void
|
|
private worker: Worker | null = null
|
|
private pending = new Map<number, PendingResolver>()
|
|
private seq = 0
|
|
private disposed = false
|
|
private readySettled = false
|
|
private configured = false
|
|
private lastError: Error | null = null
|
|
|
|
constructor(private cfg?: any) {
|
|
this.ready = new Promise((resolve) => {
|
|
this.resolveReady = () => {
|
|
if (this.readySettled) return
|
|
this.readySettled = true
|
|
resolve()
|
|
}
|
|
})
|
|
|
|
if (this.cfg?.enable === false) {
|
|
this.lastError = new Error("semantic search disabled by configuration")
|
|
this.resolveReady()
|
|
return
|
|
}
|
|
|
|
this.boot()
|
|
}
|
|
|
|
private boot() {
|
|
try {
|
|
this.worker = new Worker("/semantic.worker.js", { type: "module" })
|
|
} catch (err) {
|
|
this.handleFatal(err)
|
|
return
|
|
}
|
|
this.setupWorker()
|
|
this.startInit()
|
|
}
|
|
|
|
private setupWorker() {
|
|
if (!this.worker) return
|
|
this.worker.onmessage = (
|
|
event: MessageEvent<ProgressMessage | ReadyMessage | ResultMessage | ErrorMessage>,
|
|
) => {
|
|
const msg = event.data
|
|
if (msg.type === "progress") {
|
|
// Progress updates during initialization - can be logged if needed
|
|
return
|
|
}
|
|
if (msg.type === "ready") {
|
|
this.configured = true
|
|
this.lastError = null
|
|
this.resolveReady()
|
|
return
|
|
}
|
|
if (msg.type === "search-result") {
|
|
const pending = this.pending.get(msg.seq)
|
|
if (pending) {
|
|
this.pending.delete(msg.seq)
|
|
pending.resolve({ semantic: msg.semantic ?? [] })
|
|
}
|
|
return
|
|
}
|
|
if (msg.type === "error") {
|
|
if (typeof msg.seq === "number") {
|
|
const pending = this.pending.get(msg.seq)
|
|
if (pending) {
|
|
this.pending.delete(msg.seq)
|
|
pending.reject(new Error(msg.message))
|
|
}
|
|
} else {
|
|
this.handleFatal(msg.message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private startInit() {
|
|
if (!this.worker) return
|
|
const manifestUrl =
|
|
typeof this.cfg?.manifestUrl === "string" && this.cfg.manifestUrl.length > 0
|
|
? this.cfg.manifestUrl
|
|
: "/embeddings/manifest.json"
|
|
const disableCache = Boolean(this.cfg?.disableCache)
|
|
const baseUrl =
|
|
typeof this.cfg?.manifestBaseUrl === "string" ? this.cfg.manifestBaseUrl : undefined
|
|
this.worker.postMessage({
|
|
type: "init",
|
|
cfg: this.cfg,
|
|
manifestUrl,
|
|
baseUrl,
|
|
disableCache,
|
|
})
|
|
}
|
|
|
|
private rejectAll(err: Error, fatal = false) {
|
|
for (const [id, pending] of this.pending.entries()) {
|
|
pending.reject(err)
|
|
this.pending.delete(id)
|
|
}
|
|
if (fatal) {
|
|
this.lastError = err
|
|
this.configured = false
|
|
if (!this.readySettled) {
|
|
this.resolveReady()
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleFatal(err: unknown) {
|
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
console.error("[SemanticClient] initialization failure:", error)
|
|
this.rejectAll(error, true)
|
|
if (this.worker) {
|
|
this.worker.postMessage({ type: "reset" })
|
|
this.worker.terminate()
|
|
this.worker = null
|
|
}
|
|
}
|
|
|
|
async ensureReady() {
|
|
await this.ready
|
|
if (!this.configured) {
|
|
throw this.lastError ?? new Error("semantic search unavailable")
|
|
}
|
|
}
|
|
|
|
async search(text: string, k: number): Promise<SearchPayload> {
|
|
if (this.disposed) {
|
|
throw new Error("semantic client has been disposed")
|
|
}
|
|
await this.ensureReady()
|
|
if (!this.worker || !this.configured) {
|
|
throw this.lastError ?? new Error("worker unavailable")
|
|
}
|
|
return new Promise<SearchPayload>((resolve, reject) => {
|
|
const seq = ++this.seq
|
|
this.pending.set(seq, { resolve, reject })
|
|
this.worker?.postMessage({ type: "search", text, k, seq })
|
|
})
|
|
}
|
|
|
|
dispose() {
|
|
if (this.disposed) return
|
|
this.disposed = true
|
|
this.rejectAll(new Error("semantic client disposed"))
|
|
if (this.worker) {
|
|
this.worker.postMessage({ type: "reset" })
|
|
this.worker.terminate()
|
|
}
|
|
this.worker = null
|
|
this.configured = false
|
|
}
|
|
}
|