6. O workflow da aplicação
LocalLensApp é onde os módulos dos cinco passos anteriores se juntam.
src/locallens.ts exporta LocalLensApp, o único ponto de entrada
que a CLI e o servidor usam. Ele possui o workflow — a
ordem das coisas — e nada mais.
O construtor
export class LocalLensApp {
constructor(
private readonly store = new LocalLensStore(),
private readonly qvac = new QvacGateway(),
) {}
// …
}Dois parâmetros com defaults sensatos. Testes passam seu próprio store apontado para um diretório temp. A CLI e o servidor usam os defaults.
Criando um brain a partir de uma pasta
async createBrainFromFolder(input: CreateBrainFromFolderInput): Promise<Brain> {
const folderPath = path.resolve(input.folderPath);
const documents = await discoverTextDocuments(folderPath);
return this.createBrainFromLocalDocuments(input.name.trim(), folderPath, documents);
}O método público é curto. O trabalho real mora no helper privado:
private async createBrainFromLocalDocuments(
name: string,
folderPath: string,
documents: LocalDocument[],
): Promise<Brain> {
if (!name) throw new AppError("Brain name is required.");
if (documents.length === 0) {
throw new AppError("Choose a folder with supported text files.");
}
const now = new Date().toISOString();
const brain: Brain = {
id: randomUUID(),
name,
folderPath,
workspace: `locallens-${slugify(name)}-${randomUUID().slice(0, 8)}`,
status: "indexing",
fileCount: documents.length,
chunkCount: 0,
createdAt: now,
updatedAt: now,
};
await this.store.saveBrain(brain);
try {
const chunks = await chunkDocuments(documents, { brainId: brain.id });
await this.store.saveChunks(brain.id, chunks);
await this.qvac.ingestChunks(brain.workspace, chunks);
const indexed = {
...brain,
status: "indexed" as const,
chunkCount: chunks.length,
updatedAt: new Date().toISOString(),
lastIndexedAt: new Date().toISOString(),
};
await this.store.saveBrain(indexed);
return indexed;
} catch (error) {
const failed = {
...brain,
status: "error" as const,
updatedAt: new Date().toISOString(),
lastError: toErrorMessage(error),
};
await this.store.saveBrain(failed);
throw error;
}
}O formato:
- Validar. Nome vazio? Lança erro. Sem documentos? Lança erro.
- Salvar com status
indexing. Permite a UI mostrar progresso, e deixa um registro claro se a execução crashar. - Chunk → save chunks → ingest. Três passos, em ordem.
- Salvar de novo com status
indexed. Finaliza. - Em erro, salvar com status
errore relançar. O store mantém a falha visível para a UI.
Esse é o único lugar no app onde transições de estado multi-passo acontecem. Isso é deliberado — manter o ciclo de vida em um método só significa que existe exatamente um lugar para ler para entender.
Fazendo uma pergunta
async askBrain(id: string, question: string): Promise<ChatAnswer> {
const brain = await this.getBrain(id);
const normalizedQuestion = question.trim();
if (!normalizedQuestion) throw new AppError("Question is required.");
if (brain.status !== "indexed") {
throw new AppError("This brain is not ready. Remove it and create it again.", 409);
}
const hits = await this.qvac.search(brain.workspace, normalizedQuestion, 5);
const history = buildGroundedHistory(normalizedQuestion, hits);
let answer = "";
for await (const token of this.qvac.answer(history)) answer += token;
return {
answer: answer.trim() || "This brain does not contain enough information to answer.",
citations: hits.map(({ id: hitId, relativePath, chunkIndex, score }) => ({
id: hitId,
relativePath,
chunkIndex,
score,
})),
};
}Três coisas para notar:
- Checagem de status. Perguntar para um brain que ainda está indexando ou em erro retorna 409. A UI usa isso para manter o input de chat desabilitado.
- Citações são derivadas dos hits. Cada
SearchHité mapeado para umCitation(sem conteúdo de chunk) antes de retornar. Essa é a fronteira de privacidade que mantém o texto do chunk fora do servidor. - Fallback de resposta vazia. Se o modelo não produz nada, o chamador recebe uma mensagem simples em vez de uma string vazia.
Caminho do navegador
A variante do navegador é um wrapper fino em volta do mesmo helper privado:
async createBrainFromDocuments(input: CreateBrainFromFilesInput): Promise<Brain> {
const name = input.name.trim();
const folderName = sanitizeFolderName(input.folderName);
const documents = browserDocumentsFromInput(input.documents);
return this.createBrainFromLocalDocuments(name, `browser://${folderName}`, documents);
}Usa browser://<folderName> como o folderPath do brain, então
você consegue saber de onde veio sem nunca segurar um caminho
real de disco que o navegador nunca nos deu.
Apagando um brain
async deleteBrain(id: string): Promise<void> {
const brain = await this.getBrain(id);
await this.qvac.closeWorkspace(brain.workspace, true);
await this.store.deleteBrain(id);
}Duas fases, em ordem: fechar o workspace QVAC (apagando embeddings em disco), depois dropar a entrada JSON. Se o QVAC lança erro, a entrada JSON fica — esse é o comportamento certo, porque deixa um registro visível de que algo deu errado em vez de um half-cleanup silencioso.
Shutdown
async close(): Promise<void> {
await this.qvac.close();
}Isso é tudo que o workflow precisa expor. O store não tem nada para fechar — um arquivo JSON está bem quando você simplesmente para de escrever nele.
Onde adicionar features
Se sua feature envolve um passo novo no ciclo de vida do brain, adiciona
aqui. Se é um pedaço novo de dado, adiciona em domain.ts e
passa pelos gateways relevantes. A classe de workflow é o
único lugar onde múltiplos módulos são orquestrados.
A seguir: a CLI, o menor consumidor possível dessa classe.