3. O gateway QVAC
Carregar modelos, ingerir chunks, buscar, fazer stream de completions — tudo atrás de uma classe.
src/qvac.ts é o único lugar no LocalLens que conversa com o
QVAC SDK. Todo o resto passa pelo QvacGateway. É assim que
o resto do app fica livre do SDK.
A forma do gateway
import {
close,
completion,
GTE_LARGE_FP16,
loadModel,
QWEN3_1_7B_INST_Q4,
QWEN3_600M_INST_Q4,
ragCloseWorkspace,
ragDeleteWorkspace,
ragIngest,
ragSearch,
} from "@qvac/sdk";
import type { ChatMessage, SearchHit, TextChunk } from "./domain.ts";
const chatModelConfig = { ctx_size: 4096, temp: 0.2, top_p: 0.9 };
export class QvacGateway {
private chatModelId: string | undefined;
private embeddingModelId: string | undefined;
private readyPromise: Promise<void> | undefined;
// … methods below
}Três campos privados, três métodos públicos, mais helpers de ciclo de vida. Essa é a superfície inteira.
Carregamento lazy do modelo
private async ensureReady(): Promise<void> {
if (this.chatModelId && this.embeddingModelId) return;
this.readyPromise ??= this.loadModels().finally(() => {
this.readyPromise = undefined;
});
await this.readyPromise;
}
private async loadModels(): Promise<void> {
this.embeddingModelId ??= await loadModel({ modelSrc: GTE_LARGE_FP16 });
if (this.chatModelId) return;
try {
this.chatModelId = await loadModel({
modelSrc: QWEN3_1_7B_INST_Q4,
modelConfig: chatModelConfig,
});
} catch {
this.chatModelId = await loadModel({
modelSrc: QWEN3_600M_INST_Q4,
modelConfig: chatModelConfig,
});
}
}Duas propriedades lazy:
embeddingModelIdcarrega primeiro e nunca cai para fallback.GTE_LARGE_FP16é pequeno o suficiente para assumir.chatModelIdtentaQWEN3_1_7B_INST_Q4primeiro e cai paraQWEN3_600M_INST_Q4em qualquer falha de carregamento.
readyPromise faz chamadas concorrentes compartilharem um load em voo.
Dois requests chegando no mesmo tick não chamam loadModel
para o mesmo source duas vezes.
Ingerindo chunks
async ingestChunks(workspace: string, chunks: TextChunk[]): Promise<void> {
await this.ensureReady();
await this.closeWorkspace(workspace, true);
if (chunks.length === 0) return;
await ragIngest({
modelId: required(this.embeddingModelId, "QVAC embedding model is not loaded."),
workspace,
documents: chunks.map((chunk) => formatChunkForRag(workspace, chunk)),
chunk: false,
});
}Dois detalhes que importam:
closeWorkspace(workspace, true)é chamado antes do ingest. Isso limpa qualquer workspace anterior com o mesmo nome, então reingerir é destrutivo e idempotente.chunk: falseavisa ao QVAC que os documentos já estão chunked. O splitting aconteceu emrag.ts. Fazer duas vezes seria errado.
Os chunks são reformatados num envelope de texto pequeno:
source:<relativePath>
chunk:<chunkIndex>
id:<workspace>:<chunk.id>
<chunk content>…para o ragSearch retornar com a metadata ainda anexada.
Buscando
async search(workspace: string, question: string, topK = 5): Promise<SearchHit[]> {
await this.ensureReady();
const results = await ragSearch({
modelId: required(this.embeddingModelId, "QVAC embedding model is not loaded."),
query: question,
topK,
n: 3,
workspace,
});
return results.map(parseRagHit);
}parseRagHit extrai os headers source: e chunk: que escrevemos
durante o ingest e remove eles do conteúdo do chunk:
function parseRagHit(hit: { id: string; content: string; score: number }): SearchHit {
return {
id: hit.id,
relativePath: /^source:(.+)$/m.exec(hit.content)?.[1] ?? "unknown",
chunkIndex: Number(/^chunk:(\d+)$/m.exec(hit.content)?.[1] ?? 0),
content: hit.content.replace(/^(?:workspace:.+\n)?source:.+\nchunk:\d+\nid:.+\n\n/m, "").trim(),
score: hit.score,
};
}É onde strings voltam como objetos SearchHit estruturados.
Streaming completion
async *answer(history: ChatMessage[]): AsyncGenerator<string> {
await this.ensureReady();
const run = completion({
modelId: required(this.chatModelId, "QVAC chat model is not loaded."),
history,
stream: true,
captureThinking: true,
kvCache: true,
});
for await (const event of run.events) if (event.type === "contentDelta") yield event.text;
await run.final;
}O gateway expõe inferência como AsyncGenerator<string>, para
chamadores fazerem stream direto para um console, uma resposta HTTP ou uma
UI.
captureThinking: truemantém qualquer token de raciocínio fora do stream de saída visível ao usuário.kvCache: truedeixa o QVAC reutilizar estado de atenção de prefixo entre perguntas de follow-up.
Ciclo de vida
async closeWorkspace(workspace: string, deleteOnClose = false): Promise<void> {
await ragCloseWorkspace({ workspace, deleteOnClose }).catch(async () => {
if (deleteOnClose) await ragDeleteWorkspace({ workspace }).catch(() => undefined);
});
}
async close(): Promise<void> {
await close();
}closeWorkspace é tolerante. Se o QVAC diz que o workspace não
existe, tudo bem — o chamador queria que ele sumisse mesmo. close()
desmonta o runtime QVAC inteiro e é chamado de
LocalLensApp.close() no shutdown.
Por que uma classe e não um módulo?
O gateway segura estado — IDs de modelo, uma promise de load em voo. Um módulo de funções livres empurraria esse estado pra todo chamador, ou esconderia em mutáveis no nível de módulo. Uma classe pequena é o jeito mais barato de encapsular.
A seguir: o JSON store, onde brains e chunks ficam persistidos.