Spaces:
Running
Running
Commit ·
8eb816f
0
Parent(s):
Deploy: DeepTrust workspace (v2 clean history)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .cursor/plans/client-side_rag_knowledge_upload_c1d69919.plan.md +141 -0
- .dockerignore +30 -0
- .gitignore +47 -0
- AGENTS.md +16 -0
- DEPLOY.md +47 -0
- Dockerfile +53 -0
- README.md +643 -0
- app/api/model/load/route.ts +109 -0
- app/api/research/approve/route.ts +55 -0
- app/api/research/route.ts +80 -0
- app/favicon.ico +0 -0
- app/globals.css +26 -0
- app/layout.tsx +34 -0
- app/page.tsx +1120 -0
- dist/llm/pipeline.js +195 -0
- dist/llm/worker-entry.js +47 -0
- docs/ARCHITECTURE.md +503 -0
- eslint.config.mjs +18 -0
- instrumentation.ts +56 -0
- lib/agent/POLICY.md +4 -0
- lib/agent/graph.ts +217 -0
- lib/agent/index.ts +32 -0
- lib/agent/llm/index.ts +121 -0
- lib/agent/llm/pipeline.ts +243 -0
- lib/agent/llm/worker-entry.ts +65 -0
- lib/agent/nodes/auditor.ts +101 -0
- lib/agent/nodes/hitl-gate.ts +40 -0
- lib/agent/nodes/index.ts +6 -0
- lib/agent/nodes/synthesizer.ts +66 -0
- lib/agent/nodes/thinker.ts +228 -0
- lib/agent/nodes/tool-executor.ts +79 -0
- lib/agent/routing.ts +49 -0
- lib/agent/state.ts +209 -0
- lib/agent/utils/extract-json.ts +42 -0
- lib/agent/utils/index.ts +3 -0
- lib/agent/utils/policy.ts +27 -0
- lib/knowledge/chunk.ts +34 -0
- lib/knowledge/db.ts +111 -0
- lib/knowledge/embeddings.ts +59 -0
- lib/knowledge/index.ts +10 -0
- lib/knowledge/pdf.ts +28 -0
- lib/knowledge/store.ts +156 -0
- lib/knowledge/types.ts +37 -0
- next.config.ts +11 -0
- package-lock.json +0 -0
- package.json +39 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
.cursor/plans/client-side_rag_knowledge_upload_c1d69919.plan.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Client-side RAG knowledge upload
|
| 3 |
+
overview: "Implement the Context panel as a real client-side RAG flow: persist PDFs, notes, and URLs in IndexedDB, index them with in-browser embeddings and a vector store, retrieve relevant chunks at query time, and pass that context to the research agent so the LLM uses it when planning and synthesizing."
|
| 4 |
+
todos: []
|
| 5 |
+
isProject: false
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# Client-Side RAG: Knowledge Upload and Retrieval
|
| 9 |
+
|
| 10 |
+
## Current state
|
| 11 |
+
|
| 12 |
+
- **Frontend** ([app/page.tsx](app/page.tsx)): The Context panel stores only metadata in React state (`KnowledgeItem`: id, type, label, meta). File content is never read; notes are plain text; URLs are just strings. No persistence, no embeddings, no retrieval.
|
| 13 |
+
- **API** ([app/api/research/route.ts](app/api/research/route.ts)): Reads only `query` from the body; `knowledge` is ignored.
|
| 14 |
+
- **Agent** ([lib/agent/graph.ts](lib/agent/graph.ts), [lib/agent/state.ts](lib/agent/state.ts)): `runResearch(userQuery, sessionName)` and `createInitialState({ threadId, userQuery, sessionName })`; no field for retrieved context. The Thinker builds the prompt from `state.userQuery` only ([lib/agent/nodes/thinker.ts](lib/agent/nodes/thinker.ts) line 117).
|
| 15 |
+
|
| 16 |
+
## Target flow
|
| 17 |
+
|
| 18 |
+
```mermaid
|
| 19 |
+
sequenceDiagram
|
| 20 |
+
participant User
|
| 21 |
+
participant UI
|
| 22 |
+
participant IndexedDB
|
| 23 |
+
participant VectorStore
|
| 24 |
+
participant API
|
| 25 |
+
participant Agent
|
| 26 |
+
|
| 27 |
+
User->>UI: Add PDF / note / URL
|
| 28 |
+
UI->>UI: Extract text (PDF: pdf.js; note: as-is; URL: see below)
|
| 29 |
+
UI->>UI: Chunk and embed (browser)
|
| 30 |
+
UI->>VectorStore: Store chunks + vectors in IndexedDB
|
| 31 |
+
User->>UI: Run research
|
| 32 |
+
UI->>VectorStore: Embed query; top-k similarity search
|
| 33 |
+
UI->>API: POST { query, retrievedContext }
|
| 34 |
+
API->>Agent: runResearch(query, { knowledgeContext })
|
| 35 |
+
Agent->>Agent: Thinker / Synthesizer use knowledgeContext in prompts
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
## 1. Client: Storage, extraction, embedding, and retrieval
|
| 41 |
+
|
| 42 |
+
### 1.1 Persistence and data model
|
| 43 |
+
|
| 44 |
+
- Introduce a **client-side knowledge store** backed by **IndexedDB** (separate from the existing React state so refreshes and future sessions retain data).
|
| 45 |
+
- **Schema** (conceptual):
|
| 46 |
+
- **Documents**: `id`, `type` (file | url | note), `label`, `createdAt`, optional `url` for type url.
|
| 47 |
+
- **Chunks**: `id`, `documentId`, `text`, `embedding` (array of numbers), `startIndex`, `endIndex`.
|
| 48 |
+
- Use a single IndexedDB database (e.g. `deeptrust-knowledge`) with object stores for documents and chunks (or a library that wraps this).
|
| 49 |
+
|
| 50 |
+
### 1.2 Text extraction
|
| 51 |
+
|
| 52 |
+
- **PDFs**: Use **pdf.js** (Mozilla) in the browser to extract text from dropped/selected files. No server round-trip; keep PDFs client-side only.
|
| 53 |
+
- **Notes**: Use the note text as a single “document”; optional chunking by paragraph or fixed size.
|
| 54 |
+
- **URLs**: Two options (choose one for v1):
|
| 55 |
+
- **A (recommended for scope)**
|
| 56 |
+
Store URL as metadata only. Do **not** fetch or embed URL content in the browser (avoids CORS and complexity). When building the research payload, send the list of “context URLs” with the request; the backend can pass them into the Thinker so the plan may include `document_fetch` steps for those URLs, or the Thinker can be prompted to consider “the user provided these URLs as references” in the plan.
|
| 57 |
+
- **B (full client-side)**
|
| 58 |
+
Fetch URL in the browser (e.g. via a Next.js API route that proxies the fetch to avoid CORS), extract text (HTML → text), then chunk and embed like PDFs. Adds proxy and error handling.
|
| 59 |
+
|
| 60 |
+
Recommendation: **A** for the first iteration; document B as a follow-up.
|
| 61 |
+
|
| 62 |
+
### 1.3 Chunking and embedding in the browser
|
| 63 |
+
|
| 64 |
+
- **Chunking**: Split document text into overlapping or fixed-size segments (e.g. 256–512 tokens or ~500 chars with 50–100 char overlap). No server call.
|
| 65 |
+
- **Embeddings**: Use a **browser-run embedding model** so everything stays client-side:
|
| 66 |
+
- **Option A**: **Transformers.js** (`@xenova/transformers`) with a small feature-extraction model (e.g. `Xenova/all-MiniLM-L6-v2` or similar). Runs in WebAssembly; single dependency consistent with “local-first”.
|
| 67 |
+
- **Option B**: A dedicated client-side vector DB that includes embeddings (e.g. **idbvec** with external embeddings, or **entity-db** which uses Transformers.js under the hood). Prefer one that stores vectors in IndexedDB and supports similarity search.
|
| 68 |
+
- Store each chunk’s embedding in IndexedDB with a reference to its document and text.
|
| 69 |
+
|
| 70 |
+
### 1.4 Vector store and retrieval
|
| 71 |
+
|
| 72 |
+
- **Option A**: **idbvec** (`@brainwires/idbvec`): WASM + IndexedDB, HNSW index, configurable distance. You supply embeddings (e.g. from Transformers.js); idbvec handles storage and k-NN search.
|
| 73 |
+
- **Option B**: **Custom**: Store chunks (with embeddings) in IndexedDB; on query, load relevant chunks and run brute-force cosine similarity (or a tiny WASM k-NN) in a worker to avoid blocking the main thread. Good for small corpora (< ~10k chunks).
|
| 74 |
+
- At “Run research” time: embed the user query with the same model, run top-k similarity search (e.g. k = 5–10), build a single `retrievedContext` string (e.g. concatenate chunk texts with source labels) and send it with the request.
|
| 75 |
+
|
| 76 |
+
### 1.5 UI and sync with existing Context panel
|
| 77 |
+
|
| 78 |
+
- Keep the existing **Context panel** UX: drag-and-drop, “Attach files”, URL input, “Short note”, and the list of items.
|
| 79 |
+
- **On add**:
|
| 80 |
+
- For **files** (PDF): read file, extract text with pdf.js, chunk, embed, and write documents + chunks (+ vectors) to IndexedDB. Add a corresponding entry to React state for the list (label, type, id) and optionally sync the list from IndexedDB on load.
|
| 81 |
+
- For **notes**: create one document, chunk if needed, embed, store; update UI list.
|
| 82 |
+
- For **URLs** (v1): store URL in IndexedDB as a document with no chunks (or a single placeholder chunk with URL as “text”); backend will receive “context URLs” and can use them in the prompt or in plan steps.
|
| 83 |
+
- **On remove**: Delete document (and its chunks) from IndexedDB and from React state. Add a remove control next to each item in the list (currently there is none).
|
| 84 |
+
- **On load**: Hydrate the Context list from IndexedDB so persisted knowledge survives refresh.
|
| 85 |
+
|
| 86 |
+
## 2. Backend: Accept and use retrieved context
|
| 87 |
+
|
| 88 |
+
### 2.1 Research API
|
| 89 |
+
|
| 90 |
+
- In [app/api/research/route.ts](app/api/research/route.ts), read from the request body: `query` and either `retrievedContext` (string) or `knowledge` (array or object that includes `retrievedContext` and optionally `contextUrls: string[]`).
|
| 91 |
+
- Call the agent with this context, e.g. `runResearch(query, sessionName, { retrievedContext, contextUrls })`.
|
| 92 |
+
|
| 93 |
+
### 2.2 Agent state and graph
|
| 94 |
+
|
| 95 |
+
- In [lib/agent/state.ts](lib/agent/state.ts), add an optional field to `ResearchState`, e.g.
|
| 96 |
+
`knowledgeContext: z.string().optional()`
|
| 97 |
+
and optionally `contextUrls: z.array(z.string()).optional()`.
|
| 98 |
+
- In [lib/agent/graph.ts](lib/agent/graph.ts), add the new channel(s) to the state graph (e.g. `knowledgeContext: { value: (_, n) => n }`) and ensure they are part of the initial state when provided.
|
| 99 |
+
- Extend [lib/agent/state.ts](lib/agent/state.ts) `createInitialState` to accept optional `knowledgeContext` (and `contextUrls` if used), and pass them through in [lib/agent/graph.ts](lib/agent/graph.ts) when calling `createInitialState` and streaming.
|
| 100 |
+
|
| 101 |
+
### 2.3 Thinker and Synthesizer
|
| 102 |
+
|
| 103 |
+
- In [lib/agent/nodes/thinker.ts](lib/agent/nodes/thinker.ts), when building the user message (around line 117), if `state.knowledgeContext` is present, append it to the prompt, e.g.
|
| 104 |
+
“The user provided the following retrieved context from their local knowledge base. Use it to inform the plan and prefer steps that leverage this context where relevant:\n\n” + state.knowledgeContext.
|
| 105 |
+
If `state.contextUrls` is used, add a line like “The user also referenced these URLs: …” so the Thinker can emit `document_fetch` steps for them.
|
| 106 |
+
- In [lib/agent/nodes/synthesizer.ts](lib/agent/nodes/synthesizer.ts), if `state.knowledgeContext` is present, include it in the context passed to the LLM when synthesizing the final report (so the report can cite or summarize the user’s local knowledge).
|
| 107 |
+
|
| 108 |
+
## 3. Dependencies and build
|
| 109 |
+
|
| 110 |
+
- Add npm packages: e.g. **pdfjs-dist** (or **react-pdf** if you prefer a React wrapper) for PDF text extraction; **@xenova/transformers** (or the chosen embedding solution); and **idbvec** or a minimal IndexedDB + vector search helper.
|
| 111 |
+
- Ensure the embedding model is loaded only when the user first adds knowledge (or on first “Run research” with non-empty context) to avoid blocking initial page load; consider a small “Preparing knowledge…” state in the UI when the embedding pipeline is loading.
|
| 112 |
+
|
| 113 |
+
## 4. Scope summary
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
| Area | In scope |
|
| 117 |
+
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 118 |
+
| Client | IndexedDB persistence for documents/chunks; PDF text extraction (pdf.js); notes as documents; URLs stored as references (no client fetch in v1). |
|
| 119 |
+
| Client | Browser embeddings (Transformers.js or equivalent); vector store in IndexedDB (idbvec or custom); top-k retrieval at query time; send `retrievedContext` (+ optional `contextUrls`) with POST /api/research. |
|
| 120 |
+
| Client | Context panel: remove item; hydrate list from IndexedDB on load; show “indexed” or “ready” per item after processing. |
|
| 121 |
+
| Backend | Research route parses `retrievedContext` (and optional `contextUrls`); runResearch(..., { knowledgeContext, contextUrls }); state + createInitialState + graph channels; Thinker and Synthesizer include context in prompts. |
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
## 5. Files to add or touch
|
| 125 |
+
|
| 126 |
+
- **New**: `lib/knowledge/` (or `app/lib/knowledge/`) — IndexedDB schema, chunking, embedding pipeline (or wrapper around Transformers.js + idbvec), and retrieval function. Keep it UI-agnostic so it can be called from the Context panel and before `fetch('/api/research')`.
|
| 127 |
+
- **New** (optional): `app/components/KnowledgePanel.tsx` — Extract the Context panel into a component that uses the knowledge store and exposes “items” + “retrievedContext” for the parent.
|
| 128 |
+
- **Edit**: [app/page.tsx](app/page.tsx) — Wire file drop/note/URL to the knowledge store; before runResearch, call retrieval and send `retrievedContext` (and `contextUrls`) in the request body; add remove button; hydrate from IndexedDB.
|
| 129 |
+
- **Edit**: [app/api/research/route.ts](app/api/research/route.ts) — Read `retrievedContext` / `contextUrls`; pass to runResearch.
|
| 130 |
+
- **Edit**: [lib/agent/state.ts](lib/agent/state.ts) — Add `knowledgeContext` (and optionally `contextUrls`); extend `createInitialState`.
|
| 131 |
+
- **Edit**: [lib/agent/graph.ts](lib/agent/graph.ts) — Add channel(s); pass context into `createInitialState` in runResearch.
|
| 132 |
+
- **Edit**: [lib/agent/nodes/thinker.ts](lib/agent/nodes/thinker.ts) — Include `state.knowledgeContext` (and URLs) in the Thinker prompt.
|
| 133 |
+
- **Edit**: [lib/agent/nodes/synthesizer.ts](lib/agent/nodes/synthesizer.ts) — Include `state.knowledgeContext` in the synthesis prompt.
|
| 134 |
+
- **Edit**: [lib/agent/index.ts](lib/agent/index.ts) — Export any new types if needed; ensure runResearch signature is updated and documented.
|
| 135 |
+
|
| 136 |
+
## 6. Optional follow-ups
|
| 137 |
+
|
| 138 |
+
- **URL content in browser**: Proxy fetch in an API route; extract text; chunk and embed like PDFs so URL content is fully in the vector store.
|
| 139 |
+
- **Delete-all / export**: Clear all knowledge from IndexedDB; export documents/chunks as JSON.
|
| 140 |
+
- **Progress UX**: Show “Extracting…”, “Embedding…”, “Indexed” per file so large PDFs don’t look stuck.
|
| 141 |
+
|
.dockerignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies (reinstalled in image)
|
| 2 |
+
node_modules
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
.pnpm-debug.log*
|
| 5 |
+
.yarn*
|
| 6 |
+
|
| 7 |
+
# Build outputs (rebuilt in image)
|
| 8 |
+
.next
|
| 9 |
+
dist
|
| 10 |
+
out
|
| 11 |
+
|
| 12 |
+
# Git and IDE
|
| 13 |
+
.git
|
| 14 |
+
.gitignore
|
| 15 |
+
.cursor
|
| 16 |
+
.vscode
|
| 17 |
+
*.md
|
| 18 |
+
!README.md
|
| 19 |
+
|
| 20 |
+
# Env and secrets
|
| 21 |
+
.env
|
| 22 |
+
.env.*
|
| 23 |
+
!.env.example
|
| 24 |
+
|
| 25 |
+
# Misc
|
| 26 |
+
.DS_Store
|
| 27 |
+
*.log
|
| 28 |
+
coverage
|
| 29 |
+
.nyc_output
|
| 30 |
+
.hf-cache
|
.gitignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
| 42 |
+
|
| 43 |
+
# huggingface model cache
|
| 44 |
+
.hf-cache/
|
| 45 |
+
|
| 46 |
+
# HF Spaces reject binary files in git; keep .pptx out of history
|
| 47 |
+
docs/*.pptx
|
AGENTS.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepTrust — Agent context
|
| 2 |
+
|
| 3 |
+
**What this is:** TypeScript research agent (LangGraph state graph + local LLM + Next.js). Plan → audit → tools → synthesize. Real-time workspace UI with SSE streaming and context/knowledge upload.
|
| 4 |
+
|
| 5 |
+
**Where the truth lives:** Structure and features change over time. Prefer these for current details:
|
| 6 |
+
- **README.md** — Overview, stack, project structure, API, frontend, run/config.
|
| 7 |
+
- **docs/ARCHITECTURE.md** — Low-level design: state machine, nodes, routing, LLM layer, SSE protocol, frontend architecture.
|
| 8 |
+
|
| 9 |
+
**Rough layout (may evolve):**
|
| 10 |
+
- `lib/agent/` — Graph, state, nodes, LLM client (worker thread), utils.
|
| 11 |
+
- `app/` — Next.js app; `page.tsx` = workspace UI; `app/api/` = research + model endpoints.
|
| 12 |
+
- `dist/llm/` — Built worker (from `npm run build:worker`).
|
| 13 |
+
|
| 14 |
+
**Commands:** `npm run build:worker` before first run; `npm run dev` to develop; `npm run build` then `npm run start` for production.
|
| 15 |
+
|
| 16 |
+
**When editing:** Follow existing patterns in the area you change. For agent graph or state, check `lib/agent/state.ts` and `lib/agent/graph.ts`; for API/streaming, see the route handlers and README/ARCHITECTURE.
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying DeepTrust on Render
|
| 2 |
+
|
| 3 |
+
The app is containerized with **Docker**. You do **not** need Docker installed locally—Render builds the image in the cloud when you push.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
- Git repo pushed to GitHub (or GitLab)
|
| 8 |
+
- Account on [Render](https://render.com)
|
| 9 |
+
|
| 10 |
+
## Deploy steps
|
| 11 |
+
|
| 12 |
+
1. Go to [dashboard.render.com](https://dashboard.render.com), connect your GitHub repo.
|
| 13 |
+
2. Add a **Web Service**.
|
| 14 |
+
3. Set:
|
| 15 |
+
- **Build:** Docker (Render will use the repo `Dockerfile`).
|
| 16 |
+
- **Plan:** See [Memory and plan](#memory-and-plan) below.
|
| 17 |
+
4. Create the service. Render builds and deploys on each push.
|
| 18 |
+
|
| 19 |
+
**Blueprint:** Alternatively, use the repo’s `render.yaml` (Blueprint deploy) so the service is defined in code.
|
| 20 |
+
|
| 21 |
+
## Memory and plan
|
| 22 |
+
|
| 23 |
+
Render **Free** instances have **512 MB RAM**. Model loading often exceeds this:
|
| 24 |
+
|
| 25 |
+
- **SmolLM2-360M (q4)** ≈ 300–450 MB for weights alone, plus Node, Next.js, worker, and inference → typically **OOM on Free** (502 when loading).
|
| 26 |
+
- **SmolLM2-135M (q4)** ≈ 150–200 MB → may work on Free but is tight; 502 can still occur under load.
|
| 27 |
+
|
| 28 |
+
**Recommendations:**
|
| 29 |
+
|
| 30 |
+
- **Free tier (512 MB):** Set env `HF_MODEL=HuggingFaceTB/SmolLM2-135M-Instruct` and use the 135M model (q4) in the UI. If you still get 502 on “Load model”, the instance is likely OOM — upgrade to Starter.
|
| 31 |
+
- **Starter (2 GB) or higher:** Safe for SmolLM2-360M (q4) and 135M. Use Starter if you need 360M or reliable inference.
|
| 32 |
+
|
| 33 |
+
## Environment variables (optional)
|
| 34 |
+
|
| 35 |
+
- `HF_MODEL` — Hugging Face model id (default in code). On Free tier, use `HuggingFaceTB/SmolLM2-135M-Instruct` to reduce OOM risk.
|
| 36 |
+
- `HF_CACHE_DIR` — Where to cache model files (default `./.hf-cache`). On ephemeral disks this is lost between restarts; models re-download on cold start.
|
| 37 |
+
- `DEEPTRUST_LOAD_MODEL_AT_STARTUP` — Set to `0` or `false` to disable loading the model when the server starts (default: load at startup). Use this only if you prefer to load on first user request.
|
| 38 |
+
|
| 39 |
+
## Hugging Face Spaces (Docker)
|
| 40 |
+
|
| 41 |
+
When running as a **Docker Space**, the app **skips startup model preload** so the Space becomes healthy within the 30‑minute launch timeout. You’ll see in the logs: `[instrumentation] Hugging Face Space detected; skipping startup model preload (model will load on first use).` The model loads on first use (e.g. when you click “Load model” or run research). If your Space still times out, set the variable `DEEPTRUST_LOAD_MODEL_AT_STARTUP=0` in the Space’s **Settings → Variables**. In the Space README YAML you can set `app_port: 3000` to match the Dockerfile.
|
| 42 |
+
|
| 43 |
+
## Notes
|
| 44 |
+
|
| 45 |
+
- **Model at startup:** The app loads the default model (from `HF_MODEL`, or 135M q4) when the Node server starts, so the first user request does not wait for download/load. (On Hugging Face Spaces this is skipped so the Space stays healthy.) If preload fails (e.g. OOM), the server still starts and the UI "Load model" button can be used to retry.
|
| 46 |
+
- **First deploy/cold start** can be slow while the model downloads and loads during server boot; subsequent requests are fast.
|
| 47 |
+
- **Standalone:** The image uses Next.js `output: "standalone"` for a smaller build and includes the compiled LLM worker under `dist/llm/`.
|
Dockerfile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepTrust — multi-stage build for Render
|
| 2 |
+
# Build runs in the cloud; no local Docker required.
|
| 3 |
+
# Uses node:20-slim (glibc) so onnxruntime-node and tokenizers native bindings work.
|
| 4 |
+
# Alpine (musl) causes 502 on model load due to incompatible native modules.
|
| 5 |
+
|
| 6 |
+
FROM node:20-slim AS deps
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
COPY package.json package-lock.json* ./
|
| 9 |
+
RUN npm ci
|
| 10 |
+
|
| 11 |
+
FROM node:20-slim AS builder
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Build LLM worker (dist/llm/) then Next.js (standalone)
|
| 17 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
# Pre-download the default model so the container doesn't fetch at runtime
|
| 21 |
+
ENV HF_CACHE_DIR=/app/.hf-cache-build
|
| 22 |
+
RUN mkdir -p /app/.hf-cache-build && node scripts/preload-hf-model.cjs
|
| 23 |
+
|
| 24 |
+
FROM node:20-slim AS runner
|
| 25 |
+
WORKDIR /app
|
| 26 |
+
|
| 27 |
+
ENV NODE_ENV=production
|
| 28 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 29 |
+
|
| 30 |
+
RUN groupadd --gid 1001 nodejs && useradd --uid 1001 --gid nodejs --shell /bin/false nextjs
|
| 31 |
+
|
| 32 |
+
# Standalone output: server + minimal node_modules
|
| 33 |
+
COPY --from=builder /app/.next/standalone ./
|
| 34 |
+
COPY --from=builder /app/.next/static ./.next/static
|
| 35 |
+
COPY --from=builder /app/public ./public
|
| 36 |
+
# LLM worker (required at runtime; path is process.cwd()/dist/llm)
|
| 37 |
+
COPY --from=builder /app/dist ./dist
|
| 38 |
+
# Worker runs in a separate thread and requires these at runtime; standalone does not trace them.
|
| 39 |
+
COPY --from=builder /app/package.json ./package.json
|
| 40 |
+
COPY --from=builder /app/package-lock.json ./package-lock.json
|
| 41 |
+
RUN npm install @huggingface/transformers onnxruntime-node --omit=dev --ignore-scripts --no-save
|
| 42 |
+
|
| 43 |
+
# Pre-downloaded model cache (avoids runtime download; worker reads from here)
|
| 44 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.hf-cache-build ./.hf-cache
|
| 45 |
+
ENV HF_CACHE_DIR=/app/.hf-cache
|
| 46 |
+
|
| 47 |
+
USER nextjs
|
| 48 |
+
|
| 49 |
+
EXPOSE 3000
|
| 50 |
+
ENV PORT=3000
|
| 51 |
+
ENV HOSTNAME="0.0.0.0"
|
| 52 |
+
|
| 53 |
+
CMD ["node", "server.js"]
|
README.md
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: DeepTrust Research Agent
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 3000
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: LangGraph research agent with local LLM and Next.js workspace.
|
| 10 |
+
tags:
|
| 11 |
+
- langgraph
|
| 12 |
+
- nextjs
|
| 13 |
+
- transformers
|
| 14 |
+
- research-agent
|
| 15 |
+
- llm
|
| 16 |
+
- typescript
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# DeepTrust Research Agent
|
| 20 |
+
|
| 21 |
+
A TypeScript implementation of an autonomous research agent: LangGraph state machines, local LLM inference (Hugging Face Transformers in a worker thread), and a real-time, AI-centric Next.js workspace. The UI is designed for a Cursor/Gemini-like flow—immediate feedback, Server-Sent Events (SSE) streaming, optimistic updates, a knowledge/context drop zone, and quick-action chips—so the full application from graph nodes to the browser is understandable in one read.
|
| 22 |
+
|
| 23 |
+
## Documentation
|
| 24 |
+
|
| 25 |
+
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — Low-level design: state machine, channels, routing, LLM layer, API protocols, and frontend architecture (SSE, streaming UX, knowledge flow).
|
| 26 |
+
|
| 27 |
+
## Table of Contents
|
| 28 |
+
|
| 29 |
+
1. [Architecture Overview](#architecture-overview)
|
| 30 |
+
2. [Technology Stack](#technology-stack)
|
| 31 |
+
3. [Project Structure](#project-structure)
|
| 32 |
+
4. [State Management](#state-management)
|
| 33 |
+
5. [Graph Nodes](#graph-nodes)
|
| 34 |
+
6. [Routing and Conditional Edges](#routing-and-conditional-edges)
|
| 35 |
+
7. [LLM Integration](#llm-integration)
|
| 36 |
+
8. [API Layer](#api-layer)
|
| 37 |
+
9. [Frontend: Real-Time Workspace](#frontend-real-time-workspace)
|
| 38 |
+
10. [Running the Project](#running-the-project)
|
| 39 |
+
11. [Configuration](#configuration)
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## Architecture Overview
|
| 44 |
+
|
| 45 |
+
DeepTrust implements a cyclic state graph where a research query flows through multiple specialized nodes:
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
┌──────────────────────────────────────────────────────────┐
|
| 49 |
+
│ │
|
| 50 |
+
│ [START] ──► thinker ──► auditor ──► tool_executor │
|
| 51 |
+
│ ▲ │ │ │
|
| 52 |
+
│ │ reject │ │ │
|
| 53 |
+
│ └───────────┘ ▼ │
|
| 54 |
+
│ synthesizer │
|
| 55 |
+
│ │ │
|
| 56 |
+
│ [END] │
|
| 57 |
+
└──────────────────────────────────────────────────────────┘
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### Node Responsibilities
|
| 61 |
+
|
| 62 |
+
- **Thinker**: Decomposes a research question into a structured, multi-step plan
|
| 63 |
+
- **Auditor**: Validates the plan against organizational policy; rejects non-compliant plans
|
| 64 |
+
- **HITL Gate**: Pauses execution for human approval before tool execution
|
| 65 |
+
- **Tool Executor**: Executes each plan step sequentially (web search, document fetch, etc.)
|
| 66 |
+
- **Synthesizer**: Aggregates tool outputs into a final research report
|
| 67 |
+
|
| 68 |
+
The graph supports revision loops: if the Auditor rejects a plan, control returns to the Thinker with structured feedback. A configurable ceiling (`maxPlanRevisions`) prevents infinite loops.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Technology Stack
|
| 73 |
+
|
| 74 |
+
| Layer | Technology | Purpose |
|
| 75 |
+
|-------|------------|---------|
|
| 76 |
+
| **Frontend** | Next.js 16, React 19, Tailwind CSS 4 | Server-side rendering, streaming UI updates |
|
| 77 |
+
| **State Machine** | LangGraph.js | Graph construction, checkpointing, conditional routing |
|
| 78 |
+
| **LLM Inference** | @huggingface/transformers | Local model loading and text generation |
|
| 79 |
+
| **Schema Validation** | Zod 4 | Runtime type validation for state and API contracts |
|
| 80 |
+
| **Type System** | TypeScript 5 | Static type safety across the codebase |
|
| 81 |
+
|
| 82 |
+
### Key Dependencies
|
| 83 |
+
|
| 84 |
+
```json
|
| 85 |
+
{
|
| 86 |
+
"@langchain/langgraph": "^1.1.5",
|
| 87 |
+
"@huggingface/transformers": "^3.8.1",
|
| 88 |
+
"zod": "^4.3.6",
|
| 89 |
+
"next": "16.1.6",
|
| 90 |
+
"react": "19.2.3"
|
| 91 |
+
}
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## Project Structure
|
| 97 |
+
|
| 98 |
+
```
|
| 99 |
+
lib/agent/
|
| 100 |
+
├── index.ts # Public API exports
|
| 101 |
+
├── graph.ts # StateGraph construction and compilation
|
| 102 |
+
├── state.ts # Zod schemas and TypeScript types
|
| 103 |
+
├── routing.ts # Conditional edge functions
|
| 104 |
+
├── llm/
|
| 105 |
+
│ ├── index.ts # Worker proxy: loadModel, chatComplete, getModelStatus
|
| 106 |
+
│ ├── pipeline.ts # Pipeline config (used by worker)
|
| 107 |
+
│ └── worker-entry.ts # Worker entry: runs Transformers in a separate thread
|
| 108 |
+
├── nodes/
|
| 109 |
+
│ ├── index.ts # Node exports
|
| 110 |
+
│ ├── thinker.ts # Plan generation node
|
| 111 |
+
│ ├── auditor.ts # Policy validation node
|
| 112 |
+
│ ├── hitl-gate.ts # Human approval checkpoint
|
| 113 |
+
│ ├── tool-executor.ts # Tool dispatch node
|
| 114 |
+
│ └── synthesizer.ts # Report synthesis node
|
| 115 |
+
└── utils/
|
| 116 |
+
├── index.ts # Utility exports
|
| 117 |
+
├── extract-json.ts # Robust JSON parsing
|
| 118 |
+
└── policy.ts # Policy file loader
|
| 119 |
+
|
| 120 |
+
dist/llm/ # Built by npm run build:worker
|
| 121 |
+
├── worker-entry.js # Compiled worker
|
| 122 |
+
└── pipeline.js # Pipeline bundle
|
| 123 |
+
|
| 124 |
+
app/
|
| 125 |
+
├── page.tsx # Workspace UI: chat, context panel, model card, SSE client
|
| 126 |
+
├── layout.tsx
|
| 127 |
+
├── globals.css
|
| 128 |
+
└── api/
|
| 129 |
+
├── research/
|
| 130 |
+
│ └── route.ts # POST: SSE stream of research events
|
| 131 |
+
└── model/
|
| 132 |
+
└── load/
|
| 133 |
+
└── route.ts # GET/POST: model load + SSE progress
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## State Management
|
| 139 |
+
|
| 140 |
+
### The ResearchState Schema
|
| 141 |
+
|
| 142 |
+
All state flows through a single Zod-validated schema. This ensures runtime type safety and enables serialization for checkpointing.
|
| 143 |
+
|
| 144 |
+
```typescript
|
| 145 |
+
export const ResearchState = z.object({
|
| 146 |
+
// Identity
|
| 147 |
+
threadId: z.string().uuid(),
|
| 148 |
+
sessionName: z.string().default("Unnamed Session"),
|
| 149 |
+
|
| 150 |
+
// Input
|
| 151 |
+
userQuery: z.string().min(1),
|
| 152 |
+
|
| 153 |
+
// Planning
|
| 154 |
+
plan: ResearchPlan.nullable().default(null),
|
| 155 |
+
rejectionFeedback: z.string().nullable().default(null),
|
| 156 |
+
planRevisionCount: z.number().int().nonnegative().default(0),
|
| 157 |
+
maxPlanRevisions: z.number().int().positive().default(5),
|
| 158 |
+
|
| 159 |
+
// Auditing
|
| 160 |
+
auditResult: AuditResult.nullable().default(null),
|
| 161 |
+
|
| 162 |
+
// Execution
|
| 163 |
+
currentStepIndex: z.number().int().nonnegative().default(0),
|
| 164 |
+
humanApproved: z.boolean().default(false),
|
| 165 |
+
|
| 166 |
+
// Output
|
| 167 |
+
finalReport: z.string().nullable().default(null),
|
| 168 |
+
|
| 169 |
+
// Observability
|
| 170 |
+
reasoning: z.array(ReasoningEntry).default([]),
|
| 171 |
+
status: RunStatus.default("idle"),
|
| 172 |
+
updatedAt: z.string().datetime(),
|
| 173 |
+
errorMessage: z.string().nullable().default(null),
|
| 174 |
+
});
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
### Channel Configuration
|
| 178 |
+
|
| 179 |
+
LangGraph requires explicit channel definitions for state merging. Most fields use last-write-wins semantics, but the `reasoning` array uses append-only concatenation:
|
| 180 |
+
|
| 181 |
+
```typescript
|
| 182 |
+
const graph = new StateGraph<ResearchState>({
|
| 183 |
+
channels: {
|
| 184 |
+
threadId: { value: (_, n) => n },
|
| 185 |
+
// ... other scalar fields use (_, n) => n
|
| 186 |
+
|
| 187 |
+
// Append-only reasoning log
|
| 188 |
+
reasoning: {
|
| 189 |
+
value: (existing: ReasoningEntry[], incoming: ReasoningEntry[]) =>
|
| 190 |
+
[...(existing ?? []), ...(incoming ?? [])],
|
| 191 |
+
default: () => [],
|
| 192 |
+
},
|
| 193 |
+
},
|
| 194 |
+
});
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
### Sub-Schemas
|
| 198 |
+
|
| 199 |
+
**ResearchStep**: A single action in the research plan.
|
| 200 |
+
|
| 201 |
+
```typescript
|
| 202 |
+
export const ResearchStep = z.object({
|
| 203 |
+
id: z.string().uuid(),
|
| 204 |
+
tool: z.enum(["web_search", "document_fetch", "code_interpreter", "summarize"]),
|
| 205 |
+
input: z.string().min(1),
|
| 206 |
+
rationale: z.string(),
|
| 207 |
+
output: z.string().optional(),
|
| 208 |
+
executedAt: z.string().datetime().optional(),
|
| 209 |
+
});
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
**AuditResult**: Structured feedback from the Auditor.
|
| 213 |
+
|
| 214 |
+
```typescript
|
| 215 |
+
export const AuditResult = z.object({
|
| 216 |
+
verdict: z.enum(["approved", "rejected", "needs_revision"]),
|
| 217 |
+
policyViolations: z.array(z.string()).default([]),
|
| 218 |
+
suggestions: z.array(z.string()).default([]),
|
| 219 |
+
auditedAt: z.string().datetime(),
|
| 220 |
+
});
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## Graph Nodes
|
| 226 |
+
|
| 227 |
+
Each node is an async function that receives the current state and returns a partial state update.
|
| 228 |
+
|
| 229 |
+
### Thinker Node
|
| 230 |
+
|
| 231 |
+
Generates or revises a research plan. Prompts the LLM with structured output requirements:
|
| 232 |
+
|
| 233 |
+
```typescript
|
| 234 |
+
async function thinkerNode(state: ResearchState): Promise<Partial<ResearchState>> {
|
| 235 |
+
const isRevision = state.planRevisionCount > 0 && state.rejectionFeedback;
|
| 236 |
+
|
| 237 |
+
const system = `
|
| 238 |
+
You are the Thinker node of DeepTrust, an autonomous research agent.
|
| 239 |
+
Return ONLY a valid JSON object matching:
|
| 240 |
+
{
|
| 241 |
+
"objective": string,
|
| 242 |
+
"steps": Array<{ "id": UUID, "tool": string, "input": string, "rationale": string }>,
|
| 243 |
+
"estimatedTokenBudget": number,
|
| 244 |
+
"createdAt": ISO8601,
|
| 245 |
+
"revision": number
|
| 246 |
+
}`;
|
| 247 |
+
|
| 248 |
+
const userMessage = isRevision
|
| 249 |
+
? `Research question: "${state.userQuery}"\n\nPREVIOUS PLAN REJECTED:\n${state.rejectionFeedback}`
|
| 250 |
+
: `Research question: "${state.userQuery}"`;
|
| 251 |
+
|
| 252 |
+
const rawThought = await chatComplete(system, userMessage);
|
| 253 |
+
const parsed = extractJSON(rawThought);
|
| 254 |
+
const plan = ResearchPlan.parse({ ...parsed, revision: state.planRevisionCount });
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
plan,
|
| 258 |
+
status: "thinking",
|
| 259 |
+
rejectionFeedback: null,
|
| 260 |
+
reasoning: appendReasoning(state, { node: "thinker", summary: "..." }),
|
| 261 |
+
updatedAt: new Date().toISOString(),
|
| 262 |
+
};
|
| 263 |
+
}
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
### Auditor Node
|
| 267 |
+
|
| 268 |
+
Validates plans against `POLICY.md`. Returns structured violations and suggestions:
|
| 269 |
+
|
| 270 |
+
```typescript
|
| 271 |
+
async function auditorNode(state: ResearchState): Promise<Partial<ResearchState>> {
|
| 272 |
+
const policy = loadPolicy();
|
| 273 |
+
|
| 274 |
+
const system = `
|
| 275 |
+
You are the Auditor node. Evaluate research plans against policy.
|
| 276 |
+
Return ONLY: { "verdict": "approved"|"rejected"|"needs_revision", ... }`;
|
| 277 |
+
|
| 278 |
+
const rawThought = await chatComplete(system, `POLICY:\n${policy}\n\nPLAN:\n${JSON.stringify(state.plan)}`);
|
| 279 |
+
const auditResult = AuditResult.parse(extractJSON(rawThought));
|
| 280 |
+
|
| 281 |
+
const isRejected = auditResult.verdict !== "approved";
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
auditResult,
|
| 285 |
+
rejectionFeedback: isRejected ? formatFeedback(auditResult) : null,
|
| 286 |
+
planRevisionCount: isRejected ? state.planRevisionCount + 1 : state.planRevisionCount,
|
| 287 |
+
status: isRejected ? "thinking" : "awaiting_approval",
|
| 288 |
+
};
|
| 289 |
+
}
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
### HITL Gate Node
|
| 293 |
+
|
| 294 |
+
Uses LangGraph's `interrupt()` primitive to pause execution and write a checkpoint:
|
| 295 |
+
|
| 296 |
+
```typescript
|
| 297 |
+
async function hitlGateNode(state: ResearchState): Promise<Partial<ResearchState>> {
|
| 298 |
+
interrupt({
|
| 299 |
+
message: "Plan ready for review. Set humanApproved=true to continue.",
|
| 300 |
+
plan: state.plan,
|
| 301 |
+
auditResult: state.auditResult,
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
return { updatedAt: new Date().toISOString() };
|
| 305 |
+
}
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
### Tool Executor Node
|
| 309 |
+
|
| 310 |
+
Iterates through plan steps. Each invocation processes one step and increments `currentStepIndex`:
|
| 311 |
+
|
| 312 |
+
```typescript
|
| 313 |
+
async function toolExecutorNode(state: ResearchState): Promise<Partial<ResearchState>> {
|
| 314 |
+
const step = state.plan.steps[state.currentStepIndex];
|
| 315 |
+
const output = await dispatchTool(step.tool, step.input);
|
| 316 |
+
|
| 317 |
+
const updatedSteps = state.plan.steps.map((s, i) =>
|
| 318 |
+
i === state.currentStepIndex ? { ...s, output, executedAt: new Date().toISOString() } : s
|
| 319 |
+
);
|
| 320 |
+
|
| 321 |
+
return {
|
| 322 |
+
plan: { ...state.plan, steps: updatedSteps },
|
| 323 |
+
currentStepIndex: state.currentStepIndex + 1,
|
| 324 |
+
status: "executing",
|
| 325 |
+
};
|
| 326 |
+
}
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
### Synthesizer Node
|
| 330 |
+
|
| 331 |
+
Aggregates all step outputs into a final report:
|
| 332 |
+
|
| 333 |
+
```typescript
|
| 334 |
+
async function synthesizerNode(state: ResearchState): Promise<Partial<ResearchState>> {
|
| 335 |
+
const stepsContext = state.plan.steps
|
| 336 |
+
.map((s, i) => `Step ${i + 1} [${s.tool}]: ${s.output}`)
|
| 337 |
+
.join("\n\n");
|
| 338 |
+
|
| 339 |
+
const finalReport = await chatComplete(
|
| 340 |
+
"Write a comprehensive research report.",
|
| 341 |
+
`Objective: ${state.plan.objective}\n\nResults:\n${stepsContext}`
|
| 342 |
+
);
|
| 343 |
+
|
| 344 |
+
return { finalReport, status: "complete" };
|
| 345 |
+
}
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
|
| 350 |
+
## Routing and Conditional Edges
|
| 351 |
+
|
| 352 |
+
LangGraph uses routing functions to determine the next node based on current state.
|
| 353 |
+
|
| 354 |
+
### Post-Audit Routing
|
| 355 |
+
|
| 356 |
+
```typescript
|
| 357 |
+
function routeAfterAudit(state: ResearchState): "thinker" | "hitl_gate" | typeof END {
|
| 358 |
+
if (state.planRevisionCount >= state.maxPlanRevisions) {
|
| 359 |
+
return END; // Safety ceiling reached
|
| 360 |
+
}
|
| 361 |
+
if (state.auditResult?.verdict !== "approved") {
|
| 362 |
+
return "thinker"; // Loop back for revision
|
| 363 |
+
}
|
| 364 |
+
return "hitl_gate"; // Proceed to human approval
|
| 365 |
+
}
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
### Post-HITL Routing
|
| 369 |
+
|
| 370 |
+
```typescript
|
| 371 |
+
function routeAfterHitl(state: ResearchState): "tool_executor" | typeof END {
|
| 372 |
+
if (!state.humanApproved) {
|
| 373 |
+
return END; // Fail-safe if approval missing
|
| 374 |
+
}
|
| 375 |
+
return "tool_executor";
|
| 376 |
+
}
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
### Post-Tool Routing
|
| 380 |
+
|
| 381 |
+
```typescript
|
| 382 |
+
function routeAfterToolStep(state: ResearchState): "tool_executor" | "synthesizer" {
|
| 383 |
+
if (state.currentStepIndex < state.plan.steps.length) {
|
| 384 |
+
return "tool_executor"; // More steps remain
|
| 385 |
+
}
|
| 386 |
+
return "synthesizer"; // All steps complete
|
| 387 |
+
}
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
---
|
| 391 |
+
|
| 392 |
+
## LLM Integration
|
| 393 |
+
|
| 394 |
+
### Hugging Face Transformers
|
| 395 |
+
|
| 396 |
+
The project uses `@huggingface/transformers` for local inference. Models are cached to `.hf-cache/` for persistence across restarts.
|
| 397 |
+
|
| 398 |
+
```typescript
|
| 399 |
+
import { pipeline, TextGenerationPipeline, env } from "@huggingface/transformers";
|
| 400 |
+
|
| 401 |
+
env.cacheDir = process.env.HF_CACHE_DIR || "./.hf-cache";
|
| 402 |
+
|
| 403 |
+
const MODEL_ID = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-360M-Instruct";
|
| 404 |
+
|
| 405 |
+
let generatorPromise: Promise<TextGenerationPipeline> | null = null;
|
| 406 |
+
|
| 407 |
+
export function loadModel(onProgress?: ProgressCallback): Promise<TextGenerationPipeline> {
|
| 408 |
+
if (generatorPromise) return generatorPromise;
|
| 409 |
+
|
| 410 |
+
generatorPromise = pipeline("text-generation", MODEL_ID, {
|
| 411 |
+
progress_callback: (data) => {
|
| 412 |
+
onProgress?.({
|
| 413 |
+
status: data.status === "progress" ? "downloading" : "loading",
|
| 414 |
+
progress: Math.round((data.progress || 0) * 100),
|
| 415 |
+
file: data.file || "",
|
| 416 |
+
message: `Downloading ${data.file?.split("/").pop()}`,
|
| 417 |
+
});
|
| 418 |
+
},
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
return generatorPromise;
|
| 422 |
+
}
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
### Chat Completion Interface
|
| 426 |
+
|
| 427 |
+
```typescript
|
| 428 |
+
export async function chatComplete(systemPrompt: string, userMessage: string): Promise<string> {
|
| 429 |
+
const generator = await loadModel();
|
| 430 |
+
|
| 431 |
+
const output = await generator(
|
| 432 |
+
[
|
| 433 |
+
{ role: "system", content: systemPrompt },
|
| 434 |
+
{ role: "user", content: userMessage },
|
| 435 |
+
],
|
| 436 |
+
{ max_new_tokens: 4096, do_sample: true, temperature: 0.7 }
|
| 437 |
+
);
|
| 438 |
+
|
| 439 |
+
const result = output[0] as { generated_text: Array<{ role: string; content: string }> };
|
| 440 |
+
return result.generated_text.find((m) => m.role === "assistant")?.content ?? "";
|
| 441 |
+
}
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
### JSON Extraction
|
| 445 |
+
|
| 446 |
+
Small models often produce malformed JSON. The `extractJSON` utility handles common issues:
|
| 447 |
+
|
| 448 |
+
```typescript
|
| 449 |
+
export function extractJSON(text: string): unknown {
|
| 450 |
+
// Try direct parse
|
| 451 |
+
try { return JSON.parse(text); } catch {}
|
| 452 |
+
|
| 453 |
+
// Remove markdown fences
|
| 454 |
+
const cleaned = text.replace(/```json\s*/gi, "").replace(/```\s*/g, "");
|
| 455 |
+
|
| 456 |
+
// Extract JSON object
|
| 457 |
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
| 458 |
+
if (match) {
|
| 459 |
+
try { return JSON.parse(match[0]); } catch {}
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
throw new Error(`Could not extract JSON from: ${text.slice(0, 200)}`);
|
| 463 |
+
}
|
| 464 |
+
```
|
| 465 |
+
|
| 466 |
+
---
|
| 467 |
+
|
| 468 |
+
## API Layer
|
| 469 |
+
|
| 470 |
+
### Research Endpoint (SSE)
|
| 471 |
+
|
| 472 |
+
`POST /api/research` streams research state updates as **Server-Sent Events** so the client can show progress immediately and parse events by type. Using SSE (instead of raw NDJSON) gives a standard, well-supported streaming protocol and allows future event names (e.g. `ping`, `heartbeat`) without changing the wire format.
|
| 473 |
+
|
| 474 |
+
**Request body:** `{ "query": string, "knowledge"?: Array<{ id, type, label, meta? }> }`. The `knowledge` array is sent by the workspace when the user adds files, URLs, or notes in the Context panel; the backend currently uses only `query` and may later use `knowledge` for RAG or plan conditioning.
|
| 475 |
+
|
| 476 |
+
**Response:** `Content-Type: text/event-stream`. Each message is an SSE message:
|
| 477 |
+
|
| 478 |
+
- `event: start` — First event; signals that the run has started (enables optimistic UI).
|
| 479 |
+
- `event: research` — One per graph node update; `data` is `{ node, state }`.
|
| 480 |
+
- `event: error` — On exception; `data` includes `node: "_error"` and `state.errorMessage`.
|
| 481 |
+
|
| 482 |
+
```typescript
|
| 483 |
+
// Server: send helper
|
| 484 |
+
const send = (event: string, payload: { node: string; state: Record<string, unknown> }) => {
|
| 485 |
+
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`));
|
| 486 |
+
};
|
| 487 |
+
send("start", { node: "_start", state: { status: "started", ... } });
|
| 488 |
+
for await (const event of runResearch(query)) {
|
| 489 |
+
send("research", event);
|
| 490 |
+
}
|
| 491 |
+
// On catch: send("error", { node: "_error", state: { status: "failed", errorMessage } });
|
| 492 |
+
```
|
| 493 |
+
|
| 494 |
+
### Model Loading Endpoint
|
| 495 |
+
|
| 496 |
+
`GET /api/model/load?...modelId=...&dtype=...` (or POST with same query) streams download/load progress via SSE. The client parses `data: {...}` lines to drive the progress bar and status pill. See [Frontend: Real-Time Workspace](#frontend-real-time-workspace) for how the UI consumes these streams.
|
| 497 |
+
|
| 498 |
+
---
|
| 499 |
+
|
| 500 |
+
## Frontend: Real-Time Workspace
|
| 501 |
+
|
| 502 |
+
The React client (`app/page.tsx`) is a single-page workspace that mirrors a Cursor/Gemini-style flow: immediate, non-blocking feedback, streaming AI responses, and a dedicated context/knowledge area.
|
| 503 |
+
|
| 504 |
+
### What the UI Provides
|
| 505 |
+
|
| 506 |
+
| Area | Purpose |
|
| 507 |
+
|------|--------|
|
| 508 |
+
| **Chat** | User messages and assistant replies. When a run finishes, the final report is streamed **word-by-word** into the last assistant message to mimic a live conversation. |
|
| 509 |
+
| **Context / Knowledge** | Drag-and-drop zone for PDFs, text files, or URLs; optional fields to add URLs and short notes. Stored in React state and sent as `knowledge` with each research request for future backend use. |
|
| 510 |
+
| **Quick-action chips** | A row of buttons below the input (e.g. “Help me learn this topic”, “Summarize these docs”) that set or extend the query and trigger a run—Gemini-style shortcuts. |
|
| 511 |
+
| **Starter cards** | Empty state with example prompts (e.g. “How does Gemini Pro work…”) that fill the input and can be run in one click. |
|
| 512 |
+
| **Model card** | Compact panel for model selection, load/progress, and status (Ready / Loading / Error). |
|
| 513 |
+
| **Reasoning trace** | Scrollable list of the latest reasoning entries from the event stream (node + summary) for observability. |
|
| 514 |
+
|
| 515 |
+
### Optimistic UI and Shimmer
|
| 516 |
+
|
| 517 |
+
- On “Run Research”, the UI immediately appends the user message and a placeholder assistant message with a shimmer skeleton, then consumes SSE and updates that message when the final report arrives.
|
| 518 |
+
- Shimmer and loading states use Tailwind (e.g. `animate-pulse`, neutral backgrounds) so the interface feels responsive even when the agent is still planning or executing.
|
| 519 |
+
|
| 520 |
+
### SSE Consumption (Research)
|
| 521 |
+
|
| 522 |
+
The client uses `EventSource`-style parsing on the `ReadableStream`: split by `\n\n`, then for each line look for `event:` and `data:` and dispatch by event type. Accumulated `research` events update both the reasoning trace and the chat when `finalReport` is present.
|
| 523 |
+
|
| 524 |
+
```typescript
|
| 525 |
+
// Conceptual: read stream, split by double newline, parse "event:" and "data:"
|
| 526 |
+
const chunks = buffer.split("\n\n");
|
| 527 |
+
for (const chunk of chunks) {
|
| 528 |
+
const eventMatch = chunk.match(/event:\s*(\w+)/);
|
| 529 |
+
const dataMatch = chunk.match(/data:\s*(\{[\s\S]*\})/);
|
| 530 |
+
if (eventMatch && dataMatch) {
|
| 531 |
+
const payload = JSON.parse(dataMatch[1]);
|
| 532 |
+
if (eventMatch[1] === "research") setEvents((prev) => [...prev, payload]);
|
| 533 |
+
// ... handle start, error; when payload.state.finalReport exists, run word-by-word animation
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
```
|
| 537 |
+
|
| 538 |
+
### Word-by-Word Streaming
|
| 539 |
+
|
| 540 |
+
When an event contains `state.finalReport`, the full text is not dumped at once. A small timer (e.g. every 40ms) reveals the report word-by-word in the last assistant message and clears the “streaming” state when done. This keeps the same SSE event payload while making the reply feel live.
|
| 541 |
+
|
| 542 |
+
---
|
| 543 |
+
|
| 544 |
+
## Running the Project
|
| 545 |
+
|
| 546 |
+
### Prerequisites
|
| 547 |
+
|
| 548 |
+
- Node.js 20+
|
| 549 |
+
- npm or pnpm
|
| 550 |
+
|
| 551 |
+
### Installation
|
| 552 |
+
|
| 553 |
+
```bash
|
| 554 |
+
npm install
|
| 555 |
+
```
|
| 556 |
+
|
| 557 |
+
### Build the LLM worker (required for local inference)
|
| 558 |
+
|
| 559 |
+
Inference runs in a Node.js worker thread. Compile the worker once before using the app locally:
|
| 560 |
+
|
| 561 |
+
```bash
|
| 562 |
+
npm run build:worker
|
| 563 |
+
```
|
| 564 |
+
|
| 565 |
+
This writes `dist/llm/worker-entry.js` and `dist/llm/pipeline.js`. The production build runs this step automatically.
|
| 566 |
+
|
| 567 |
+
### Development
|
| 568 |
+
|
| 569 |
+
```bash
|
| 570 |
+
npm run dev
|
| 571 |
+
```
|
| 572 |
+
|
| 573 |
+
Open http://localhost:3000. Click "Load Model" to download and initialize the LLM, then run research queries.
|
| 574 |
+
|
| 575 |
+
If you see an error that the worker was not found, run `npm run build:worker` first.
|
| 576 |
+
|
| 577 |
+
### Production build
|
| 578 |
+
|
| 579 |
+
```bash
|
| 580 |
+
npm run build
|
| 581 |
+
```
|
| 582 |
+
|
| 583 |
+
This runs `build:worker` then builds the Next.js app with webpack. The app is served with:
|
| 584 |
+
|
| 585 |
+
```bash
|
| 586 |
+
npm run start
|
| 587 |
+
```
|
| 588 |
+
|
| 589 |
+
### First run
|
| 590 |
+
|
| 591 |
+
The first model load downloads weights to `.hf-cache/` (approximately 400MB for SmolLM2-360M Q4). Subsequent loads are fast.
|
| 592 |
+
|
| 593 |
+
### Deploy (Render)
|
| 594 |
+
|
| 595 |
+
The repo includes a **Dockerfile** and config for [Render](https://render.com). Render builds the image in the cloud (no local Docker required). See **[DEPLOY.md](DEPLOY.md)** for steps.
|
| 596 |
+
|
| 597 |
+
---
|
| 598 |
+
|
| 599 |
+
## Configuration
|
| 600 |
+
|
| 601 |
+
Create `.env.local` from `.env.example`:
|
| 602 |
+
|
| 603 |
+
```bash
|
| 604 |
+
# Model selection
|
| 605 |
+
HF_MODEL=HuggingFaceTB/SmolLM2-360M-Instruct
|
| 606 |
+
HF_CACHE_DIR=./.hf-cache
|
| 607 |
+
```
|
| 608 |
+
|
| 609 |
+
### Observability (LangSmith)
|
| 610 |
+
|
| 611 |
+
To make agent behavior observable and debug failures (e.g. thinker returning invalid JSON), use [LangSmith](https://smith.langchain.com/). Set in `.env.local`:
|
| 612 |
+
|
| 613 |
+
```bash
|
| 614 |
+
LANGCHAIN_TRACING_V2=true
|
| 615 |
+
LANGCHAIN_API_KEY=your-langsmith-api-key
|
| 616 |
+
LANGCHAIN_PROJECT=deeptrust
|
| 617 |
+
```
|
| 618 |
+
|
| 619 |
+
With tracing enabled, every graph run is recorded. You can inspect prompts, raw LLM outputs, and state transitions in the LangSmith UI, which helps diagnose schema validation errors and long-running or looping runs.
|
| 620 |
+
|
| 621 |
+
### Available Models
|
| 622 |
+
|
| 623 |
+
| Model | Size | Speed | Quality |
|
| 624 |
+
|-------|------|-------|---------|
|
| 625 |
+
| `HuggingFaceTB/SmolLM2-360M-Instruct` | 400MB | Fast | Basic |
|
| 626 |
+
| `HuggingFaceTB/SmolLM2-1.7B-Instruct` | 1.7GB | Moderate | Better |
|
| 627 |
+
|
| 628 |
+
---
|
| 629 |
+
|
| 630 |
+
## Key Concepts Demonstrated
|
| 631 |
+
|
| 632 |
+
1. **State Machines for Agents**: Using LangGraph to model complex, cyclic agent workflows
|
| 633 |
+
2. **Type-Safe State**: Zod schemas with TypeScript inference for runtime validation
|
| 634 |
+
3. **Local LLM Inference**: Running models in-process without external API dependencies
|
| 635 |
+
4. **Streaming Responses**: Server-Sent Events and ReadableStream for real-time updates
|
| 636 |
+
5. **Human-in-the-Loop**: Checkpoint interrupts for manual approval gates
|
| 637 |
+
6. **Revision Loops**: Cyclic graph edges for iterative refinement with safety ceilings
|
| 638 |
+
|
| 639 |
+
---
|
| 640 |
+
|
| 641 |
+
## License
|
| 642 |
+
|
| 643 |
+
MIT
|
app/api/model/load/route.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Avoid loading @huggingface/transformers on Vercel (no native runtime / disk); use dynamic import only when needed.
|
| 2 |
+
const isVercel = process.env.VERCEL === "1";
|
| 3 |
+
|
| 4 |
+
function unsupportedStream(encoder: TextEncoder): ReadableStream<Uint8Array> {
|
| 5 |
+
const msg = {
|
| 6 |
+
status: "error",
|
| 7 |
+
progress: 0,
|
| 8 |
+
message:
|
| 9 |
+
"Model loading is not supported on Vercel (serverless). Run the app locally or self-host for local inference.",
|
| 10 |
+
models: [] as { id: string; label: string; dtype?: string; sizeNote?: string }[],
|
| 11 |
+
};
|
| 12 |
+
return new ReadableStream({
|
| 13 |
+
start(controller) {
|
| 14 |
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
|
| 15 |
+
controller.close();
|
| 16 |
+
},
|
| 17 |
+
});
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export async function GET(request: Request) {
|
| 21 |
+
const encoder = new TextEncoder();
|
| 22 |
+
|
| 23 |
+
if (isVercel) {
|
| 24 |
+
return new Response(unsupportedStream(encoder), {
|
| 25 |
+
headers: {
|
| 26 |
+
"Content-Type": "text/event-stream",
|
| 27 |
+
"Cache-Control": "no-cache",
|
| 28 |
+
Connection: "keep-alive",
|
| 29 |
+
},
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const { loadModel, getModelStatus, MODELS } = await import("@/lib/agent/llm");
|
| 34 |
+
const { searchParams } = new URL(request.url);
|
| 35 |
+
const modelId = searchParams.get("modelId") ?? undefined;
|
| 36 |
+
const dtype = searchParams.get("dtype") ?? undefined;
|
| 37 |
+
|
| 38 |
+
const stream = new ReadableStream({
|
| 39 |
+
async start(controller) {
|
| 40 |
+
const send = (data: object) => {
|
| 41 |
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const status = await getModelStatus(modelId, dtype);
|
| 45 |
+
const requestedId = modelId ?? status.modelId;
|
| 46 |
+
const requestedDtype = dtype ?? status.dtype;
|
| 47 |
+
if (
|
| 48 |
+
status.status === "ready" &&
|
| 49 |
+
status.modelId === requestedId &&
|
| 50 |
+
status.dtype === requestedDtype
|
| 51 |
+
) {
|
| 52 |
+
send({ ...status, models: status.models ?? MODELS });
|
| 53 |
+
controller.close();
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
send({
|
| 58 |
+
status: "loading",
|
| 59 |
+
progress: 0,
|
| 60 |
+
message: "Starting model load...",
|
| 61 |
+
modelId: modelId ?? status.modelId,
|
| 62 |
+
dtype: dtype ?? status.dtype,
|
| 63 |
+
models: status.models ?? MODELS,
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
try {
|
| 67 |
+
await loadModel(modelId, dtype as "q4" | "fp16" | "fp32" | undefined, (progress) => {
|
| 68 |
+
send({ ...progress, models: (progress as { models?: typeof MODELS }).models ?? MODELS });
|
| 69 |
+
});
|
| 70 |
+
controller.close();
|
| 71 |
+
} catch (error) {
|
| 72 |
+
send({
|
| 73 |
+
status: "error",
|
| 74 |
+
progress: 0,
|
| 75 |
+
message: error instanceof Error ? error.message : "Unknown error",
|
| 76 |
+
modelId: modelId ?? undefined,
|
| 77 |
+
dtype: dtype ?? undefined,
|
| 78 |
+
models: MODELS,
|
| 79 |
+
});
|
| 80 |
+
controller.close();
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
return new Response(stream, {
|
| 86 |
+
headers: {
|
| 87 |
+
"Content-Type": "text/event-stream",
|
| 88 |
+
"Cache-Control": "no-cache",
|
| 89 |
+
Connection: "keep-alive",
|
| 90 |
+
},
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export async function POST() {
|
| 95 |
+
if (isVercel) {
|
| 96 |
+
return Response.json({
|
| 97 |
+
status: "error",
|
| 98 |
+
progress: 0,
|
| 99 |
+
file: "",
|
| 100 |
+
message:
|
| 101 |
+
"Model loading is not supported on Vercel (serverless). Run the app locally or self-host for local inference.",
|
| 102 |
+
models: [],
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const { getModelStatus, MODELS } = await import("@/lib/agent/llm");
|
| 107 |
+
const status = await getModelStatus();
|
| 108 |
+
return Response.json({ ...status, models: status.models ?? MODELS });
|
| 109 |
+
}
|
app/api/research/approve/route.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { approveAndResume } from "@/lib/agent";
|
| 2 |
+
import { NextRequest } from "next/server";
|
| 3 |
+
|
| 4 |
+
export async function POST(req: NextRequest) {
|
| 5 |
+
const body = await req.json();
|
| 6 |
+
const { threadId } = body as { threadId?: string };
|
| 7 |
+
|
| 8 |
+
if (!threadId) {
|
| 9 |
+
return new Response(
|
| 10 |
+
JSON.stringify({ error: "threadId is required to approve a run" }),
|
| 11 |
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
| 12 |
+
);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const encoder = new TextEncoder();
|
| 16 |
+
|
| 17 |
+
const stream = new ReadableStream({
|
| 18 |
+
async start(controller) {
|
| 19 |
+
const send = (
|
| 20 |
+
event: string,
|
| 21 |
+
payload: { node: string; state: Record<string, unknown> }
|
| 22 |
+
) => {
|
| 23 |
+
const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
| 24 |
+
controller.enqueue(encoder.encode(data));
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
for await (const event of approveAndResume(threadId)) {
|
| 29 |
+
send("research", event);
|
| 30 |
+
}
|
| 31 |
+
} catch (err) {
|
| 32 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 33 |
+
send("error", {
|
| 34 |
+
node: "_error",
|
| 35 |
+
state: {
|
| 36 |
+
status: "failed",
|
| 37 |
+
errorMessage: message,
|
| 38 |
+
},
|
| 39 |
+
});
|
| 40 |
+
} finally {
|
| 41 |
+
controller.close();
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
return new Response(stream, {
|
| 47 |
+
headers: {
|
| 48 |
+
"Content-Type": "text/event-stream; charset=utf-8",
|
| 49 |
+
"Cache-Control": "no-cache, no-transform",
|
| 50 |
+
Connection: "keep-alive",
|
| 51 |
+
"X-Accel-Buffering": "no",
|
| 52 |
+
},
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
app/api/research/route.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { runResearch } from "@/lib/agent";
|
| 2 |
+
import { NextRequest } from "next/server";
|
| 3 |
+
|
| 4 |
+
export async function POST(req: NextRequest) {
|
| 5 |
+
const body = await req.json();
|
| 6 |
+
const {
|
| 7 |
+
query,
|
| 8 |
+
retrievedContext,
|
| 9 |
+
contextUrls,
|
| 10 |
+
}: {
|
| 11 |
+
query: string;
|
| 12 |
+
retrievedContext?: string;
|
| 13 |
+
contextUrls?: string[];
|
| 14 |
+
} = body;
|
| 15 |
+
|
| 16 |
+
const encoder = new TextEncoder();
|
| 17 |
+
|
| 18 |
+
const stream = new ReadableStream({
|
| 19 |
+
async start(controller) {
|
| 20 |
+
const send = (
|
| 21 |
+
event: string,
|
| 22 |
+
payload: { node: string; state: Record<string, unknown> }
|
| 23 |
+
) => {
|
| 24 |
+
const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
| 25 |
+
controller.enqueue(encoder.encode(data));
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// Initial "cursor-like" optimistic event
|
| 29 |
+
send("start", {
|
| 30 |
+
node: "_start",
|
| 31 |
+
state: {
|
| 32 |
+
status: "started",
|
| 33 |
+
plan: { objective: "Starting…", steps: [] },
|
| 34 |
+
reasoning: [
|
| 35 |
+
{
|
| 36 |
+
node: "_start",
|
| 37 |
+
summary:
|
| 38 |
+
"Research started. First step (planning) may take 1–2 minutes on slower devices.",
|
| 39 |
+
},
|
| 40 |
+
],
|
| 41 |
+
},
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
try {
|
| 45 |
+
const options =
|
| 46 |
+
retrievedContext != null || (contextUrls?.length ?? 0) > 0
|
| 47 |
+
? { knowledgeContext: retrievedContext ?? "", contextUrls: contextUrls ?? [] }
|
| 48 |
+
: undefined;
|
| 49 |
+
for await (const event of runResearch(query, "Research Session", options)) {
|
| 50 |
+
if (event.node === "__interrupt__") {
|
| 51 |
+
// Surface HITL interrupt to the client and close the stream.
|
| 52 |
+
send("hitl_waiting", event);
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
send("research", event);
|
| 56 |
+
}
|
| 57 |
+
} catch (err) {
|
| 58 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 59 |
+
send("error", {
|
| 60 |
+
node: "_error",
|
| 61 |
+
state: {
|
| 62 |
+
status: "failed",
|
| 63 |
+
errorMessage: message,
|
| 64 |
+
},
|
| 65 |
+
});
|
| 66 |
+
} finally {
|
| 67 |
+
controller.close();
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
return new Response(stream, {
|
| 73 |
+
headers: {
|
| 74 |
+
"Content-Type": "text/event-stream; charset=utf-8",
|
| 75 |
+
"Cache-Control": "no-cache, no-transform",
|
| 76 |
+
Connection: "keep-alive",
|
| 77 |
+
"X-Accel-Buffering": "no",
|
| 78 |
+
},
|
| 79 |
+
});
|
| 80 |
+
}
|
app/favicon.ico
ADDED
|
|
app/globals.css
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #ffffff;
|
| 5 |
+
--foreground: #171717;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@theme inline {
|
| 9 |
+
--color-background: var(--background);
|
| 10 |
+
--color-foreground: var(--foreground);
|
| 11 |
+
--font-sans: var(--font-geist-sans);
|
| 12 |
+
--font-mono: var(--font-geist-mono);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
@media (prefers-color-scheme: dark) {
|
| 16 |
+
:root {
|
| 17 |
+
--background: #0a0a0a;
|
| 18 |
+
--foreground: #ededed;
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body {
|
| 23 |
+
background: var(--background);
|
| 24 |
+
color: var(--foreground);
|
| 25 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 26 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "Create Next App",
|
| 17 |
+
description: "Generated by create next app",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html lang="en">
|
| 27 |
+
<body
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 29 |
+
>
|
| 30 |
+
{children}
|
| 31 |
+
</body>
|
| 32 |
+
</html>
|
| 33 |
+
);
|
| 34 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,1120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useCallback, useEffect, useRef } from "react";
|
| 4 |
+
|
| 5 |
+
interface ModelProgress {
|
| 6 |
+
status: "idle" | "loading" | "downloading" | "ready" | "error";
|
| 7 |
+
progress: number;
|
| 8 |
+
file: string;
|
| 9 |
+
message: string;
|
| 10 |
+
modelId?: string;
|
| 11 |
+
dtype?: string;
|
| 12 |
+
models?: ModelOption[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface ModelOption {
|
| 16 |
+
id: string;
|
| 17 |
+
label: string;
|
| 18 |
+
dtype?: "q4" | "fp16" | "fp32";
|
| 19 |
+
sizeNote?: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface ResearchEvent {
|
| 23 |
+
node: string;
|
| 24 |
+
state: {
|
| 25 |
+
threadId?: string;
|
| 26 |
+
interrupt?: unknown;
|
| 27 |
+
status?: string;
|
| 28 |
+
plan?: {
|
| 29 |
+
objective: string;
|
| 30 |
+
steps: Array<{ tool: string; input: string; rationale?: string }>;
|
| 31 |
+
};
|
| 32 |
+
auditResult?: {
|
| 33 |
+
verdict: "approved" | "rejected" | "needs_revision";
|
| 34 |
+
policyViolations?: string[];
|
| 35 |
+
suggestions?: string[];
|
| 36 |
+
};
|
| 37 |
+
finalReport?: string;
|
| 38 |
+
reasoning?: Array<{ node: string; summary: string }>;
|
| 39 |
+
errorMessage?: string;
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
type ChatRole = "user" | "assistant";
|
| 44 |
+
|
| 45 |
+
interface ChatMessage {
|
| 46 |
+
id: string;
|
| 47 |
+
role: ChatRole;
|
| 48 |
+
content: string;
|
| 49 |
+
isStreaming?: boolean;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
type KnowledgeItemType = "file" | "url" | "note";
|
| 53 |
+
|
| 54 |
+
interface KnowledgeItem {
|
| 55 |
+
id: string;
|
| 56 |
+
type: KnowledgeItemType;
|
| 57 |
+
label: string;
|
| 58 |
+
meta?: string;
|
| 59 |
+
status?: "pending" | "indexing" | "indexed" | "error";
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const DEFAULT_MODELS: ModelOption[] = [
|
| 63 |
+
{
|
| 64 |
+
id: "HuggingFaceTB/SmolLM2-135M-Instruct",
|
| 65 |
+
label: "SmolLM2 135M (Q4, tiny)",
|
| 66 |
+
dtype: "q4",
|
| 67 |
+
sizeNote: "~150–200 MB (approx)",
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 71 |
+
label: "SmolLM2 360M (Q4)",
|
| 72 |
+
dtype: "q4",
|
| 73 |
+
sizeNote: "~388 MB",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 77 |
+
label: "SmolLM2 360M (FP16)",
|
| 78 |
+
dtype: "fp16",
|
| 79 |
+
sizeNote: "~725 MB",
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 83 |
+
label: "SmolLM2 360M (full)",
|
| 84 |
+
dtype: "fp32",
|
| 85 |
+
sizeNote: "~1.45 GB",
|
| 86 |
+
},
|
| 87 |
+
];
|
| 88 |
+
|
| 89 |
+
const QUICK_ACTIONS = [
|
| 90 |
+
"Create image-ready research brief",
|
| 91 |
+
"Help me learn this topic",
|
| 92 |
+
"Summarize these docs for me",
|
| 93 |
+
"Audit my assumptions",
|
| 94 |
+
"Turn this into an implementation plan",
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
const PREVIEW_QUERIES = [
|
| 98 |
+
"How does Gemini Pro work for deep research workflows?",
|
| 99 |
+
"Design a learning plan for mastering LangGraph in 30 days.",
|
| 100 |
+
"Compare self-hosted vs managed LLM stacks for a fintech startup.",
|
| 101 |
+
];
|
| 102 |
+
|
| 103 |
+
export default function DeepTrustWorkspace() {
|
| 104 |
+
const [modelStatus, setModelStatus] = useState<ModelProgress>({
|
| 105 |
+
status: "idle",
|
| 106 |
+
progress: 0,
|
| 107 |
+
file: "",
|
| 108 |
+
message: "Model not loaded",
|
| 109 |
+
});
|
| 110 |
+
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
|
| 111 |
+
const models = modelStatus.models?.length ? modelStatus.models : DEFAULT_MODELS;
|
| 112 |
+
const selectedModel = models[selectedModelIndex] ?? null;
|
| 113 |
+
|
| 114 |
+
const [query, setQuery] = useState(PREVIEW_QUERIES[0]);
|
| 115 |
+
const [chat, setChat] = useState<ChatMessage[]>([]);
|
| 116 |
+
const [events, setEvents] = useState<ResearchEvent[]>([]);
|
| 117 |
+
const [hitlThreadId, setHitlThreadId] = useState<string | null>(null);
|
| 118 |
+
const [hitlPayload, setHitlPayload] = useState<unknown | null>(null);
|
| 119 |
+
const [error, setError] = useState<string | null>(null);
|
| 120 |
+
const [isStreaming, setIsStreaming] = useState(false);
|
| 121 |
+
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeItem[]>([]);
|
| 122 |
+
const [isDragOver, setIsDragOver] = useState(false);
|
| 123 |
+
const [noteDraft, setNoteDraft] = useState("");
|
| 124 |
+
const [urlDraft, setUrlDraft] = useState("");
|
| 125 |
+
|
| 126 |
+
const streamAbortRef = useRef<AbortController | null>(null);
|
| 127 |
+
const streamingTargetRef = useRef<string | null>(null);
|
| 128 |
+
const streamingTimerRef = useRef<number | null>(null);
|
| 129 |
+
const pendingFullTextRef = useRef<string | null>(null);
|
| 130 |
+
|
| 131 |
+
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
| 132 |
+
|
| 133 |
+
useEffect(() => {
|
| 134 |
+
fetch("/api/model/load", { method: "POST" })
|
| 135 |
+
.then((res) => res.json())
|
| 136 |
+
.then((data: ModelProgress) => {
|
| 137 |
+
setModelStatus((prev) => ({ ...prev, ...data }));
|
| 138 |
+
if (data.models?.length && selectedModelIndex >= data.models.length) {
|
| 139 |
+
setSelectedModelIndex(0);
|
| 140 |
+
}
|
| 141 |
+
})
|
| 142 |
+
.catch(() => {});
|
| 143 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 144 |
+
}, []);
|
| 145 |
+
|
| 146 |
+
const hydrateKnowledge = useCallback(() => {
|
| 147 |
+
import("@/lib/knowledge")
|
| 148 |
+
.then(({ listKnowledgeItems }) => listKnowledgeItems())
|
| 149 |
+
.then((items) => setKnowledgeItems(items))
|
| 150 |
+
.catch(() => {});
|
| 151 |
+
}, []);
|
| 152 |
+
useEffect(() => {
|
| 153 |
+
hydrateKnowledge();
|
| 154 |
+
}, [hydrateKnowledge]);
|
| 155 |
+
|
| 156 |
+
const startStreamingAnimation = useCallback((fullText: string, messageId: string) => {
|
| 157 |
+
if (!fullText) return;
|
| 158 |
+
if (streamingTimerRef.current) {
|
| 159 |
+
window.clearInterval(streamingTimerRef.current);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const words = fullText.split(/\s+/);
|
| 163 |
+
let index = 0;
|
| 164 |
+
|
| 165 |
+
streamingTargetRef.current = messageId;
|
| 166 |
+
|
| 167 |
+
setChat((prev) =>
|
| 168 |
+
prev.map((m) =>
|
| 169 |
+
m.id === messageId
|
| 170 |
+
? {
|
| 171 |
+
...m,
|
| 172 |
+
content: "",
|
| 173 |
+
isStreaming: true,
|
| 174 |
+
}
|
| 175 |
+
: m
|
| 176 |
+
)
|
| 177 |
+
);
|
| 178 |
+
|
| 179 |
+
const timer = window.setInterval(() => {
|
| 180 |
+
index += 1;
|
| 181 |
+
const nextContent = words.slice(0, index).join(" ");
|
| 182 |
+
|
| 183 |
+
setChat((prev) =>
|
| 184 |
+
prev.map((m) =>
|
| 185 |
+
m.id === messageId
|
| 186 |
+
? {
|
| 187 |
+
...m,
|
| 188 |
+
content: nextContent,
|
| 189 |
+
}
|
| 190 |
+
: m
|
| 191 |
+
)
|
| 192 |
+
);
|
| 193 |
+
|
| 194 |
+
if (index >= words.length) {
|
| 195 |
+
if (streamingTimerRef.current) {
|
| 196 |
+
window.clearInterval(streamingTimerRef.current);
|
| 197 |
+
}
|
| 198 |
+
streamingTimerRef.current = null;
|
| 199 |
+
pendingFullTextRef.current = null;
|
| 200 |
+
setChat((prev) =>
|
| 201 |
+
prev.map((m) =>
|
| 202 |
+
m.id === messageId
|
| 203 |
+
? {
|
| 204 |
+
...m,
|
| 205 |
+
isStreaming: false,
|
| 206 |
+
}
|
| 207 |
+
: m
|
| 208 |
+
)
|
| 209 |
+
);
|
| 210 |
+
}
|
| 211 |
+
}, 40);
|
| 212 |
+
|
| 213 |
+
streamingTimerRef.current = timer;
|
| 214 |
+
}, []);
|
| 215 |
+
|
| 216 |
+
useEffect(() => {
|
| 217 |
+
if (chatScrollRef.current) {
|
| 218 |
+
chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
|
| 219 |
+
}
|
| 220 |
+
}, [chat]);
|
| 221 |
+
|
| 222 |
+
const isModelReady =
|
| 223 |
+
modelStatus.status === "ready" &&
|
| 224 |
+
selectedModel &&
|
| 225 |
+
modelStatus.modelId === selectedModel.id &&
|
| 226 |
+
modelStatus.dtype === selectedModel.dtype;
|
| 227 |
+
|
| 228 |
+
const isModelLoading =
|
| 229 |
+
modelStatus.status === "loading" || modelStatus.status === "downloading";
|
| 230 |
+
|
| 231 |
+
const loadModel = useCallback(async () => {
|
| 232 |
+
if (!selectedModel) return;
|
| 233 |
+
setModelStatus((prev) => ({ ...prev, status: "loading", message: "Starting..." }));
|
| 234 |
+
|
| 235 |
+
try {
|
| 236 |
+
const params = new URLSearchParams({ modelId: selectedModel.id });
|
| 237 |
+
if (selectedModel.dtype) params.set("dtype", selectedModel.dtype);
|
| 238 |
+
const response = await fetch(`/api/model/load?${params.toString()}`);
|
| 239 |
+
const reader = response.body?.getReader();
|
| 240 |
+
if (!reader) throw new Error("No response body");
|
| 241 |
+
|
| 242 |
+
const decoder = new TextDecoder();
|
| 243 |
+
let buffer = "";
|
| 244 |
+
|
| 245 |
+
while (true) {
|
| 246 |
+
const { done, value } = await reader.read();
|
| 247 |
+
if (done) break;
|
| 248 |
+
|
| 249 |
+
buffer += decoder.decode(value, { stream: true });
|
| 250 |
+
const chunks = buffer.split("\n\n");
|
| 251 |
+
buffer = chunks.pop() || "";
|
| 252 |
+
|
| 253 |
+
for (const chunk of chunks) {
|
| 254 |
+
if (chunk.startsWith("data: ")) {
|
| 255 |
+
try {
|
| 256 |
+
const data = JSON.parse(chunk.slice(6)) as ModelProgress;
|
| 257 |
+
setModelStatus((prev) => ({ ...prev, ...data }));
|
| 258 |
+
} catch {
|
| 259 |
+
// ignore malformed progress chunks
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
} catch (err) {
|
| 265 |
+
setModelStatus((prev) => ({
|
| 266 |
+
...prev,
|
| 267 |
+
status: "error",
|
| 268 |
+
message: err instanceof Error ? err.message : "Failed to load model",
|
| 269 |
+
}));
|
| 270 |
+
}
|
| 271 |
+
}, [selectedModel]);
|
| 272 |
+
|
| 273 |
+
const registerKnowledgeFiles = useCallback((files: FileList | null) => {
|
| 274 |
+
if (!files || files.length === 0) return;
|
| 275 |
+
const pdfs = Array.from(files).filter((f) => f.type === "application/pdf");
|
| 276 |
+
import("@/lib/knowledge").then(({ addPdfFile }) => {
|
| 277 |
+
for (const file of pdfs) {
|
| 278 |
+
const tempId = `temp-${file.name}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
| 279 |
+
setKnowledgeItems((prev) => [
|
| 280 |
+
...prev,
|
| 281 |
+
{ id: tempId, type: "file" as KnowledgeItemType, label: file.name, meta: "Indexing…", status: "indexing" as const },
|
| 282 |
+
]);
|
| 283 |
+
addPdfFile(file)
|
| 284 |
+
.then((meta: KnowledgeItem) => {
|
| 285 |
+
setKnowledgeItems((prev) => prev.map((x) => (x.id === tempId ? { ...meta, status: "indexed" as const } : x)));
|
| 286 |
+
})
|
| 287 |
+
.catch(() => {
|
| 288 |
+
setKnowledgeItems((prev) => prev.map((x) => (x.id === tempId ? { ...x, status: "error" as const, meta: "Failed" } : x)));
|
| 289 |
+
});
|
| 290 |
+
}
|
| 291 |
+
});
|
| 292 |
+
}, []);
|
| 293 |
+
|
| 294 |
+
const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
|
| 295 |
+
event.preventDefault();
|
| 296 |
+
event.stopPropagation();
|
| 297 |
+
setIsDragOver(false);
|
| 298 |
+
|
| 299 |
+
if (event.dataTransfer.files?.length) registerKnowledgeFiles(event.dataTransfer.files);
|
| 300 |
+
|
| 301 |
+
const urlPayload =
|
| 302 |
+
event.dataTransfer.getData("text/uri-list") ||
|
| 303 |
+
event.dataTransfer.getData("text/plain");
|
| 304 |
+
|
| 305 |
+
if (urlPayload && /^https?:\/\//i.test(urlPayload.trim())) {
|
| 306 |
+
const url = urlPayload.trim();
|
| 307 |
+
import("@/lib/knowledge")
|
| 308 |
+
.then(({ addUrl }) => addUrl(url))
|
| 309 |
+
.then((meta) => setKnowledgeItems((prev) => [...prev, meta]))
|
| 310 |
+
.catch(() => {});
|
| 311 |
+
}
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
const handleDragOver: React.DragEventHandler<HTMLDivElement> = (event) => {
|
| 315 |
+
event.preventDefault();
|
| 316 |
+
event.stopPropagation();
|
| 317 |
+
setIsDragOver(true);
|
| 318 |
+
};
|
| 319 |
+
|
| 320 |
+
const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
|
| 321 |
+
event.preventDefault();
|
| 322 |
+
event.stopPropagation();
|
| 323 |
+
setIsDragOver(false);
|
| 324 |
+
};
|
| 325 |
+
|
| 326 |
+
const handleAddNote = () => {
|
| 327 |
+
const value = noteDraft.trim();
|
| 328 |
+
if (!value) return;
|
| 329 |
+
import("@/lib/knowledge")
|
| 330 |
+
.then(({ addNote }) => addNote(value))
|
| 331 |
+
.then((meta) => {
|
| 332 |
+
setKnowledgeItems((prev) => [...prev, meta]);
|
| 333 |
+
setNoteDraft("");
|
| 334 |
+
})
|
| 335 |
+
.catch(() => {});
|
| 336 |
+
};
|
| 337 |
+
|
| 338 |
+
const handleAddUrl = () => {
|
| 339 |
+
const value = urlDraft.trim();
|
| 340 |
+
if (!value) return;
|
| 341 |
+
import("@/lib/knowledge")
|
| 342 |
+
.then(({ addUrl }) => addUrl(value))
|
| 343 |
+
.then((meta) => {
|
| 344 |
+
setKnowledgeItems((prev) => [...prev, meta]);
|
| 345 |
+
setUrlDraft("");
|
| 346 |
+
})
|
| 347 |
+
.catch(() => {});
|
| 348 |
+
};
|
| 349 |
+
|
| 350 |
+
const handleRemoveKnowledgeItem = (id: string) => {
|
| 351 |
+
import("@/lib/knowledge")
|
| 352 |
+
.then(({ removeKnowledgeDocument }) => removeKnowledgeDocument(id))
|
| 353 |
+
.then(() => setKnowledgeItems((prev) => prev.filter((x) => x.id !== id)))
|
| 354 |
+
.catch(() => {});
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
const resetStreaming = () => {
|
| 358 |
+
if (streamAbortRef.current) {
|
| 359 |
+
streamAbortRef.current.abort();
|
| 360 |
+
streamAbortRef.current = null;
|
| 361 |
+
}
|
| 362 |
+
if (streamingTimerRef.current) {
|
| 363 |
+
window.clearInterval(streamingTimerRef.current);
|
| 364 |
+
streamingTimerRef.current = null;
|
| 365 |
+
}
|
| 366 |
+
pendingFullTextRef.current = null;
|
| 367 |
+
streamingTargetRef.current = null;
|
| 368 |
+
setIsStreaming(false);
|
| 369 |
+
};
|
| 370 |
+
|
| 371 |
+
const runResearch = useCallback(
|
| 372 |
+
async (promptOverride?: string) => {
|
| 373 |
+
const nextQuery = (promptOverride ?? query).trim();
|
| 374 |
+
if (!nextQuery || !isModelReady || isStreaming) return;
|
| 375 |
+
|
| 376 |
+
if (streamAbortRef.current) {
|
| 377 |
+
streamAbortRef.current.abort();
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const controller = new AbortController();
|
| 381 |
+
streamAbortRef.current = controller;
|
| 382 |
+
|
| 383 |
+
setError(null);
|
| 384 |
+
setEvents([]);
|
| 385 |
+
setIsStreaming(true);
|
| 386 |
+
|
| 387 |
+
const userMessage: ChatMessage = {
|
| 388 |
+
id: `user-${Date.now()}`,
|
| 389 |
+
role: "user",
|
| 390 |
+
content: nextQuery,
|
| 391 |
+
};
|
| 392 |
+
const assistantMessage: ChatMessage = {
|
| 393 |
+
id: `assistant-${Date.now()}`,
|
| 394 |
+
role: "assistant",
|
| 395 |
+
content: "",
|
| 396 |
+
isStreaming: true,
|
| 397 |
+
};
|
| 398 |
+
|
| 399 |
+
setChat((prev) => [...prev, userMessage, assistantMessage]);
|
| 400 |
+
|
| 401 |
+
try {
|
| 402 |
+
let retrievedContext = "";
|
| 403 |
+
let contextUrls: string[] = [];
|
| 404 |
+
if (knowledgeItems.length > 0) {
|
| 405 |
+
const { retrieve } = await import("@/lib/knowledge");
|
| 406 |
+
const result = await retrieve(nextQuery);
|
| 407 |
+
retrievedContext = result.retrievedContext;
|
| 408 |
+
contextUrls = result.contextUrls;
|
| 409 |
+
}
|
| 410 |
+
const response = await fetch("/api/research", {
|
| 411 |
+
method: "POST",
|
| 412 |
+
headers: { "Content-Type": "application/json" },
|
| 413 |
+
body: JSON.stringify({
|
| 414 |
+
query: nextQuery,
|
| 415 |
+
retrievedContext,
|
| 416 |
+
contextUrls,
|
| 417 |
+
}),
|
| 418 |
+
signal: controller.signal,
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
if (!response.ok) {
|
| 422 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
const reader = response.body?.getReader();
|
| 426 |
+
if (!reader) {
|
| 427 |
+
throw new Error("No response body");
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
const decoder = new TextDecoder();
|
| 431 |
+
let buffer = "";
|
| 432 |
+
let hadError = false;
|
| 433 |
+
const assistantId = assistantMessage.id;
|
| 434 |
+
|
| 435 |
+
while (true) {
|
| 436 |
+
const { done, value } = await reader.read();
|
| 437 |
+
if (done) break;
|
| 438 |
+
|
| 439 |
+
buffer += decoder.decode(value, { stream: true });
|
| 440 |
+
const eventsRaw = buffer.split("\n\n");
|
| 441 |
+
buffer = eventsRaw.pop() || "";
|
| 442 |
+
|
| 443 |
+
for (const raw of eventsRaw) {
|
| 444 |
+
const lines = raw.split("\n");
|
| 445 |
+
let eventType = "message";
|
| 446 |
+
let dataLine = "";
|
| 447 |
+
for (const line of lines) {
|
| 448 |
+
if (line.startsWith("event: ")) {
|
| 449 |
+
eventType = line.slice(7);
|
| 450 |
+
}
|
| 451 |
+
if (line.startsWith("data: ")) {
|
| 452 |
+
dataLine = line.slice(6);
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
if (!dataLine) continue;
|
| 457 |
+
|
| 458 |
+
try {
|
| 459 |
+
const parsed = JSON.parse(dataLine) as ResearchEvent;
|
| 460 |
+
if (eventType === "hitl_waiting" && parsed.node === "__interrupt__") {
|
| 461 |
+
setHitlThreadId(parsed.state.threadId ?? null);
|
| 462 |
+
setHitlPayload(parsed.state.interrupt ?? null);
|
| 463 |
+
setIsStreaming(false);
|
| 464 |
+
continue;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
setEvents((prev) => [...prev, parsed]);
|
| 468 |
+
|
| 469 |
+
if (parsed.node === "_error" || parsed.state.status === "failed") {
|
| 470 |
+
const message =
|
| 471 |
+
parsed.state.errorMessage ?? "Research failed. See server logs for details.";
|
| 472 |
+
setError(message);
|
| 473 |
+
hadError = true;
|
| 474 |
+
resetStreaming();
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
if (parsed.state.finalReport && !hadError) {
|
| 478 |
+
pendingFullTextRef.current = parsed.state.finalReport;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
if (pendingFullTextRef.current && !streamingTimerRef.current) {
|
| 482 |
+
startStreamingAnimation(pendingFullTextRef.current, assistantId);
|
| 483 |
+
}
|
| 484 |
+
} catch (e) {
|
| 485 |
+
console.warn("Failed to parse SSE event:", e);
|
| 486 |
+
}
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
if (!hadError) {
|
| 491 |
+
setIsStreaming(false);
|
| 492 |
+
}
|
| 493 |
+
} catch (err) {
|
| 494 |
+
if ((err as Error).name === "AbortError") {
|
| 495 |
+
return;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 499 |
+
resetStreaming();
|
| 500 |
+
}
|
| 501 |
+
},
|
| 502 |
+
[
|
| 503 |
+
knowledgeItems,
|
| 504 |
+
isModelReady,
|
| 505 |
+
isStreaming,
|
| 506 |
+
query,
|
| 507 |
+
startStreamingAnimation,
|
| 508 |
+
resetStreaming,
|
| 509 |
+
]
|
| 510 |
+
);
|
| 511 |
+
|
| 512 |
+
const handleQuickAction = (template: string) => {
|
| 513 |
+
const composed = query ? `${query}\n\n${template}` : template;
|
| 514 |
+
setQuery(composed);
|
| 515 |
+
void runResearch(composed);
|
| 516 |
+
};
|
| 517 |
+
|
| 518 |
+
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
| 519 |
+
event.preventDefault();
|
| 520 |
+
void runResearch();
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
const isInputDisabled = !isModelReady || isStreaming;
|
| 524 |
+
|
| 525 |
+
const handleApprovePlan = useCallback(async () => {
|
| 526 |
+
if (!hitlThreadId) return;
|
| 527 |
+
|
| 528 |
+
setIsStreaming(true);
|
| 529 |
+
setError(null);
|
| 530 |
+
|
| 531 |
+
const assistantMessage: ChatMessage = {
|
| 532 |
+
id: `assistant-${Date.now()}`,
|
| 533 |
+
role: "assistant",
|
| 534 |
+
content: "",
|
| 535 |
+
isStreaming: true,
|
| 536 |
+
};
|
| 537 |
+
setChat((prev) => [...prev, assistantMessage]);
|
| 538 |
+
|
| 539 |
+
try {
|
| 540 |
+
const response = await fetch("/api/research/approve", {
|
| 541 |
+
method: "POST",
|
| 542 |
+
headers: { "Content-Type": "application/json" },
|
| 543 |
+
body: JSON.stringify({ threadId: hitlThreadId }),
|
| 544 |
+
});
|
| 545 |
+
|
| 546 |
+
if (!response.ok) {
|
| 547 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
const reader = response.body?.getReader();
|
| 551 |
+
if (!reader) {
|
| 552 |
+
throw new Error("No response body");
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
const decoder = new TextDecoder();
|
| 556 |
+
let buffer = "";
|
| 557 |
+
let hadError = false;
|
| 558 |
+
const assistantId = assistantMessage.id;
|
| 559 |
+
|
| 560 |
+
while (true) {
|
| 561 |
+
const { done, value } = await reader.read();
|
| 562 |
+
if (done) break;
|
| 563 |
+
|
| 564 |
+
buffer += decoder.decode(value, { stream: true });
|
| 565 |
+
const eventsRaw = buffer.split("\n\n");
|
| 566 |
+
buffer = eventsRaw.pop() || "";
|
| 567 |
+
|
| 568 |
+
for (const raw of eventsRaw) {
|
| 569 |
+
const lines = raw.split("\n");
|
| 570 |
+
let eventType = "message";
|
| 571 |
+
let dataLine = "";
|
| 572 |
+
for (const line of lines) {
|
| 573 |
+
if (line.startsWith("event: ")) {
|
| 574 |
+
eventType = line.slice(7);
|
| 575 |
+
}
|
| 576 |
+
if (line.startsWith("data: ")) {
|
| 577 |
+
dataLine = line.slice(6);
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
if (!dataLine) continue;
|
| 582 |
+
|
| 583 |
+
try {
|
| 584 |
+
const parsed = JSON.parse(dataLine) as ResearchEvent;
|
| 585 |
+
if (eventType === "error" || parsed.node === "_error" || parsed.state.status === "failed") {
|
| 586 |
+
const message =
|
| 587 |
+
parsed.state.errorMessage ?? "Research failed after approval. See server logs for details.";
|
| 588 |
+
setError(message);
|
| 589 |
+
hadError = true;
|
| 590 |
+
resetStreaming();
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
setEvents((prev) => [...prev, parsed]);
|
| 594 |
+
|
| 595 |
+
if (parsed.state.finalReport && !hadError) {
|
| 596 |
+
pendingFullTextRef.current = parsed.state.finalReport;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
if (pendingFullTextRef.current && !streamingTimerRef.current) {
|
| 600 |
+
startStreamingAnimation(pendingFullTextRef.current, assistantId);
|
| 601 |
+
}
|
| 602 |
+
} catch (e) {
|
| 603 |
+
console.warn("Failed to parse SSE event (approve):", e);
|
| 604 |
+
}
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
if (!hadError) {
|
| 609 |
+
setIsStreaming(false);
|
| 610 |
+
}
|
| 611 |
+
} catch (err) {
|
| 612 |
+
setError(err instanceof Error ? err.message : "Unknown error during approval");
|
| 613 |
+
resetStreaming();
|
| 614 |
+
} finally {
|
| 615 |
+
setHitlThreadId(null);
|
| 616 |
+
setHitlPayload(null);
|
| 617 |
+
}
|
| 618 |
+
}, [
|
| 619 |
+
hitlThreadId,
|
| 620 |
+
resetStreaming,
|
| 621 |
+
startStreamingAnimation,
|
| 622 |
+
]);
|
| 623 |
+
|
| 624 |
+
const handleRejectPlan = useCallback(() => {
|
| 625 |
+
setHitlThreadId(null);
|
| 626 |
+
setHitlPayload(null);
|
| 627 |
+
setIsStreaming(false);
|
| 628 |
+
setChat((prev) => [
|
| 629 |
+
...prev,
|
| 630 |
+
{
|
| 631 |
+
id: `assistant-${Date.now()}`,
|
| 632 |
+
role: "assistant",
|
| 633 |
+
content:
|
| 634 |
+
"The proposed plan was rejected. Please refine your question or constraints, then try again.",
|
| 635 |
+
},
|
| 636 |
+
]);
|
| 637 |
+
}, []);
|
| 638 |
+
|
| 639 |
+
return (
|
| 640 |
+
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
|
| 641 |
+
<main className="flex-1 flex flex-col lg:flex-row gap-6 px-4 sm:px-8 py-6 max-w-6xl mx-auto w-full">
|
| 642 |
+
<section className="flex-1 flex flex-col border border-zinc-900/80 bg-zinc-950/60 rounded-3xl shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
|
| 643 |
+
<header className="px-6 pt-5 pb-4 border-b border-zinc-900/80 flex items-center justify-between">
|
| 644 |
+
<div>
|
| 645 |
+
<p className="text-sm font-medium text-zinc-400">Hi there</p>
|
| 646 |
+
<h2 className="text-2xl sm:text-3xl font-semibold tracking-tight">
|
| 647 |
+
Where should we start?
|
| 648 |
+
</h2>
|
| 649 |
+
</div>
|
| 650 |
+
<div
|
| 651 |
+
className={`px-3 py-1 rounded-full text-xs font-medium border ${
|
| 652 |
+
isModelReady
|
| 653 |
+
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
| 654 |
+
: isModelLoading
|
| 655 |
+
? "border-sky-500/40 bg-sky-500/10 text-sky-300"
|
| 656 |
+
: modelStatus.status === "error"
|
| 657 |
+
? "border-red-500/40 bg-red-500/10 text-red-300"
|
| 658 |
+
: "border-zinc-700 bg-zinc-900 text-zinc-400"
|
| 659 |
+
}`}
|
| 660 |
+
>
|
| 661 |
+
{isModelReady && "Model ready"}
|
| 662 |
+
{isModelLoading && "Loading model…"}
|
| 663 |
+
{modelStatus.status === "error" && "Model error"}
|
| 664 |
+
{modelStatus.status === "idle" && "Model not loaded"}
|
| 665 |
+
</div>
|
| 666 |
+
</header>
|
| 667 |
+
|
| 668 |
+
<div className="flex-1 flex flex-col">
|
| 669 |
+
<div
|
| 670 |
+
ref={chatScrollRef}
|
| 671 |
+
className="flex-1 px-6 py-4 space-y-4 overflow-y-auto scrollbar-thin scrollbar-thumb-zinc-800 scrollbar-track-transparent"
|
| 672 |
+
>
|
| 673 |
+
{chat.length === 0 && (
|
| 674 |
+
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
| 675 |
+
{PREVIEW_QUERIES.map((example) => (
|
| 676 |
+
<button
|
| 677 |
+
key={example}
|
| 678 |
+
type="button"
|
| 679 |
+
disabled={!isModelReady}
|
| 680 |
+
onClick={() => void runResearch(example)}
|
| 681 |
+
className="group text-left rounded-2xl border border-zinc-800 bg-zinc-900/60 px-4 py-3 text-sm text-zinc-300 hover:border-zinc-500/80 hover:bg-zinc-900 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 682 |
+
>
|
| 683 |
+
<span className="block font-medium text-zinc-100 mb-1">
|
| 684 |
+
Try this
|
| 685 |
+
</span>
|
| 686 |
+
<span className="block text-zinc-400 text-xs line-clamp-3">
|
| 687 |
+
{example}
|
| 688 |
+
</span>
|
| 689 |
+
</button>
|
| 690 |
+
))}
|
| 691 |
+
</div>
|
| 692 |
+
)}
|
| 693 |
+
|
| 694 |
+
{chat.map((message) => (
|
| 695 |
+
<div
|
| 696 |
+
key={message.id}
|
| 697 |
+
className={`flex ${
|
| 698 |
+
message.role === "user" ? "justify-end" : "justify-start"
|
| 699 |
+
}`}
|
| 700 |
+
>
|
| 701 |
+
<div
|
| 702 |
+
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-sm ${
|
| 703 |
+
message.role === "user"
|
| 704 |
+
? "bg-zinc-100 text-zinc-900"
|
| 705 |
+
: "bg-zinc-900 text-zinc-100 border border-zinc-800"
|
| 706 |
+
}`}
|
| 707 |
+
>
|
| 708 |
+
{message.content || (message.isStreaming && (
|
| 709 |
+
<div className="space-y-2">
|
| 710 |
+
<div className="h-3 rounded-full bg-zinc-700/60 animate-pulse" />
|
| 711 |
+
<div className="h-3 w-2/3 rounded-full bg-zinc-800/60 animate-pulse" />
|
| 712 |
+
</div>
|
| 713 |
+
))}
|
| 714 |
+
</div>
|
| 715 |
+
</div>
|
| 716 |
+
))}
|
| 717 |
+
</div>
|
| 718 |
+
|
| 719 |
+
<div className="px-4 pb-4 pt-2 border-t border-zinc-900/80">
|
| 720 |
+
<form
|
| 721 |
+
onSubmit={handleSubmit}
|
| 722 |
+
className="flex flex-col gap-3 rounded-2xl bg-zinc-950/80 border border-zinc-800 px-3 pt-2.5 pb-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]"
|
| 723 |
+
>
|
| 724 |
+
<textarea
|
| 725 |
+
rows={2}
|
| 726 |
+
value={query}
|
| 727 |
+
onChange={(event) => setQuery(event.target.value)}
|
| 728 |
+
disabled={isInputDisabled}
|
| 729 |
+
placeholder="Ask DeepTrust anything about your code, docs, or ideas…"
|
| 730 |
+
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500 text-zinc-100"
|
| 731 |
+
/>
|
| 732 |
+
<div className="flex items-center justify-between gap-2">
|
| 733 |
+
<div className="flex flex-wrap gap-1.5">
|
| 734 |
+
{QUICK_ACTIONS.map((action) => (
|
| 735 |
+
<button
|
| 736 |
+
key={action}
|
| 737 |
+
type="button"
|
| 738 |
+
disabled={isInputDisabled}
|
| 739 |
+
onClick={() => handleQuickAction(action)}
|
| 740 |
+
className="rounded-full border border-zinc-800 bg-zinc-900/70 px-3 py-1 text-[11px] text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 741 |
+
>
|
| 742 |
+
{action}
|
| 743 |
+
</button>
|
| 744 |
+
))}
|
| 745 |
+
</div>
|
| 746 |
+
<div className="flex items-center gap-2">
|
| 747 |
+
{isStreaming && (
|
| 748 |
+
<button
|
| 749 |
+
type="button"
|
| 750 |
+
onClick={resetStreaming}
|
| 751 |
+
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded-full border border-zinc-800"
|
| 752 |
+
>
|
| 753 |
+
Stop
|
| 754 |
+
</button>
|
| 755 |
+
)}
|
| 756 |
+
<button
|
| 757 |
+
type="submit"
|
| 758 |
+
disabled={isInputDisabled}
|
| 759 |
+
className="inline-flex items-center justify-center rounded-full bg-zinc-100 text-zinc-950 text-xs font-medium px-4 py-2 hover:bg-white transition-colors disabled:bg-zinc-700 disabled:text-zinc-300 disabled:cursor-not-allowed"
|
| 760 |
+
>
|
| 761 |
+
{isStreaming ? "Thinking…" : "Run"}
|
| 762 |
+
</button>
|
| 763 |
+
</div>
|
| 764 |
+
</div>
|
| 765 |
+
</form>
|
| 766 |
+
{error && (
|
| 767 |
+
<p className="mt-2 text-xs text-red-400 border border-red-900/70 bg-red-950/60 rounded-xl px-3 py-2">
|
| 768 |
+
{error}
|
| 769 |
+
</p>
|
| 770 |
+
)}
|
| 771 |
+
</div>
|
| 772 |
+
</div>
|
| 773 |
+
</section>
|
| 774 |
+
|
| 775 |
+
<aside className="w-full lg:w-[320px] flex flex-col gap-4">
|
| 776 |
+
<div className="rounded-3xl border border-zinc-900 bg-zinc-950/70 p-4 space-y-3 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
|
| 777 |
+
<div className="flex items-center justify-between">
|
| 778 |
+
<div>
|
| 779 |
+
<p className="text-xs uppercase tracking-wide text-zinc-500">
|
| 780 |
+
Model
|
| 781 |
+
</p>
|
| 782 |
+
<p className="text-sm font-medium">
|
| 783 |
+
{modelStatus.modelId || "Select a model"}
|
| 784 |
+
</p>
|
| 785 |
+
</div>
|
| 786 |
+
<span
|
| 787 |
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium border ${
|
| 788 |
+
isModelReady
|
| 789 |
+
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
| 790 |
+
: isModelLoading
|
| 791 |
+
? "border-sky-500/40 bg-sky-500/10 text-sky-300"
|
| 792 |
+
: modelStatus.status === "error"
|
| 793 |
+
? "border-red-500/40 bg-red-500/10 text-red-300"
|
| 794 |
+
: "border-zinc-700 bg-zinc-900 text-zinc-400"
|
| 795 |
+
}`}
|
| 796 |
+
>
|
| 797 |
+
{isModelReady && "Ready"}
|
| 798 |
+
{isModelLoading && "Loading…"}
|
| 799 |
+
{modelStatus.status === "error" && "Error"}
|
| 800 |
+
{modelStatus.status === "idle" && "Not loaded"}
|
| 801 |
+
</span>
|
| 802 |
+
</div>
|
| 803 |
+
|
| 804 |
+
{models.length > 0 && (
|
| 805 |
+
<div>
|
| 806 |
+
<select
|
| 807 |
+
value={selectedModelIndex}
|
| 808 |
+
onChange={(event) => setSelectedModelIndex(Number(event.target.value))}
|
| 809 |
+
disabled={isModelLoading}
|
| 810 |
+
className="w-full mt-1 rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 811 |
+
>
|
| 812 |
+
{models.map((model, index) => (
|
| 813 |
+
<option
|
| 814 |
+
key={`${model.id}-${model.dtype ?? "default"}`}
|
| 815 |
+
value={index}
|
| 816 |
+
>
|
| 817 |
+
{model.label} {model.sizeNote ? `— ${model.sizeNote}` : ""}
|
| 818 |
+
</option>
|
| 819 |
+
))}
|
| 820 |
+
</select>
|
| 821 |
+
</div>
|
| 822 |
+
)}
|
| 823 |
+
|
| 824 |
+
{isModelLoading && (
|
| 825 |
+
<div className="space-y-1.5">
|
| 826 |
+
<div className="flex justify-between text-[11px] text-zinc-400">
|
| 827 |
+
<span>{modelStatus.message}</span>
|
| 828 |
+
<span>{modelStatus.progress}%</span>
|
| 829 |
+
</div>
|
| 830 |
+
<div className="h-1.5 rounded-full bg-zinc-900 overflow-hidden">
|
| 831 |
+
<div
|
| 832 |
+
className="h-full bg-sky-500 transition-all duration-300"
|
| 833 |
+
style={{ width: `${modelStatus.progress}%` }}
|
| 834 |
+
/>
|
| 835 |
+
</div>
|
| 836 |
+
</div>
|
| 837 |
+
)}
|
| 838 |
+
|
| 839 |
+
{!isModelReady && !isModelLoading && (
|
| 840 |
+
<button
|
| 841 |
+
type="button"
|
| 842 |
+
onClick={loadModel}
|
| 843 |
+
className="w-full inline-flex items-center justify-center text-xs font-medium rounded-xl bg-zinc-100 text-zinc-950 py-2.5 hover:bg-white transition-colors"
|
| 844 |
+
>
|
| 845 |
+
Load model
|
| 846 |
+
</button>
|
| 847 |
+
)}
|
| 848 |
+
|
| 849 |
+
{modelStatus.status === "error" && (
|
| 850 |
+
<button
|
| 851 |
+
type="button"
|
| 852 |
+
onClick={loadModel}
|
| 853 |
+
className="w-full text-[11px] text-red-300 underline underline-offset-2 hover:no-underline text-left"
|
| 854 |
+
>
|
| 855 |
+
Retry loading
|
| 856 |
+
</button>
|
| 857 |
+
)}
|
| 858 |
+
</div>
|
| 859 |
+
|
| 860 |
+
<div
|
| 861 |
+
onDrop={handleDrop}
|
| 862 |
+
onDragOver={handleDragOver}
|
| 863 |
+
onDragLeave={handleDragLeave}
|
| 864 |
+
className={`rounded-3xl border-2 border-dashed px-4 py-4 space-y-3 transition-colors cursor-pointer ${
|
| 865 |
+
isDragOver
|
| 866 |
+
? "border-zinc-200 bg-zinc-900/80"
|
| 867 |
+
: "border-zinc-700/80 bg-zinc-950/60"
|
| 868 |
+
}`}
|
| 869 |
+
>
|
| 870 |
+
<div className="flex items-center justify-between gap-2">
|
| 871 |
+
<div>
|
| 872 |
+
<p className="text-xs uppercase tracking-wide text-zinc-500">
|
| 873 |
+
Context
|
| 874 |
+
</p>
|
| 875 |
+
<p className="text-sm font-medium">
|
| 876 |
+
Drop PDFs, notes, or URLs
|
| 877 |
+
</p>
|
| 878 |
+
</div>
|
| 879 |
+
</div>
|
| 880 |
+
<p className="text-xs text-zinc-500">
|
| 881 |
+
Drag files or links here. DeepTrust will use them as additional
|
| 882 |
+
knowledge when answering.
|
| 883 |
+
</p>
|
| 884 |
+
|
| 885 |
+
<div className="flex gap-2">
|
| 886 |
+
<label className="inline-flex cursor-pointer rounded-xl border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-[11px] text-zinc-200 hover:bg-zinc-800 transition-colors">
|
| 887 |
+
Attach files
|
| 888 |
+
<input
|
| 889 |
+
type="file"
|
| 890 |
+
accept="application/pdf"
|
| 891 |
+
multiple
|
| 892 |
+
className="hidden"
|
| 893 |
+
onChange={(event) => registerKnowledgeFiles(event.target.files)}
|
| 894 |
+
/>
|
| 895 |
+
</label>
|
| 896 |
+
</div>
|
| 897 |
+
|
| 898 |
+
<div className="space-y-2 pt-1">
|
| 899 |
+
<div className="flex gap-2">
|
| 900 |
+
<input
|
| 901 |
+
type="text"
|
| 902 |
+
value={urlDraft}
|
| 903 |
+
onChange={(event) => setUrlDraft(event.target.value)}
|
| 904 |
+
placeholder="Paste URL"
|
| 905 |
+
className="flex-1 rounded-xl bg-zinc-900/70 border border-zinc-800 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
| 906 |
+
/>
|
| 907 |
+
<button
|
| 908 |
+
type="button"
|
| 909 |
+
onClick={handleAddUrl}
|
| 910 |
+
className="rounded-xl border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-[11px] text-zinc-200 hover:bg-zinc-800"
|
| 911 |
+
>
|
| 912 |
+
Add
|
| 913 |
+
</button>
|
| 914 |
+
</div>
|
| 915 |
+
<div className="flex gap-2">
|
| 916 |
+
<input
|
| 917 |
+
type="text"
|
| 918 |
+
value={noteDraft}
|
| 919 |
+
onChange={(event) => setNoteDraft(event.target.value)}
|
| 920 |
+
placeholder="Short note or hint"
|
| 921 |
+
className="flex-1 rounded-xl bg-zinc-900/70 border border-zinc-800 px-3 py-1.5 text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
| 922 |
+
/>
|
| 923 |
+
<button
|
| 924 |
+
type="button"
|
| 925 |
+
onClick={handleAddNote}
|
| 926 |
+
className="rounded-xl border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-[11px] text-zinc-200 hover:bg-zinc-800"
|
| 927 |
+
>
|
| 928 |
+
Save
|
| 929 |
+
</button>
|
| 930 |
+
</div>
|
| 931 |
+
</div>
|
| 932 |
+
|
| 933 |
+
{knowledgeItems.length > 0 && (
|
| 934 |
+
<div className="max-h-40 overflow-y-auto space-y-1 pt-1">
|
| 935 |
+
{knowledgeItems.map((item) => (
|
| 936 |
+
<div
|
| 937 |
+
key={item.id}
|
| 938 |
+
className="flex items-center justify-between gap-2 rounded-xl bg-zinc-900/70 border border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-200"
|
| 939 |
+
>
|
| 940 |
+
<span className="truncate min-w-0">
|
| 941 |
+
{item.type === "file" && "📄 "}
|
| 942 |
+
{item.type === "url" && "🔗 "}
|
| 943 |
+
{item.type === "note" && "✏️ "}
|
| 944 |
+
{item.label}
|
| 945 |
+
</span>
|
| 946 |
+
<span className="flex items-center gap-1.5 shrink-0">
|
| 947 |
+
{item.meta && (
|
| 948 |
+
<span className="text-[10px] text-zinc-500">
|
| 949 |
+
{item.meta}
|
| 950 |
+
</span>
|
| 951 |
+
)}
|
| 952 |
+
<button
|
| 953 |
+
type="button"
|
| 954 |
+
onClick={() => handleRemoveKnowledgeItem(item.id)}
|
| 955 |
+
className="rounded p-0.5 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
| 956 |
+
aria-label="Remove"
|
| 957 |
+
>
|
| 958 |
+
×
|
| 959 |
+
</button>
|
| 960 |
+
</span>
|
| 961 |
+
</div>
|
| 962 |
+
))}
|
| 963 |
+
</div>
|
| 964 |
+
)}
|
| 965 |
+
</div>
|
| 966 |
+
|
| 967 |
+
{events.length > 0 && (
|
| 968 |
+
<div className="space-y-3">
|
| 969 |
+
{/* HITL approval banner */}
|
| 970 |
+
{hitlThreadId && (
|
| 971 |
+
<div className="rounded-3xl border border-amber-500/40 bg-amber-500/5 p-3 space-y-1.5">
|
| 972 |
+
<p className="text-xs font-medium text-amber-200">
|
| 973 |
+
Human review required
|
| 974 |
+
</p>
|
| 975 |
+
<p className="text-[11px] text-amber-100/90">
|
| 976 |
+
The agent has prepared a plan and is waiting for your approval before executing tools.
|
| 977 |
+
</p>
|
| 978 |
+
<div className="flex items-center gap-2 pt-1">
|
| 979 |
+
<button
|
| 980 |
+
type="button"
|
| 981 |
+
onClick={handleApprovePlan}
|
| 982 |
+
className="px-3 py-1.5 rounded-full text-[11px] font-medium bg-emerald-500 text-emerald-950 hover:bg-emerald-400 transition-colors"
|
| 983 |
+
>
|
| 984 |
+
Approve & run tools
|
| 985 |
+
</button>
|
| 986 |
+
<button
|
| 987 |
+
type="button"
|
| 988 |
+
onClick={handleRejectPlan}
|
| 989 |
+
className="px-3 py-1.5 rounded-full text-[11px] font-medium border border-amber-400/60 text-amber-100 hover:bg-amber-500/10 transition-colors"
|
| 990 |
+
>
|
| 991 |
+
Reject plan
|
| 992 |
+
</button>
|
| 993 |
+
</div>
|
| 994 |
+
</div>
|
| 995 |
+
)}
|
| 996 |
+
|
| 997 |
+
{/* Plan & audit snapshot */}
|
| 998 |
+
{(() => {
|
| 999 |
+
const latestWithPlan = [...events]
|
| 1000 |
+
.reverse()
|
| 1001 |
+
.find((e) => e.state.plan);
|
| 1002 |
+
const latestWithAudit = [...events]
|
| 1003 |
+
.reverse()
|
| 1004 |
+
.find((e) => e.state.auditResult);
|
| 1005 |
+
if (!latestWithPlan && !latestWithAudit) return null;
|
| 1006 |
+
|
| 1007 |
+
const plan = latestWithPlan?.state.plan;
|
| 1008 |
+
const audit = latestWithAudit?.state.auditResult;
|
| 1009 |
+
|
| 1010 |
+
return (
|
| 1011 |
+
<div className="rounded-3xl border border-zinc-900 bg-zinc-950/70 p-3 space-y-2">
|
| 1012 |
+
<div className="flex items-center justify-between gap-2">
|
| 1013 |
+
<p className="text-xs font-medium text-zinc-400">
|
| 1014 |
+
Plan & audit
|
| 1015 |
+
</p>
|
| 1016 |
+
{audit && (
|
| 1017 |
+
<span
|
| 1018 |
+
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
| 1019 |
+
audit.verdict === "approved"
|
| 1020 |
+
? "bg-emerald-500/10 text-emerald-300 border border-emerald-500/30"
|
| 1021 |
+
: audit.verdict === "rejected"
|
| 1022 |
+
? "bg-red-500/10 text-red-300 border border-red-500/30"
|
| 1023 |
+
: "bg-amber-500/10 text-amber-300 border border-amber-500/30"
|
| 1024 |
+
}`}
|
| 1025 |
+
>
|
| 1026 |
+
Audit: {audit.verdict}
|
| 1027 |
+
</span>
|
| 1028 |
+
)}
|
| 1029 |
+
</div>
|
| 1030 |
+
{plan && (
|
| 1031 |
+
<div className="space-y-1.5">
|
| 1032 |
+
<p className="text-[11px] text-zinc-300">
|
| 1033 |
+
{plan.objective}
|
| 1034 |
+
</p>
|
| 1035 |
+
<ol className="space-y-1 max-h-24 overflow-y-auto">
|
| 1036 |
+
{plan.steps.map((step, idx) => (
|
| 1037 |
+
<li
|
| 1038 |
+
key={`${step.tool}-${idx}`}
|
| 1039 |
+
className="text-[11px] text-zinc-400 flex gap-1.5"
|
| 1040 |
+
>
|
| 1041 |
+
<span className="mt-0.5 text-zinc-500">
|
| 1042 |
+
{idx + 1}.
|
| 1043 |
+
</span>
|
| 1044 |
+
<span className="flex-1">
|
| 1045 |
+
<span className="font-mono text-[10px] uppercase tracking-wide text-zinc-500 mr-1">
|
| 1046 |
+
{step.tool}
|
| 1047 |
+
</span>
|
| 1048 |
+
{step.input}
|
| 1049 |
+
</span>
|
| 1050 |
+
</li>
|
| 1051 |
+
))}
|
| 1052 |
+
</ol>
|
| 1053 |
+
</div>
|
| 1054 |
+
)}
|
| 1055 |
+
{audit?.policyViolations &&
|
| 1056 |
+
audit.policyViolations.length > 0 && (
|
| 1057 |
+
<div className="pt-1 border-t border-zinc-800/80 mt-1">
|
| 1058 |
+
<p className="text-[10px] font-medium text-red-300 mb-0.5">
|
| 1059 |
+
Policy flags
|
| 1060 |
+
</p>
|
| 1061 |
+
<ul className="space-y-0.5">
|
| 1062 |
+
{audit.policyViolations.map((v, i) => (
|
| 1063 |
+
<li
|
| 1064 |
+
key={`${v}-${i}`}
|
| 1065 |
+
className="text-[10px] text-red-200/90"
|
| 1066 |
+
>
|
| 1067 |
+
• {v}
|
| 1068 |
+
</li>
|
| 1069 |
+
))}
|
| 1070 |
+
</ul>
|
| 1071 |
+
</div>
|
| 1072 |
+
)}
|
| 1073 |
+
</div>
|
| 1074 |
+
);
|
| 1075 |
+
})()}
|
| 1076 |
+
|
| 1077 |
+
{/* Reasoning trace */}
|
| 1078 |
+
<div className="rounded-3xl border border-zinc-900 bg-zinc-950/70 p-3 space-y-1.5">
|
| 1079 |
+
<p className="text-xs font-medium text-zinc-400 mb-1">
|
| 1080 |
+
Reasoning trace
|
| 1081 |
+
</p>
|
| 1082 |
+
<div className="max-h-40 overflow-y-auto space-y-1.5">
|
| 1083 |
+
{events
|
| 1084 |
+
.filter((event) => event.state.reasoning?.length)
|
| 1085 |
+
.map((event, index) => {
|
| 1086 |
+
const latest =
|
| 1087 |
+
event.state.reasoning?.[
|
| 1088 |
+
event.state.reasoning.length - 1
|
| 1089 |
+
];
|
| 1090 |
+
if (!latest) return null;
|
| 1091 |
+
return (
|
| 1092 |
+
<div
|
| 1093 |
+
key={`${event.node}-${index}`}
|
| 1094 |
+
className="rounded-2xl bg-zinc-900/80 border border-zinc-800 px-3 py-1.5"
|
| 1095 |
+
>
|
| 1096 |
+
<div className="flex items-center justify-between mb-0.5">
|
| 1097 |
+
<span className="text-[10px] font-mono text-zinc-500">
|
| 1098 |
+
{event.node}
|
| 1099 |
+
</span>
|
| 1100 |
+
{event.state.status && (
|
| 1101 |
+
<span className="text-[10px] text-zinc-500">
|
| 1102 |
+
{event.state.status}
|
| 1103 |
+
</span>
|
| 1104 |
+
)}
|
| 1105 |
+
</div>
|
| 1106 |
+
<p className="text-[11px] text-zinc-300 line-clamp-3">
|
| 1107 |
+
{latest.summary}
|
| 1108 |
+
</p>
|
| 1109 |
+
</div>
|
| 1110 |
+
);
|
| 1111 |
+
})}
|
| 1112 |
+
</div>
|
| 1113 |
+
</div>
|
| 1114 |
+
</div>
|
| 1115 |
+
)}
|
| 1116 |
+
</aside>
|
| 1117 |
+
</main>
|
| 1118 |
+
</div>
|
| 1119 |
+
);
|
| 1120 |
+
}
|
dist/llm/pipeline.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
/**
|
| 3 |
+
* Pipeline — runs inside the LLM worker thread only.
|
| 4 |
+
* Hugging Face Transformers + onnxruntime-node; state lives in this thread.
|
| 5 |
+
*/
|
| 6 |
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
| 7 |
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
| 8 |
+
};
|
| 9 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 10 |
+
exports.MODELS = exports.MODEL_ID = void 0;
|
| 11 |
+
exports.getModelStatus = getModelStatus;
|
| 12 |
+
exports.loadModel = loadModel;
|
| 13 |
+
exports.chatComplete = chatComplete;
|
| 14 |
+
const transformers_1 = require("@huggingface/transformers");
|
| 15 |
+
const node_path_1 = __importDefault(require("node:path"));
|
| 16 |
+
transformers_1.env.cacheDir =
|
| 17 |
+
process.env.HF_CACHE_DIR ||
|
| 18 |
+
node_path_1.default.join(process.cwd(), ".hf-cache");
|
| 19 |
+
// Default to a very small instruct model for low-spec machines.
|
| 20 |
+
// If this model ID does not exist or you prefer another tiny model,
|
| 21 |
+
// set HF_MODEL in your environment to override.
|
| 22 |
+
exports.MODEL_ID = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
|
| 23 |
+
exports.MODELS = [
|
| 24 |
+
{
|
| 25 |
+
id: "HuggingFaceTB/SmolLM2-135M-Instruct",
|
| 26 |
+
label: "SmolLM2 135M (Q4, tiny)",
|
| 27 |
+
dtype: "q4",
|
| 28 |
+
sizeNote: "~150–200 MB (approx)",
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 32 |
+
label: "SmolLM2 360M (Q4)",
|
| 33 |
+
dtype: "q4",
|
| 34 |
+
sizeNote: "~388 MB",
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 38 |
+
label: "SmolLM2 360M (FP16)",
|
| 39 |
+
dtype: "fp16",
|
| 40 |
+
sizeNote: "~725 MB",
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 44 |
+
label: "SmolLM2 360M (full)",
|
| 45 |
+
dtype: "fp32",
|
| 46 |
+
sizeNote: "~1.45 GB",
|
| 47 |
+
},
|
| 48 |
+
];
|
| 49 |
+
let currentModelId = exports.MODELS[0].id;
|
| 50 |
+
let currentDtype = exports.MODELS[0].dtype;
|
| 51 |
+
let generatorPromise = null;
|
| 52 |
+
let isModelLoaded = false;
|
| 53 |
+
let currentProgress = 0;
|
| 54 |
+
let currentStatus = "idle";
|
| 55 |
+
let currentFile = "";
|
| 56 |
+
function getModelStatus(forModelId, forDtype) {
|
| 57 |
+
const isOther = forModelId !== undefined && (forModelId !== currentModelId || forDtype !== currentDtype);
|
| 58 |
+
if (isOther) {
|
| 59 |
+
return {
|
| 60 |
+
status: "idle",
|
| 61 |
+
progress: 0,
|
| 62 |
+
file: "",
|
| 63 |
+
message: "Model not loaded",
|
| 64 |
+
modelId: forModelId,
|
| 65 |
+
dtype: forDtype,
|
| 66 |
+
};
|
| 67 |
+
}
|
| 68 |
+
if (isModelLoaded) {
|
| 69 |
+
return {
|
| 70 |
+
status: "ready",
|
| 71 |
+
progress: 100,
|
| 72 |
+
file: "",
|
| 73 |
+
message: "Model ready",
|
| 74 |
+
modelId: currentModelId,
|
| 75 |
+
dtype: currentDtype,
|
| 76 |
+
};
|
| 77 |
+
}
|
| 78 |
+
if (generatorPromise) {
|
| 79 |
+
return {
|
| 80 |
+
status: currentStatus,
|
| 81 |
+
progress: currentProgress,
|
| 82 |
+
file: currentFile,
|
| 83 |
+
message: currentFile ? `Downloading ${currentFile}` : "Loading model...",
|
| 84 |
+
modelId: currentModelId,
|
| 85 |
+
dtype: currentDtype,
|
| 86 |
+
};
|
| 87 |
+
}
|
| 88 |
+
return {
|
| 89 |
+
status: "idle",
|
| 90 |
+
progress: 0,
|
| 91 |
+
file: "",
|
| 92 |
+
message: "Model not loaded",
|
| 93 |
+
modelId: currentModelId,
|
| 94 |
+
dtype: currentDtype,
|
| 95 |
+
};
|
| 96 |
+
}
|
| 97 |
+
function loadModel(modelId, dtype, onProgress) {
|
| 98 |
+
console.log("[worker] loadModel called");
|
| 99 |
+
const nextId = modelId ?? currentModelId;
|
| 100 |
+
const nextDtype = dtype ?? currentDtype;
|
| 101 |
+
if (nextId !== currentModelId || nextDtype !== currentDtype) {
|
| 102 |
+
generatorPromise = null;
|
| 103 |
+
isModelLoaded = false;
|
| 104 |
+
currentModelId = nextId;
|
| 105 |
+
currentDtype = nextDtype;
|
| 106 |
+
currentStatus = "idle";
|
| 107 |
+
currentProgress = 0;
|
| 108 |
+
currentFile = "";
|
| 109 |
+
}
|
| 110 |
+
if (generatorPromise) {
|
| 111 |
+
return generatorPromise;
|
| 112 |
+
}
|
| 113 |
+
console.log(`\n🔄 [worker] Loading model: ${currentModelId}${currentDtype ? ` (${currentDtype})` : ""}`);
|
| 114 |
+
console.log(` Cache directory: ${transformers_1.env.cacheDir}\n`);
|
| 115 |
+
currentStatus = "loading";
|
| 116 |
+
const startTime = Date.now();
|
| 117 |
+
const pipelineOptions = {
|
| 118 |
+
progress_callback: (progressData) => {
|
| 119 |
+
currentStatus = progressData.status === "progress" ? "downloading" : "loading";
|
| 120 |
+
currentFile = progressData.file || progressData.name || "";
|
| 121 |
+
currentProgress = Math.round(progressData.progress ?? 0);
|
| 122 |
+
const update = {
|
| 123 |
+
status: currentStatus,
|
| 124 |
+
progress: currentProgress,
|
| 125 |
+
file: currentFile,
|
| 126 |
+
message: currentFile
|
| 127 |
+
? `Downloading ${currentFile.split("/").pop()} (${currentProgress}%)`
|
| 128 |
+
: `${progressData.status}...`,
|
| 129 |
+
modelId: currentModelId,
|
| 130 |
+
dtype: currentDtype,
|
| 131 |
+
};
|
| 132 |
+
console.log(` ${update.message}`);
|
| 133 |
+
onProgress?.(update);
|
| 134 |
+
},
|
| 135 |
+
};
|
| 136 |
+
if (currentDtype) {
|
| 137 |
+
pipelineOptions.dtype = currentDtype;
|
| 138 |
+
}
|
| 139 |
+
const pipelinePromise = (0, transformers_1.pipeline)("text-generation", currentModelId, pipelineOptions);
|
| 140 |
+
generatorPromise = pipelinePromise
|
| 141 |
+
.then((gen) => {
|
| 142 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 143 |
+
console.log(`\n✅ [worker] Model loaded in ${elapsed}s\n`);
|
| 144 |
+
isModelLoaded = true;
|
| 145 |
+
currentStatus = "ready";
|
| 146 |
+
currentProgress = 100;
|
| 147 |
+
onProgress?.({
|
| 148 |
+
status: "ready",
|
| 149 |
+
progress: 100,
|
| 150 |
+
file: "",
|
| 151 |
+
message: "Model ready",
|
| 152 |
+
modelId: currentModelId,
|
| 153 |
+
dtype: currentDtype,
|
| 154 |
+
});
|
| 155 |
+
return gen;
|
| 156 |
+
})
|
| 157 |
+
.catch((err) => {
|
| 158 |
+
currentStatus = "error";
|
| 159 |
+
generatorPromise = null;
|
| 160 |
+
onProgress?.({
|
| 161 |
+
status: "error",
|
| 162 |
+
progress: 0,
|
| 163 |
+
file: "",
|
| 164 |
+
message: err.message,
|
| 165 |
+
modelId: currentModelId,
|
| 166 |
+
dtype: currentDtype,
|
| 167 |
+
});
|
| 168 |
+
throw err;
|
| 169 |
+
});
|
| 170 |
+
return generatorPromise;
|
| 171 |
+
}
|
| 172 |
+
async function chatComplete(systemPrompt, userMessage) {
|
| 173 |
+
console.log("[worker] chatComplete called");
|
| 174 |
+
const generator = await loadModel();
|
| 175 |
+
const messages = [
|
| 176 |
+
{ role: "system", content: systemPrompt },
|
| 177 |
+
{ role: "user", content: userMessage },
|
| 178 |
+
];
|
| 179 |
+
const preview = userMessage.slice(0, 60).replace(/\n/g, " ");
|
| 180 |
+
console.log(`🤖 [worker] Generating response for: "${preview}..."`);
|
| 181 |
+
const startTime = Date.now();
|
| 182 |
+
const output = await generator(messages, {
|
| 183 |
+
max_new_tokens: 512,
|
| 184 |
+
do_sample: true,
|
| 185 |
+
temperature: 0.7,
|
| 186 |
+
});
|
| 187 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 188 |
+
const result = output[0];
|
| 189 |
+
const assistantMessage = result.generated_text.find((msg) => msg.role === "assistant");
|
| 190 |
+
if (!assistantMessage) {
|
| 191 |
+
throw new Error("No assistant response generated");
|
| 192 |
+
}
|
| 193 |
+
console.log(`✅ [worker] Generated ${assistantMessage.content.length} chars in ${elapsed}s`);
|
| 194 |
+
return assistantMessage.content;
|
| 195 |
+
}
|
dist/llm/worker-entry.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
/**
|
| 3 |
+
* Worker entry — runs in a Node worker_thread. Handles getStatus, load, chat and forwards progress.
|
| 4 |
+
*/
|
| 5 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 6 |
+
const node_worker_threads_1 = require("node:worker_threads");
|
| 7 |
+
const pipeline_1 = require("./pipeline");
|
| 8 |
+
function reply(msg) {
|
| 9 |
+
node_worker_threads_1.parentPort?.postMessage(msg);
|
| 10 |
+
}
|
| 11 |
+
node_worker_threads_1.parentPort?.on("message", (msg) => {
|
| 12 |
+
const { id, type, payload } = msg;
|
| 13 |
+
(async () => {
|
| 14 |
+
try {
|
| 15 |
+
switch (type) {
|
| 16 |
+
case "getStatus": {
|
| 17 |
+
const status = (0, pipeline_1.getModelStatus)(payload.modelId, payload.dtype);
|
| 18 |
+
reply({ id, type: "resolve", payload: { ...status, models: pipeline_1.MODELS } });
|
| 19 |
+
break;
|
| 20 |
+
}
|
| 21 |
+
case "load": {
|
| 22 |
+
await (0, pipeline_1.loadModel)(payload.modelId, payload.dtype, (progress) => {
|
| 23 |
+
reply({
|
| 24 |
+
id,
|
| 25 |
+
type: "progress",
|
| 26 |
+
payload: { ...progress, models: pipeline_1.MODELS },
|
| 27 |
+
});
|
| 28 |
+
});
|
| 29 |
+
const status = (0, pipeline_1.getModelStatus)(payload.modelId, payload.dtype);
|
| 30 |
+
reply({ id, type: "resolve", payload: { ...status, models: pipeline_1.MODELS } });
|
| 31 |
+
break;
|
| 32 |
+
}
|
| 33 |
+
case "chat": {
|
| 34 |
+
const text = await (0, pipeline_1.chatComplete)(payload.systemPrompt, payload.userMessage);
|
| 35 |
+
reply({ id, type: "resolve", payload: text });
|
| 36 |
+
break;
|
| 37 |
+
}
|
| 38 |
+
default:
|
| 39 |
+
reply({ id, type: "reject", payload: `Unknown message type: ${msg.type}` });
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
catch (err) {
|
| 43 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 44 |
+
reply({ id, type: "reject", payload: message });
|
| 45 |
+
}
|
| 46 |
+
})();
|
| 47 |
+
});
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepTrust Architecture
|
| 2 |
+
|
| 3 |
+
This document provides a detailed technical overview of the DeepTrust Research Agent architecture, covering the state machine design, data flow, and implementation patterns.
|
| 4 |
+
|
| 5 |
+
## System Overview
|
| 6 |
+
|
| 7 |
+
DeepTrust is a research automation system that orchestrates an LLM through a multi-stage workflow. The system decomposes research questions into executable plans, validates them against policy, executes tool calls, and synthesizes results into reports.
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 11 |
+
│ FRONTEND (app/page.tsx) │
|
| 12 |
+
│ ┌─────────────┐ ┌─────────────────────────────────────────────────────┐ │
|
| 13 |
+
│ │ Model Card │ │ Chat + Context panel │ │
|
| 14 |
+
│ │ (SSE load) │ │ • Messages (user / assistant with word-by-word) │ │
|
| 15 |
+
│ └──────┬──────┘ │ • Knowledge drop zone (files, URLs, notes) │ │
|
| 16 |
+
│ │ │ • Quick-action chips, preview prompts │ │
|
| 17 |
+
│ │ │ • Reasoning trace (node summaries) │ │
|
| 18 |
+
│ │ └──────────────────────────┬──────────────────────────┘ │
|
| 19 |
+
└─────────┼───────────────────────────────────┼─────────────────────────────┘
|
| 20 |
+
│ │
|
| 21 |
+
▼ ▼
|
| 22 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 23 |
+
│ API LAYER │
|
| 24 |
+
│ ┌─────────────────────┐ ┌─────────────────────────────────────┐ │
|
| 25 |
+
│ │ GET/POST │ │ POST /api/research │ │
|
| 26 |
+
│ │ /api/model/load │ │ (SSE: event + data per research step)│ │
|
| 27 |
+
│ │ (SSE progress) │ └─────────────────┬───────────────────┘ │
|
| 28 |
+
│ └─────────┬───────────┘ │ │
|
| 29 |
+
└────────────┼─────────────────────────────────┼─────────────────────────────┘
|
| 30 |
+
│ │
|
| 31 |
+
▼ ▼
|
| 32 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 33 |
+
│ AGENT CORE (lib/agent) │
|
| 34 |
+
│ ┌─────────────────┐ ┌─────────────────────────────────────────────────┐ │
|
| 35 |
+
│ │ LLM Client │ │ StateGraph │ │
|
| 36 |
+
│ │ (worker thread) │ │ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │ │
|
| 37 |
+
│ │ loadModel() │ │ │ Thinker │──│ Auditor │──│ Tool Executor │ │ │
|
| 38 |
+
│ │ chatComplete() │◄─┼──└────▲────┘ └────┬────┘ └──────┬───────┘ │ │
|
| 39 |
+
│ └─────────────────┘ │ │ │ │ │ │
|
| 40 |
+
│ │ └────────────┘ ▼ │ │
|
| 41 |
+
│ │ ┌───────────────┐ │ │
|
| 42 |
+
│ │ │ Synthesizer │ │ │
|
| 43 |
+
│ │ └───────────────��� │ │
|
| 44 |
+
│ └─────────────────────────────────────────────────┘ │
|
| 45 |
+
└─────────────────────────────────────────────────────────────────────────────┘
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## State Machine Design
|
| 49 |
+
|
| 50 |
+
### LangGraph StateGraph
|
| 51 |
+
|
| 52 |
+
The agent is implemented as a LangGraph `StateGraph<ResearchState>`. LangGraph provides:
|
| 53 |
+
|
| 54 |
+
1. **Typed State Channels**: Each state field has a merge strategy
|
| 55 |
+
2. **Conditional Routing**: Functions determine the next node based on state
|
| 56 |
+
3. **Checkpointing**: State snapshots enable pause/resume workflows
|
| 57 |
+
4. **Streaming**: Events are yielded as nodes complete
|
| 58 |
+
|
| 59 |
+
### State Flow
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
Initial State
|
| 63 |
+
│
|
| 64 |
+
▼
|
| 65 |
+
┌─────────┐
|
| 66 |
+
│ thinker │ ◄──────────────────────────────┐
|
| 67 |
+
└────┬────┘ │
|
| 68 |
+
│ produces plan │
|
| 69 |
+
▼ │
|
| 70 |
+
┌─────────┐ │
|
| 71 |
+
│ auditor │ │
|
| 72 |
+
└────┬────┘ │
|
| 73 |
+
│ │
|
| 74 |
+
├── verdict: "rejected" ──────────────┘
|
| 75 |
+
│ (planRevisionCount++)
|
| 76 |
+
│
|
| 77 |
+
├── planRevisionCount >= max ──────► END
|
| 78 |
+
│
|
| 79 |
+
└── verdict: "approved"
|
| 80 |
+
│
|
| 81 |
+
▼
|
| 82 |
+
┌───────────┐
|
| 83 |
+
│ hitl_gate │ ◄─── interrupt() pauses here
|
| 84 |
+
└─────┬─────┘
|
| 85 |
+
│ humanApproved = true (external)
|
| 86 |
+
▼
|
| 87 |
+
┌───────────────┐
|
| 88 |
+
│ tool_executor │ ◄─────────────┐
|
| 89 |
+
└───────┬───────┘ │
|
| 90 |
+
│ │
|
| 91 |
+
├── more steps ─────────┘
|
| 92 |
+
│
|
| 93 |
+
└── all steps done
|
| 94 |
+
│
|
| 95 |
+
▼
|
| 96 |
+
┌─────────────┐
|
| 97 |
+
│ synthesizer │
|
| 98 |
+
└──────┬──────┘
|
| 99 |
+
│
|
| 100 |
+
▼
|
| 101 |
+
END
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Channel Merge Strategies
|
| 105 |
+
|
| 106 |
+
LangGraph channels define how incoming state updates merge with existing state:
|
| 107 |
+
|
| 108 |
+
| Field | Strategy | Rationale |
|
| 109 |
+
|-------|----------|-----------|
|
| 110 |
+
| `reasoning` | Append | Accumulates full reasoning trace |
|
| 111 |
+
| All others | Replace | Last-write-wins for scalar values |
|
| 112 |
+
|
| 113 |
+
```typescript
|
| 114 |
+
channels: {
|
| 115 |
+
reasoning: {
|
| 116 |
+
value: (existing, incoming) => [...(existing ?? []), ...(incoming ?? [])],
|
| 117 |
+
default: () => [],
|
| 118 |
+
},
|
| 119 |
+
plan: { value: (_, n) => n },
|
| 120 |
+
// ...
|
| 121 |
+
}
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Data Schemas
|
| 125 |
+
|
| 126 |
+
### Type System Philosophy
|
| 127 |
+
|
| 128 |
+
All data structures use Zod for runtime validation. TypeScript types are inferred from Zod schemas, ensuring a single source of truth.
|
| 129 |
+
|
| 130 |
+
```typescript
|
| 131 |
+
// Schema definition
|
| 132 |
+
export const ResearchStep = z.object({
|
| 133 |
+
id: z.string().uuid(),
|
| 134 |
+
tool: z.enum(["web_search", "document_fetch", "code_interpreter", "summarize"]),
|
| 135 |
+
input: z.string().min(1),
|
| 136 |
+
rationale: z.string(),
|
| 137 |
+
output: z.string().optional(),
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
// Type inference (no manual duplication)
|
| 141 |
+
export type ResearchStep = z.infer<typeof ResearchStep>;
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Schema Hierarchy
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
ResearchState (root)
|
| 148 |
+
├── threadId: UUID
|
| 149 |
+
├── userQuery: string
|
| 150 |
+
├── plan: ResearchPlan | null
|
| 151 |
+
│ ├── objective: string
|
| 152 |
+
│ ├── steps: ResearchStep[]
|
| 153 |
+
│ │ ├── id: UUID
|
| 154 |
+
│ │ ├── tool: enum
|
| 155 |
+
│ │ ├── input: string
|
| 156 |
+
│ │ ├── rationale: string
|
| 157 |
+
│ │ └── output?: string
|
| 158 |
+
│ ├── estimatedTokenBudget: number
|
| 159 |
+
│ └── revision: number
|
| 160 |
+
├── auditResult: AuditResult | null
|
| 161 |
+
│ ├── verdict: enum
|
| 162 |
+
│ ├── policyViolations: string[]
|
| 163 |
+
│ └── suggestions: string[]
|
| 164 |
+
├── reasoning: ReasoningEntry[]
|
| 165 |
+
│ ├── node: enum
|
| 166 |
+
│ ├── timestamp: datetime
|
| 167 |
+
│ ├── summary: string
|
| 168 |
+
│ └── rawThought?: string
|
| 169 |
+
└── status: enum
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
## Node Implementation Patterns
|
| 173 |
+
|
| 174 |
+
### Node Function Signature
|
| 175 |
+
|
| 176 |
+
All nodes follow the same pattern:
|
| 177 |
+
|
| 178 |
+
```typescript
|
| 179 |
+
async function nodeName(state: ResearchState): Promise<Partial<ResearchState>> {
|
| 180 |
+
// 1. Read required state
|
| 181 |
+
// 2. Perform computation (LLM calls, tool execution, etc.)
|
| 182 |
+
// 3. Return partial state update
|
| 183 |
+
}
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
LangGraph merges the returned partial state into the existing state using channel strategies.
|
| 187 |
+
|
| 188 |
+
### Prompt Engineering Pattern
|
| 189 |
+
|
| 190 |
+
Each LLM-calling node structures prompts for reliable JSON output:
|
| 191 |
+
|
| 192 |
+
```typescript
|
| 193 |
+
const system = `
|
| 194 |
+
You are the [Role] node of DeepTrust.
|
| 195 |
+
[Brief description of responsibility]
|
| 196 |
+
|
| 197 |
+
Return ONLY a valid JSON object matching:
|
| 198 |
+
{
|
| 199 |
+
"field1": type,
|
| 200 |
+
"field2": type
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
Rules:
|
| 204 |
+
- [Constraint 1]
|
| 205 |
+
- [Constraint 2]
|
| 206 |
+
- Do not include markdown fences or prose outside JSON.
|
| 207 |
+
`;
|
| 208 |
+
|
| 209 |
+
const userMessage = `[Contextual input]`;
|
| 210 |
+
const raw = await chatComplete(system, userMessage);
|
| 211 |
+
const parsed = extractJSON(raw);
|
| 212 |
+
const validated = Schema.parse(parsed);
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### Error Handling Pattern
|
| 216 |
+
|
| 217 |
+
Nodes append to the reasoning trace even on failure, enabling debugging:
|
| 218 |
+
|
| 219 |
+
```typescript
|
| 220 |
+
async function node(state: ResearchState) {
|
| 221 |
+
try {
|
| 222 |
+
// ... main logic
|
| 223 |
+
return { /* success state */ };
|
| 224 |
+
} catch (error) {
|
| 225 |
+
const reasoning = appendReasoning(state, {
|
| 226 |
+
node: "node_name",
|
| 227 |
+
summary: `Error: ${error.message}`,
|
| 228 |
+
});
|
| 229 |
+
return {
|
| 230 |
+
status: "failed",
|
| 231 |
+
errorMessage: error.message,
|
| 232 |
+
reasoning,
|
| 233 |
+
};
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
## Routing Logic
|
| 239 |
+
|
| 240 |
+
### Conditional Edges
|
| 241 |
+
|
| 242 |
+
LangGraph `addConditionalEdges` accepts a router function that returns the next node name:
|
| 243 |
+
|
| 244 |
+
```typescript
|
| 245 |
+
graph.addConditionalEdges("auditor", routeAfterAudit, {
|
| 246 |
+
thinker: "thinker",
|
| 247 |
+
hitl_gate: "hitl_gate",
|
| 248 |
+
[END]: END,
|
| 249 |
+
});
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
The mapping object defines legal transitions. If the router returns a key not in the map, LangGraph throws an error.
|
| 253 |
+
|
| 254 |
+
### Router Functions
|
| 255 |
+
|
| 256 |
+
Routers are pure functions that inspect state:
|
| 257 |
+
|
| 258 |
+
```typescript
|
| 259 |
+
function routeAfterAudit(state: ResearchState): "thinker" | "hitl_gate" | typeof END {
|
| 260 |
+
// Safety ceiling check
|
| 261 |
+
if (state.planRevisionCount >= state.maxPlanRevisions) {
|
| 262 |
+
return END;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Rejection triggers revision
|
| 266 |
+
if (state.auditResult?.verdict !== "approved") {
|
| 267 |
+
return "thinker";
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// Approval proceeds to HITL
|
| 271 |
+
return "hitl_gate";
|
| 272 |
+
}
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
## LLM Integration Layer
|
| 276 |
+
|
| 277 |
+
### Architecture
|
| 278 |
+
|
| 279 |
+
```
|
| 280 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 281 |
+
│ llm/index.ts │
|
| 282 |
+
├─────────────────────────────────────────────────────────────┤
|
| 283 |
+
│ Module-level State │
|
| 284 |
+
│ ├── generatorPromise: Promise<TextGenerationPipeline> │
|
| 285 |
+
│ ├── isModelLoaded: boolean │
|
| 286 |
+
│ └── currentProgress: number │
|
| 287 |
+
├─────────────────────────────────────────────────────────────┤
|
| 288 |
+
│ Exports │
|
| 289 |
+
│ ├── loadModel(onProgress?) → Promise<Pipeline> │
|
| 290 |
+
│ ├── chatComplete(system, user) → Promise<string> │
|
| 291 |
+
│ ├── getModelStatus() → ModelProgress │
|
| 292 |
+
│ └── MODEL_ID: string │
|
| 293 |
+
└─────────────────────────────────────────────────────────────┘
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
+
### Lazy Loading Pattern
|
| 297 |
+
|
| 298 |
+
The model is loaded once and reused across all requests:
|
| 299 |
+
|
| 300 |
+
```typescript
|
| 301 |
+
let generatorPromise: Promise<TextGenerationPipeline> | null = null;
|
| 302 |
+
|
| 303 |
+
export function loadModel(): Promise<TextGenerationPipeline> {
|
| 304 |
+
if (generatorPromise) return generatorPromise; // Return cached promise
|
| 305 |
+
|
| 306 |
+
generatorPromise = pipeline("text-generation", MODEL_ID, {
|
| 307 |
+
progress_callback: handleProgress,
|
| 308 |
+
});
|
| 309 |
+
|
| 310 |
+
return generatorPromise;
|
| 311 |
+
}
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
### Progress Streaming
|
| 315 |
+
|
| 316 |
+
The Hugging Face Transformers library supports progress callbacks:
|
| 317 |
+
|
| 318 |
+
```typescript
|
| 319 |
+
pipeline("text-generation", MODEL_ID, {
|
| 320 |
+
progress_callback: (data: { status: string; file?: string; progress?: number }) => {
|
| 321 |
+
// data.status: "download", "progress", "ready"
|
| 322 |
+
// data.file: current file being downloaded
|
| 323 |
+
// data.progress: 0-1 fraction
|
| 324 |
+
},
|
| 325 |
+
});
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
## API Design
|
| 329 |
+
|
| 330 |
+
### Research Streaming: Server-Sent Events (SSE)
|
| 331 |
+
|
| 332 |
+
Research results are streamed as **Server-Sent Events** so the client can distinguish event types and get low-latency, non-blocking updates. The response uses `Content-Type: text/event-stream` and each message has an `event` name plus a `data` payload (JSON).
|
| 333 |
+
|
| 334 |
+
**Why SSE (not NDJSON):** Standard SSE gives a single, well-understood protocol for streaming; event names (`start`, `research`, `error`) allow the client to handle each kind of message without guessing. Proxies and browsers handle SSE well, and we can add `ping`/`heartbeat` later without changing the wire format.
|
| 335 |
+
|
| 336 |
+
**Wire format:**
|
| 337 |
+
|
| 338 |
+
```
|
| 339 |
+
event: start
|
| 340 |
+
data: {"node":"_start","state":{"status":"started","plan":{...},"reasoning":[...]}}
|
| 341 |
+
|
| 342 |
+
event: research
|
| 343 |
+
data: {"node":"thinker","state":{"status":"thinking","plan":{...}}}
|
| 344 |
+
|
| 345 |
+
event: research
|
| 346 |
+
data: {"node":"auditor","state":{"status":"awaiting_approval","auditResult":{...}}}
|
| 347 |
+
|
| 348 |
+
event: research
|
| 349 |
+
data: {"node":"synthesizer","state":{"status":"complete","finalReport":"..."}}
|
| 350 |
+
|
| 351 |
+
event: error
|
| 352 |
+
data: {"node":"_error","state":{"status":"failed","errorMessage":"..."}}
|
| 353 |
+
```
|
| 354 |
+
|
| 355 |
+
**Server (route):** The route encodes each event with `event: <name>\ndata: <JSON>\n\n` and enqueues into a `ReadableStream`, then closes the stream when the graph run finishes or throws.
|
| 356 |
+
|
| 357 |
+
**Client:** The client reads the response body with `getReader()`, accumulates chunks, splits on `\n\n` to get full SSE messages, then for each message parses the `event:` line and the `data:` line (JSON). Events of type `research` (and `start`) are appended to the events list; the last event carrying `finalReport` is used to drive the word-by-word streaming animation in the chat.
|
| 358 |
+
|
| 359 |
+
### Model Loading Protocol
|
| 360 |
+
|
| 361 |
+
Model load also uses SSE for download progress:
|
| 362 |
+
|
| 363 |
+
```
|
| 364 |
+
data: {"status":"downloading","progress":25,"file":"model.onnx"}\n\n
|
| 365 |
+
data: {"status":"downloading","progress":50,"file":"model.onnx"}\n\n
|
| 366 |
+
data: {"status":"ready","progress":100,"modelId":"...","dtype":"q4"}\n\n
|
| 367 |
+
```
|
| 368 |
+
|
| 369 |
+
SSE format: `data: ` prefix, JSON body, double newline (`\n\n`) between events.
|
| 370 |
+
|
| 371 |
+
## Frontend Architecture
|
| 372 |
+
|
| 373 |
+
The workspace (`app/page.tsx`) is built for a Cursor/Gemini-like flow: immediate feedback, non-blocking streaming, and clear separation between chat, context, and observability.
|
| 374 |
+
|
| 375 |
+
### Layout and Responsibilities
|
| 376 |
+
|
| 377 |
+
| Area | Purpose |
|
| 378 |
+
|------|--------|
|
| 379 |
+
| **Chat** | User messages and assistant replies. Assistant messages show a shimmer placeholder while waiting, then the final report is revealed word-by-word for a live-conversation feel. |
|
| 380 |
+
| **Context panel** | Knowledge drop zone: drag-and-drop files (PDF, text, etc.) or add URLs/notes. Items are listed and sent as `knowledge` in the research request for future agent use. |
|
| 381 |
+
| **Model card** | Model selection, load/progress, status pill (Ready / Loading / Error). Uses the same SSE pattern as model load API. |
|
| 382 |
+
| **Reasoning trace** | Scrollable list of the latest reasoning summaries per node so you can follow the graph’s flow while the chat shows the final answer. |
|
| 383 |
+
|
| 384 |
+
### Optimistic UI and Streaming Flow
|
| 385 |
+
|
| 386 |
+
1. **On submit:** The client immediately appends a user message and an assistant placeholder (with shimmer) to the chat and sets `isStreaming = true`. No wait for the first byte.
|
| 387 |
+
2. **SSE consumption:** `POST /api/research` is read with `response.body.getReader()`. Chunks are decoded and split on `\n\n`. Each SSE message is parsed for `event:` and `data:`; `start` and `research` events are appended to the events list.
|
| 388 |
+
3. **Final report:** When an event contains `state.finalReport`, that text is stored and a word-by-word animation is started for the latest assistant message: a timer (e.g. every 40ms) reveals the next word until the full report is shown, then `isStreaming` is cleared.
|
| 389 |
+
4. **Abort/cleanup:** A ref holds an `AbortController` for the in-flight request; starting a new run aborts the previous one and clears the streaming timer so only one “live” reply runs at a time.
|
| 390 |
+
|
| 391 |
+
### Knowledge / Context Flow
|
| 392 |
+
|
| 393 |
+
- **Drop zone:** Accepts drag-and-drop files and paste/drop of URLs (`text/uri-list` or plain text). Files and URLs are turned into `KnowledgeItem` entries (id, type, label, optional meta).
|
| 394 |
+
- **Request payload:** The research request sends `{ query, knowledge: knowledgeItems }`. The backend currently uses only `query`; `knowledge` is reserved for future use (e.g. RAG, plan conditioning).
|
| 395 |
+
- **UI copy:** The panel explains that added context can be used to ground answers; when the agent supports it, no frontend change is required beyond the existing payload.
|
| 396 |
+
|
| 397 |
+
### Quick Actions and Starter Cards
|
| 398 |
+
|
| 399 |
+
- **Quick-action chips** below the input (e.g. “Help me learn this topic”, “Summarize these docs”) set or extend the query and optionally trigger a run, similar to Gemini suggestion chips.
|
| 400 |
+
- **Starter cards** in the empty state show example prompts (e.g. “How does Gemini Pro work…”) and populate the input or start a run when clicked, so the app feels ready to use without typing.
|
| 401 |
+
|
| 402 |
+
### Why This Structure
|
| 403 |
+
|
| 404 |
+
- **Single page:** All controls (model, context, chat, trace) stay on one screen to reduce context switching and match a “flow state” tool.
|
| 405 |
+
- **SSE end-to-end:** Both research and model load use SSE so the client has one mental model: stream events, parse by type, update UI.
|
| 406 |
+
- **Word-by-word:** The synthesizer returns the full report in one event; animating it word-by-word on the client gives a streaming feel without changing the backend contract.
|
| 407 |
+
|
| 408 |
+
## File Organization
|
| 409 |
+
|
| 410 |
+
### Separation of Concerns
|
| 411 |
+
|
| 412 |
+
| Directory | Responsibility |
|
| 413 |
+
|-----------|----------------|
|
| 414 |
+
| `lib/agent/nodes/` | Individual node implementations |
|
| 415 |
+
| `lib/agent/llm/` | LLM client abstraction |
|
| 416 |
+
| `lib/agent/utils/` | Shared utilities (JSON extraction, policy loading) |
|
| 417 |
+
| `lib/agent/` | Graph construction, state schemas, routing |
|
| 418 |
+
| `app/api/` | HTTP endpoints |
|
| 419 |
+
| `app/` | React UI components |
|
| 420 |
+
|
| 421 |
+
### Import Hierarchy
|
| 422 |
+
|
| 423 |
+
```
|
| 424 |
+
app/api/research/route.ts
|
| 425 |
+
└── @/lib/agent (public API)
|
| 426 |
+
└── graph.ts
|
| 427 |
+
├── state.ts
|
| 428 |
+
├── nodes/index.ts
|
| 429 |
+
│ ├── thinker.ts → llm, utils, state
|
| 430 |
+
│ ├── auditor.ts → llm, utils, state
|
| 431 |
+
│ └── ...
|
| 432 |
+
└── routing.ts → state
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
## Checkpointing and Persistence
|
| 436 |
+
|
| 437 |
+
### MemorySaver (Development)
|
| 438 |
+
|
| 439 |
+
Default checkpointer stores state in memory. State is lost on server restart.
|
| 440 |
+
|
| 441 |
+
```typescript
|
| 442 |
+
import { MemorySaver } from "@langchain/langgraph";
|
| 443 |
+
const checkpointer = new MemorySaver();
|
| 444 |
+
```
|
| 445 |
+
|
| 446 |
+
### Production Persistence
|
| 447 |
+
|
| 448 |
+
For production, swap to a persistent checkpointer:
|
| 449 |
+
|
| 450 |
+
```typescript
|
| 451 |
+
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
|
| 452 |
+
const checkpointer = await PostgresSaver.fromConnString(process.env.DATABASE_URL);
|
| 453 |
+
```
|
| 454 |
+
|
| 455 |
+
### Thread-Based State
|
| 456 |
+
|
| 457 |
+
Each research session has a unique `threadId`. The checkpointer keys state by thread:
|
| 458 |
+
|
| 459 |
+
```typescript
|
| 460 |
+
const config = { configurable: { thread_id: initialState.threadId } };
|
| 461 |
+
|
| 462 |
+
// Stream with checkpointing
|
| 463 |
+
for await (const event of graph.stream(initialState, config)) { ... }
|
| 464 |
+
|
| 465 |
+
// Resume from checkpoint
|
| 466 |
+
await graph.updateState(config, { humanApproved: true });
|
| 467 |
+
for await (const event of graph.stream(null, config)) { ... }
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
## Security Considerations
|
| 471 |
+
|
| 472 |
+
### Policy Enforcement
|
| 473 |
+
|
| 474 |
+
The Auditor node validates plans against `POLICY.md` before execution. Policy rules should cover:
|
| 475 |
+
|
| 476 |
+
- Data access restrictions
|
| 477 |
+
- External request limits
|
| 478 |
+
- Allowed tool types
|
| 479 |
+
- Content guidelines
|
| 480 |
+
|
| 481 |
+
### Tool Sandboxing
|
| 482 |
+
|
| 483 |
+
Tool implementations (currently stubs) should sandbox external operations:
|
| 484 |
+
|
| 485 |
+
- Network requests: Rate limiting, allowlists
|
| 486 |
+
- Code execution: Containerized environments
|
| 487 |
+
- File access: Scoped to specific directories
|
| 488 |
+
|
| 489 |
+
### Input Validation
|
| 490 |
+
|
| 491 |
+
All state mutations pass through Zod schemas, preventing malformed data from propagating.
|
| 492 |
+
|
| 493 |
+
---
|
| 494 |
+
|
| 495 |
+
## Future Considerations
|
| 496 |
+
|
| 497 |
+
### Potential Enhancements
|
| 498 |
+
|
| 499 |
+
1. **Persistent Checkpointing**: PostgreSQL or Redis for production state storage
|
| 500 |
+
2. **Tool Implementations**: Real web search (Tavily), document fetch (Playwright), code execution
|
| 501 |
+
3. **Multi-Model Support**: Router to select appropriate model per task complexity
|
| 502 |
+
4. **Observability**: OpenTelemetry traces for node-level metrics
|
| 503 |
+
5. **Parallel Tool Execution**: Execute independent steps concurrently
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
instrumentation.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Next.js instrumentation — runs once when the Node server starts.
|
| 3 |
+
* Preloads the LLM model so the first request doesn't wait for download + load.
|
| 4 |
+
* Skip on Vercel (no local inference). Disable with DEEPTRUST_LOAD_MODEL_AT_STARTUP=0.
|
| 5 |
+
* Skip on Hugging Face Spaces so the Space becomes healthy immediately (model loads on first use).
|
| 6 |
+
*
|
| 7 |
+
* Logs progress from the worker so long-running downloads/loads are visible in Space logs.
|
| 8 |
+
* After PRELOAD_TIMEOUT_MS (default 10 min) logs a timeout message if still loading.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
const PRELOAD_TIMEOUT_MS = Number(process.env.DEEPTRUST_PRELOAD_TIMEOUT_MS) || 10 * 60 * 1000;
|
| 12 |
+
|
| 13 |
+
/** True when running inside a Hugging Face Space (avoids startup preload so health check passes). */
|
| 14 |
+
function isHuggingFaceSpace(): boolean {
|
| 15 |
+
if (process.env.SPACE_ID || process.env.HF_SPACE_ID) return true;
|
| 16 |
+
if (process.env.PORT === "7860") return true; // Spaces default Docker app_port
|
| 17 |
+
return false;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export async function register() {
|
| 21 |
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
| 22 |
+
if (process.env.VERCEL === "1") return;
|
| 23 |
+
if (process.env.DEEPTRUST_LOAD_MODEL_AT_STARTUP === "0" || process.env.DEEPTRUST_LOAD_MODEL_AT_STARTUP === "false") return;
|
| 24 |
+
if (isHuggingFaceSpace()) {
|
| 25 |
+
console.log("[instrumentation] Hugging Face Space detected; skipping startup model preload (model will load on first use).");
|
| 26 |
+
return;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const { loadModel } = await import("@/lib/agent/llm");
|
| 30 |
+
const modelId = process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
|
| 31 |
+
console.log(`[instrumentation] Preloading model in background: ${modelId}`);
|
| 32 |
+
|
| 33 |
+
let completed = false;
|
| 34 |
+
const timeoutId = setTimeout(() => {
|
| 35 |
+
if (!completed) {
|
| 36 |
+
console.log(
|
| 37 |
+
`[instrumentation] Model preload still in progress (timeout after ${PRELOAD_TIMEOUT_MS / 60000} min). Check worker logs for download/load progress.`
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
}, PRELOAD_TIMEOUT_MS);
|
| 41 |
+
|
| 42 |
+
const onProgress = (p: { status: string; message: string; file?: string; progress?: number }) => {
|
| 43 |
+
console.log(`[instrumentation] ${p.message}${p.progress != null ? ` ${p.progress}%` : ""}`);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
void loadModel(undefined, "q4", onProgress)
|
| 47 |
+
.then(() => {
|
| 48 |
+
completed = true;
|
| 49 |
+
console.log("[instrumentation] Model preload complete.");
|
| 50 |
+
})
|
| 51 |
+
.catch((err) => {
|
| 52 |
+
completed = true;
|
| 53 |
+
console.error("[instrumentation] Model preload failed:", err instanceof Error ? err.message : err);
|
| 54 |
+
})
|
| 55 |
+
.finally(() => clearTimeout(timeoutId));
|
| 56 |
+
}
|
lib/agent/POLICY.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Research Policy
|
| 2 |
+
- Do not access private or personal data.
|
| 3 |
+
- Do not make more than 10 external requests per session.
|
| 4 |
+
- All sources must be publicly accessible.
|
lib/agent/graph.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* graph.ts — DeepTrust Research Agent
|
| 3 |
+
*
|
| 4 |
+
* Defines the full StateGraph lifecycle:
|
| 5 |
+
*
|
| 6 |
+
* ┌──────────────────────────────────────────────────────────┐
|
| 7 |
+
* │ │
|
| 8 |
+
* │ [START] ──► thinker ──► auditor ──► tool_executor │
|
| 9 |
+
* │ ▲ │ │ │
|
| 10 |
+
* │ │ reject │ │ │
|
| 11 |
+
* │ └───────────┘ ▼ │
|
| 12 |
+
* │ synthesizer │
|
| 13 |
+
* │ │ │
|
| 14 |
+
* │ [END] │
|
| 15 |
+
* └──────────────────────────────────────────────────────────┘
|
| 16 |
+
*
|
| 17 |
+
* HITL interrupt fires between auditor approval and tool_executor,
|
| 18 |
+
* giving operators a chance to review the plan before any tool runs.
|
| 19 |
+
*
|
| 20 |
+
* Every node writes to `state.reasoning` for full observability.
|
| 21 |
+
*/
|
| 22 |
+
|
| 23 |
+
import { StateGraph, END, START, MemorySaver, Command } from "@langchain/langgraph";
|
| 24 |
+
import { v4 as uuidv4 } from "uuid";
|
| 25 |
+
|
| 26 |
+
import {
|
| 27 |
+
ResearchState,
|
| 28 |
+
ReasoningEntry,
|
| 29 |
+
createInitialState,
|
| 30 |
+
} from "./state";
|
| 31 |
+
|
| 32 |
+
import {
|
| 33 |
+
thinkerNode,
|
| 34 |
+
auditorNode,
|
| 35 |
+
hitlGateNode,
|
| 36 |
+
toolExecutorNode,
|
| 37 |
+
synthesizerNode,
|
| 38 |
+
} from "./nodes";
|
| 39 |
+
|
| 40 |
+
import {
|
| 41 |
+
routeAfterAudit,
|
| 42 |
+
routeAfterHitl,
|
| 43 |
+
routeAfterToolStep,
|
| 44 |
+
} from "./routing";
|
| 45 |
+
|
| 46 |
+
// ─────────────────────────────────────────────────────────────
|
| 47 |
+
// Graph construction
|
| 48 |
+
// ─────────────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Builds and compiles the DeepTrust StateGraph.
|
| 52 |
+
*
|
| 53 |
+
* The `checkpointer` defaults to MemorySaver (in-process, dev-only).
|
| 54 |
+
* Pass a PostgresSaver instance for production persistence.
|
| 55 |
+
*/
|
| 56 |
+
export function buildDeepTrustGraph(
|
| 57 |
+
checkpointer: MemorySaver = new MemorySaver()
|
| 58 |
+
) {
|
| 59 |
+
const graph = new StateGraph<ResearchState>({
|
| 60 |
+
channels: {
|
| 61 |
+
threadId: { value: (_, n) => n },
|
| 62 |
+
sessionName: { value: (_, n) => n },
|
| 63 |
+
userQuery: { value: (_, n) => n },
|
| 64 |
+
knowledgeContext: { value: (_, n) => n },
|
| 65 |
+
contextUrls: { value: (_, n) => n },
|
| 66 |
+
plan: { value: (_, n) => n },
|
| 67 |
+
rejectionFeedback:{ value: (_, n) => n },
|
| 68 |
+
planRevisionCount:{ value: (_, n) => n },
|
| 69 |
+
maxPlanRevisions: { value: (_, n) => n },
|
| 70 |
+
auditResult: { value: (_, n) => n },
|
| 71 |
+
currentStepIndex: { value: (_, n) => n },
|
| 72 |
+
humanApproved: { value: (_, n) => n },
|
| 73 |
+
finalReport: { value: (_, n) => n },
|
| 74 |
+
reasoning: {
|
| 75 |
+
value: (existing: ReasoningEntry[], incoming: ReasoningEntry[]) =>
|
| 76 |
+
[...(existing ?? []), ...(incoming ?? [])],
|
| 77 |
+
default: () => [],
|
| 78 |
+
},
|
| 79 |
+
status: { value: (_, n) => n },
|
| 80 |
+
updatedAt: { value: (_, n) => n },
|
| 81 |
+
errorMessage: { value: (_, n) => n },
|
| 82 |
+
},
|
| 83 |
+
})
|
| 84 |
+
// Nodes
|
| 85 |
+
.addNode("thinker", thinkerNode)
|
| 86 |
+
.addNode("auditor", auditorNode)
|
| 87 |
+
.addNode("hitl_gate", hitlGateNode)
|
| 88 |
+
.addNode("tool_executor", toolExecutorNode)
|
| 89 |
+
.addNode("synthesizer", synthesizerNode)
|
| 90 |
+
|
| 91 |
+
// Edges
|
| 92 |
+
.addEdge(START, "thinker")
|
| 93 |
+
.addEdge("thinker", "auditor")
|
| 94 |
+
|
| 95 |
+
.addConditionalEdges("auditor", routeAfterAudit, {
|
| 96 |
+
thinker: "thinker",
|
| 97 |
+
hitl_gate: "hitl_gate",
|
| 98 |
+
[END]: END,
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
.addConditionalEdges("hitl_gate", routeAfterHitl, {
|
| 102 |
+
tool_executor: "tool_executor",
|
| 103 |
+
[END]: END,
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
.addConditionalEdges("tool_executor", routeAfterToolStep, {
|
| 107 |
+
tool_executor: "tool_executor",
|
| 108 |
+
synthesizer: "synthesizer",
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
.addEdge("synthesizer", END);
|
| 112 |
+
|
| 113 |
+
return graph.compile({ checkpointer, interruptBefore: [] });
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ─────────────────────────────────────────────────────────────
|
| 117 |
+
// Singleton instance
|
| 118 |
+
// ─────────────────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
const checkpointer = new MemorySaver();
|
| 121 |
+
export const deepTrustGraph = buildDeepTrustGraph(checkpointer);
|
| 122 |
+
|
| 123 |
+
// ─────────────────────────────────────────────────────────────
|
| 124 |
+
// Public API
|
| 125 |
+
// ─────���───────────────────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
export interface RunResearchOptions {
|
| 128 |
+
knowledgeContext?: string;
|
| 129 |
+
contextUrls?: string[];
|
| 130 |
+
/**
|
| 131 |
+
* Optional metadata forwarded to LangGraph / LangSmith.
|
| 132 |
+
* Useful for grouping and searching traces.
|
| 133 |
+
*/
|
| 134 |
+
metadata?: Record<string, unknown>;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* Start a new research session and stream events to the caller.
|
| 139 |
+
* Optionally pass retrieved context from client-side RAG (knowledgeContext, contextUrls).
|
| 140 |
+
*
|
| 141 |
+
* @example
|
| 142 |
+
* ```ts
|
| 143 |
+
* for await (const event of runResearch("What caused the 2008 crisis?")) {
|
| 144 |
+
* console.log(event);
|
| 145 |
+
* }
|
| 146 |
+
* for await (const event of runResearch("Summarize my docs", "Session", { knowledgeContext: "..." })) {
|
| 147 |
+
* console.log(event);
|
| 148 |
+
* }
|
| 149 |
+
* ```
|
| 150 |
+
*/
|
| 151 |
+
export async function* runResearch(
|
| 152 |
+
userQuery: string,
|
| 153 |
+
sessionName = "Research Session",
|
| 154 |
+
options: RunResearchOptions = {}
|
| 155 |
+
): AsyncGenerator<{ node: string; state: Partial<ResearchState> }> {
|
| 156 |
+
const initialState = createInitialState({
|
| 157 |
+
threadId: uuidv4(),
|
| 158 |
+
userQuery,
|
| 159 |
+
sessionName,
|
| 160 |
+
knowledgeContext: options.knowledgeContext,
|
| 161 |
+
contextUrls: options.contextUrls,
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
const config = {
|
| 165 |
+
configurable: { thread_id: initialState.threadId },
|
| 166 |
+
metadata: {
|
| 167 |
+
project: process.env.LANGCHAIN_PROJECT ?? "deeptrust",
|
| 168 |
+
run_name: "DeepTrust research session",
|
| 169 |
+
source: "deeptrust-ui",
|
| 170 |
+
...(options.metadata ?? {}),
|
| 171 |
+
},
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
for await (const event of await deepTrustGraph.stream(initialState, config)) {
|
| 175 |
+
// LangGraph interrupts surface under the __interrupt__ key.
|
| 176 |
+
if ("__interrupt__" in event) {
|
| 177 |
+
const interruptInfo = (event as any).__interrupt__;
|
| 178 |
+
yield {
|
| 179 |
+
node: "__interrupt__",
|
| 180 |
+
// Expose threadId so callers can resume the run.
|
| 181 |
+
state: {
|
| 182 |
+
threadId: initialState.threadId,
|
| 183 |
+
interrupt: interruptInfo,
|
| 184 |
+
} as any,
|
| 185 |
+
};
|
| 186 |
+
return;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
for (const [node, state] of Object.entries(event)) {
|
| 190 |
+
yield { node, state: state as Partial<ResearchState> };
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/**
|
| 196 |
+
* Resume a paused session after human approval.
|
| 197 |
+
*
|
| 198 |
+
* The caller passes the threadId and streams the remaining events.
|
| 199 |
+
*/
|
| 200 |
+
export async function* approveAndResume(
|
| 201 |
+
threadId: string
|
| 202 |
+
): AsyncGenerator<{ node: string; state: Partial<ResearchState> }> {
|
| 203 |
+
const config = { configurable: { thread_id: threadId } };
|
| 204 |
+
|
| 205 |
+
await deepTrustGraph.updateState(config, { humanApproved: true });
|
| 206 |
+
|
| 207 |
+
// Resume execution from the last checkpoint.
|
| 208 |
+
const command = new Command({
|
| 209 |
+
resume: { approved: true },
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
for await (const event of await deepTrustGraph.stream(command, config)) {
|
| 213 |
+
for (const [node, state] of Object.entries(event)) {
|
| 214 |
+
yield { node, state: state as Partial<ResearchState> };
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
}
|
lib/agent/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DeepTrust Research Agent — Public API
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
// Graph and public functions
|
| 6 |
+
export {
|
| 7 |
+
buildDeepTrustGraph,
|
| 8 |
+
deepTrustGraph,
|
| 9 |
+
runResearch,
|
| 10 |
+
approveAndResume,
|
| 11 |
+
} from "./graph";
|
| 12 |
+
export type { RunResearchOptions } from "./graph";
|
| 13 |
+
|
| 14 |
+
// State types and helpers
|
| 15 |
+
export {
|
| 16 |
+
ResearchState,
|
| 17 |
+
ResearchPlan,
|
| 18 |
+
ResearchStep,
|
| 19 |
+
AuditResult,
|
| 20 |
+
ReasoningEntry,
|
| 21 |
+
NodeName,
|
| 22 |
+
createInitialState,
|
| 23 |
+
appendReasoning,
|
| 24 |
+
} from "./state";
|
| 25 |
+
|
| 26 |
+
// LLM client (for advanced usage)
|
| 27 |
+
export { chatComplete, loadModel, getModelStatus, MODEL_ID, MODELS } from "./llm";
|
| 28 |
+
export type { ModelProgress, ProgressCallback, ModelOption } from "./llm";
|
| 29 |
+
|
| 30 |
+
// Utilities
|
| 31 |
+
export { extractJSON, loadPolicy } from "./utils";
|
| 32 |
+
|
lib/agent/llm/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LLM Client — server-side inference via a worker thread
|
| 3 |
+
*
|
| 4 |
+
* Pipeline runs in lib/agent/llm/worker-entry (compiled to dist/llm). This module
|
| 5 |
+
* spawns the worker and proxies getModelStatus, loadModel, chatComplete so API routes
|
| 6 |
+
* and the agent keep the same API. Keeps the main Node process non-blocking.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { Worker } from "node:worker_threads";
|
| 10 |
+
import path from "node:path";
|
| 11 |
+
import fs from "node:fs";
|
| 12 |
+
|
| 13 |
+
import type { ModelOption, ModelProgress } from "./pipeline";
|
| 14 |
+
|
| 15 |
+
// Static list for API responses; worker also returns models in getStatus/load payloads
|
| 16 |
+
export type { ModelOption, ModelProgress };
|
| 17 |
+
export const MODEL_ID =
|
| 18 |
+
process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
|
| 19 |
+
|
| 20 |
+
export const MODELS: ModelOption[] = [
|
| 21 |
+
{
|
| 22 |
+
id: "HuggingFaceTB/SmolLM2-135M-Instruct",
|
| 23 |
+
label: "SmolLM2 135M (Q4, tiny)",
|
| 24 |
+
dtype: "q4",
|
| 25 |
+
sizeNote: "~150–200 MB (approx)",
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 29 |
+
label: "SmolLM2 360M (Q4)",
|
| 30 |
+
dtype: "q4",
|
| 31 |
+
sizeNote: "~388 MB",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 35 |
+
label: "SmolLM2 360M (FP16)",
|
| 36 |
+
dtype: "fp16",
|
| 37 |
+
sizeNote: "~725 MB",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 41 |
+
label: "SmolLM2 360M (full)",
|
| 42 |
+
dtype: "fp32",
|
| 43 |
+
sizeNote: "~1.45 GB",
|
| 44 |
+
},
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
export type ProgressCallback = (progress: ModelProgress) => void;
|
| 48 |
+
|
| 49 |
+
let worker: Worker | null = null;
|
| 50 |
+
const pending = new Map<
|
| 51 |
+
string,
|
| 52 |
+
{ resolve: (v: unknown) => void; reject: (e: Error) => void; onProgress?: ProgressCallback }
|
| 53 |
+
>();
|
| 54 |
+
|
| 55 |
+
function getWorkerPath(): string {
|
| 56 |
+
return path.join(process.cwd(), "dist", "llm", "worker-entry.js");
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function getWorker(): Worker {
|
| 60 |
+
if (worker) return worker;
|
| 61 |
+
const workerPath = getWorkerPath();
|
| 62 |
+
if (!fs.existsSync(workerPath)) {
|
| 63 |
+
throw new Error(
|
| 64 |
+
`LLM worker not found at ${workerPath}. Run "npm run build:worker" (or "npm run build") first.`
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
worker = new Worker(workerPath, {
|
| 68 |
+
stdout: true,
|
| 69 |
+
stderr: true,
|
| 70 |
+
});
|
| 71 |
+
worker.on("message", (msg: { id: string; type: string; payload: unknown }) => {
|
| 72 |
+
const entry = pending.get(msg.id);
|
| 73 |
+
if (!entry) return;
|
| 74 |
+
if (msg.type === "progress" && entry.onProgress) {
|
| 75 |
+
entry.onProgress(msg.payload as ModelProgress);
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
pending.delete(msg.id);
|
| 79 |
+
if (msg.type === "resolve") entry.resolve(msg.payload);
|
| 80 |
+
else if (msg.type === "reject") entry.reject(new Error(String(msg.payload)));
|
| 81 |
+
});
|
| 82 |
+
worker.on("error", (err) => {
|
| 83 |
+
for (const [, entry] of pending) entry.reject(err);
|
| 84 |
+
pending.clear();
|
| 85 |
+
});
|
| 86 |
+
worker.on("exit", (code) => {
|
| 87 |
+
if (code !== 0) {
|
| 88 |
+
for (const [, entry] of pending) entry.reject(new Error(`Worker exited with code ${code}`));
|
| 89 |
+
pending.clear();
|
| 90 |
+
}
|
| 91 |
+
worker = null;
|
| 92 |
+
});
|
| 93 |
+
return worker;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function send<T>(type: string, payload: unknown, onProgress?: ProgressCallback): Promise<T> {
|
| 97 |
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
| 98 |
+
return new Promise<T>((resolve, reject) => {
|
| 99 |
+
pending.set(id, { resolve: resolve as (v: unknown) => void, reject, onProgress });
|
| 100 |
+
getWorker().postMessage({ id, type, payload });
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
export type ModelStatusResponse = ModelProgress & { models: ModelOption[] };
|
| 105 |
+
|
| 106 |
+
export function getModelStatus(forModelId?: string, forDtype?: string): Promise<ModelStatusResponse> {
|
| 107 |
+
return send<ModelStatusResponse>("getStatus", { modelId: forModelId, dtype: forDtype });
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/** Resolves when the model is loaded; progress is reported via onProgress. */
|
| 111 |
+
export function loadModel(
|
| 112 |
+
modelId?: string,
|
| 113 |
+
dtype?: ModelOption["dtype"],
|
| 114 |
+
onProgress?: ProgressCallback
|
| 115 |
+
): Promise<ModelStatusResponse> {
|
| 116 |
+
return send<ModelStatusResponse>("load", { modelId, dtype }, onProgress);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
export async function chatComplete(systemPrompt: string, userMessage: string): Promise<string> {
|
| 120 |
+
return send<string>("chat", { systemPrompt, userMessage });
|
| 121 |
+
}
|
lib/agent/llm/pipeline.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Pipeline — runs inside the LLM worker thread only.
|
| 3 |
+
* Hugging Face Transformers + onnxruntime-node; state lives in this thread.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { pipeline, TextGenerationPipeline, env } from "@huggingface/transformers";
|
| 7 |
+
import path from "node:path";
|
| 8 |
+
|
| 9 |
+
env.cacheDir =
|
| 10 |
+
process.env.HF_CACHE_DIR ||
|
| 11 |
+
path.join(process.cwd(), ".hf-cache");
|
| 12 |
+
|
| 13 |
+
// Default to a very small instruct model for low-spec machines.
|
| 14 |
+
// If this model ID does not exist or you prefer another tiny model,
|
| 15 |
+
// set HF_MODEL in your environment to override.
|
| 16 |
+
export const MODEL_ID =
|
| 17 |
+
process.env.HF_MODEL || "HuggingFaceTB/SmolLM2-135M-Instruct";
|
| 18 |
+
|
| 19 |
+
export interface ModelOption {
|
| 20 |
+
id: string;
|
| 21 |
+
label: string;
|
| 22 |
+
dtype?: "q4" | "fp16" | "fp32";
|
| 23 |
+
sizeNote?: string;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export const MODELS: ModelOption[] = [
|
| 27 |
+
{
|
| 28 |
+
id: "HuggingFaceTB/SmolLM2-135M-Instruct",
|
| 29 |
+
label: "SmolLM2 135M (Q4, tiny)",
|
| 30 |
+
dtype: "q4",
|
| 31 |
+
sizeNote: "~150–200 MB (approx)",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 35 |
+
label: "SmolLM2 360M (Q4)",
|
| 36 |
+
dtype: "q4",
|
| 37 |
+
sizeNote: "~388 MB",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 41 |
+
label: "SmolLM2 360M (FP16)",
|
| 42 |
+
dtype: "fp16",
|
| 43 |
+
sizeNote: "~725 MB",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
id: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
| 47 |
+
label: "SmolLM2 360M (full)",
|
| 48 |
+
dtype: "fp32",
|
| 49 |
+
sizeNote: "~1.45 GB",
|
| 50 |
+
},
|
| 51 |
+
];
|
| 52 |
+
|
| 53 |
+
let currentModelId = MODELS[0].id;
|
| 54 |
+
let currentDtype = MODELS[0].dtype;
|
| 55 |
+
let generatorPromise: Promise<TextGenerationPipeline> | null = null;
|
| 56 |
+
let isModelLoaded = false;
|
| 57 |
+
let currentProgress = 0;
|
| 58 |
+
let currentStatus = "idle";
|
| 59 |
+
let currentFile = "";
|
| 60 |
+
|
| 61 |
+
export interface ModelProgress {
|
| 62 |
+
status: "idle" | "loading" | "downloading" | "ready" | "error";
|
| 63 |
+
progress: number;
|
| 64 |
+
file: string;
|
| 65 |
+
message: string;
|
| 66 |
+
modelId?: string;
|
| 67 |
+
dtype?: string;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function getModelStatus(forModelId?: string, forDtype?: string): ModelProgress {
|
| 71 |
+
const isOther = forModelId !== undefined && (forModelId !== currentModelId || forDtype !== currentDtype);
|
| 72 |
+
if (isOther) {
|
| 73 |
+
return {
|
| 74 |
+
status: "idle",
|
| 75 |
+
progress: 0,
|
| 76 |
+
file: "",
|
| 77 |
+
message: "Model not loaded",
|
| 78 |
+
modelId: forModelId,
|
| 79 |
+
dtype: forDtype,
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
if (isModelLoaded) {
|
| 83 |
+
return {
|
| 84 |
+
status: "ready",
|
| 85 |
+
progress: 100,
|
| 86 |
+
file: "",
|
| 87 |
+
message: "Model ready",
|
| 88 |
+
modelId: currentModelId,
|
| 89 |
+
dtype: currentDtype,
|
| 90 |
+
};
|
| 91 |
+
}
|
| 92 |
+
if (generatorPromise) {
|
| 93 |
+
return {
|
| 94 |
+
status: currentStatus as ModelProgress["status"],
|
| 95 |
+
progress: currentProgress,
|
| 96 |
+
file: currentFile,
|
| 97 |
+
message: currentFile ? `Downloading ${currentFile}` : "Loading model...",
|
| 98 |
+
modelId: currentModelId,
|
| 99 |
+
dtype: currentDtype,
|
| 100 |
+
};
|
| 101 |
+
}
|
| 102 |
+
return {
|
| 103 |
+
status: "idle",
|
| 104 |
+
progress: 0,
|
| 105 |
+
file: "",
|
| 106 |
+
message: "Model not loaded",
|
| 107 |
+
modelId: currentModelId,
|
| 108 |
+
dtype: currentDtype,
|
| 109 |
+
};
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export type ProgressCallback = (progress: ModelProgress) => void;
|
| 113 |
+
|
| 114 |
+
export function loadModel(
|
| 115 |
+
modelId?: string,
|
| 116 |
+
dtype?: ModelOption["dtype"],
|
| 117 |
+
onProgress?: ProgressCallback
|
| 118 |
+
): Promise<TextGenerationPipeline> {
|
| 119 |
+
const nextId = modelId ?? currentModelId;
|
| 120 |
+
const nextDtype = dtype ?? currentDtype;
|
| 121 |
+
|
| 122 |
+
if (nextId !== currentModelId || nextDtype !== currentDtype) {
|
| 123 |
+
generatorPromise = null;
|
| 124 |
+
isModelLoaded = false;
|
| 125 |
+
currentModelId = nextId;
|
| 126 |
+
currentDtype = nextDtype;
|
| 127 |
+
currentStatus = "idle";
|
| 128 |
+
currentProgress = 0;
|
| 129 |
+
currentFile = "";
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (generatorPromise) {
|
| 133 |
+
return generatorPromise;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
console.log(`\n🔄 [worker] Loading model: ${currentModelId}${currentDtype ? ` (${currentDtype})` : ""}`);
|
| 137 |
+
console.log(` Cache directory: ${env.cacheDir}\n`);
|
| 138 |
+
|
| 139 |
+
currentStatus = "loading";
|
| 140 |
+
const startTime = Date.now();
|
| 141 |
+
|
| 142 |
+
const pipelineOptions: Parameters<typeof pipeline>[2] = {
|
| 143 |
+
progress_callback: (progressData: {
|
| 144 |
+
status: string;
|
| 145 |
+
name?: string;
|
| 146 |
+
file?: string;
|
| 147 |
+
loaded?: number;
|
| 148 |
+
total?: number;
|
| 149 |
+
progress?: number;
|
| 150 |
+
}) => {
|
| 151 |
+
currentStatus = progressData.status === "progress" ? "downloading" : "loading";
|
| 152 |
+
currentFile = progressData.file || progressData.name || "";
|
| 153 |
+
currentProgress = Math.round(progressData.progress ?? 0);
|
| 154 |
+
|
| 155 |
+
const update: ModelProgress = {
|
| 156 |
+
status: currentStatus as ModelProgress["status"],
|
| 157 |
+
progress: currentProgress,
|
| 158 |
+
file: currentFile,
|
| 159 |
+
message: currentFile
|
| 160 |
+
? `Downloading ${currentFile.split("/").pop()} (${currentProgress}%)`
|
| 161 |
+
: `${progressData.status}...`,
|
| 162 |
+
modelId: currentModelId,
|
| 163 |
+
dtype: currentDtype,
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
console.log(` ${update.message}`);
|
| 167 |
+
onProgress?.(update);
|
| 168 |
+
},
|
| 169 |
+
};
|
| 170 |
+
if (currentDtype) {
|
| 171 |
+
(pipelineOptions as Record<string, unknown>).dtype = currentDtype;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const pipelinePromise = pipeline("text-generation", currentModelId, pipelineOptions);
|
| 175 |
+
generatorPromise = (pipelinePromise as Promise<TextGenerationPipeline>)
|
| 176 |
+
.then((gen) => {
|
| 177 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 178 |
+
console.log(`\n✅ [worker] Model loaded in ${elapsed}s\n`);
|
| 179 |
+
isModelLoaded = true;
|
| 180 |
+
currentStatus = "ready";
|
| 181 |
+
currentProgress = 100;
|
| 182 |
+
onProgress?.({
|
| 183 |
+
status: "ready",
|
| 184 |
+
progress: 100,
|
| 185 |
+
file: "",
|
| 186 |
+
message: "Model ready",
|
| 187 |
+
modelId: currentModelId,
|
| 188 |
+
dtype: currentDtype,
|
| 189 |
+
});
|
| 190 |
+
return gen;
|
| 191 |
+
})
|
| 192 |
+
.catch((err) => {
|
| 193 |
+
currentStatus = "error";
|
| 194 |
+
generatorPromise = null;
|
| 195 |
+
onProgress?.({
|
| 196 |
+
status: "error",
|
| 197 |
+
progress: 0,
|
| 198 |
+
file: "",
|
| 199 |
+
message: err.message,
|
| 200 |
+
modelId: currentModelId,
|
| 201 |
+
dtype: currentDtype,
|
| 202 |
+
});
|
| 203 |
+
throw err;
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
return generatorPromise;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
export async function chatComplete(
|
| 210 |
+
systemPrompt: string,
|
| 211 |
+
userMessage: string
|
| 212 |
+
): Promise<string> {
|
| 213 |
+
const generator = await loadModel();
|
| 214 |
+
|
| 215 |
+
const messages = [
|
| 216 |
+
{ role: "system", content: systemPrompt },
|
| 217 |
+
{ role: "user", content: userMessage },
|
| 218 |
+
];
|
| 219 |
+
|
| 220 |
+
const preview = userMessage.slice(0, 60).replace(/\n/g, " ");
|
| 221 |
+
console.log(`🤖 [worker] Generating response for: "${preview}..."`);
|
| 222 |
+
const startTime = Date.now();
|
| 223 |
+
|
| 224 |
+
const output = await generator(messages, {
|
| 225 |
+
max_new_tokens: 4096,
|
| 226 |
+
do_sample: true,
|
| 227 |
+
temperature: 0.7,
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 231 |
+
|
| 232 |
+
const result = output[0] as { generated_text: Array<{ role: string; content: string }> };
|
| 233 |
+
const assistantMessage = result.generated_text.find(
|
| 234 |
+
(msg) => msg.role === "assistant"
|
| 235 |
+
);
|
| 236 |
+
|
| 237 |
+
if (!assistantMessage) {
|
| 238 |
+
throw new Error("No assistant response generated");
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
console.log(`✅ [worker] Generated ${assistantMessage.content.length} chars in ${elapsed}s`);
|
| 242 |
+
return assistantMessage.content;
|
| 243 |
+
}
|
lib/agent/llm/worker-entry.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Worker entry — runs in a Node worker_thread. Handles getStatus, load, chat and forwards progress.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { parentPort } from "node:worker_threads";
|
| 6 |
+
import {
|
| 7 |
+
getModelStatus,
|
| 8 |
+
loadModel,
|
| 9 |
+
chatComplete,
|
| 10 |
+
MODELS,
|
| 11 |
+
type ModelProgress,
|
| 12 |
+
type ModelOption,
|
| 13 |
+
} from "./pipeline";
|
| 14 |
+
|
| 15 |
+
type Incoming =
|
| 16 |
+
| { id: string; type: "getStatus"; payload: { modelId?: string; dtype?: string } }
|
| 17 |
+
| { id: string; type: "load"; payload: { modelId?: string; dtype?: ModelOption["dtype"] } }
|
| 18 |
+
| { id: string; type: "chat"; payload: { systemPrompt: string; userMessage: string } };
|
| 19 |
+
|
| 20 |
+
type Outgoing =
|
| 21 |
+
| { id: string; type: "resolve"; payload: unknown }
|
| 22 |
+
| { id: string; type: "progress"; payload: ModelProgress & { models: ModelOption[] } }
|
| 23 |
+
| { id: string; type: "reject"; payload: string };
|
| 24 |
+
|
| 25 |
+
function reply(msg: Outgoing) {
|
| 26 |
+
parentPort?.postMessage(msg);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
parentPort?.on("message", (msg: Incoming) => {
|
| 30 |
+
const { id, type, payload } = msg;
|
| 31 |
+
|
| 32 |
+
(async () => {
|
| 33 |
+
try {
|
| 34 |
+
switch (type) {
|
| 35 |
+
case "getStatus": {
|
| 36 |
+
const status = getModelStatus(payload.modelId, payload.dtype);
|
| 37 |
+
reply({ id, type: "resolve", payload: { ...status, models: MODELS } });
|
| 38 |
+
break;
|
| 39 |
+
}
|
| 40 |
+
case "load": {
|
| 41 |
+
await loadModel(payload.modelId, payload.dtype, (progress) => {
|
| 42 |
+
reply({
|
| 43 |
+
id,
|
| 44 |
+
type: "progress",
|
| 45 |
+
payload: { ...progress, models: MODELS },
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
const status = getModelStatus(payload.modelId, payload.dtype);
|
| 49 |
+
reply({ id, type: "resolve", payload: { ...status, models: MODELS } });
|
| 50 |
+
break;
|
| 51 |
+
}
|
| 52 |
+
case "chat": {
|
| 53 |
+
const text = await chatComplete(payload.systemPrompt, payload.userMessage);
|
| 54 |
+
reply({ id, type: "resolve", payload: text });
|
| 55 |
+
break;
|
| 56 |
+
}
|
| 57 |
+
default:
|
| 58 |
+
reply({ id, type: "reject", payload: `Unknown message type: ${(msg as { type: string }).type}` });
|
| 59 |
+
}
|
| 60 |
+
} catch (err) {
|
| 61 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 62 |
+
reply({ id, type: "reject", payload: message });
|
| 63 |
+
}
|
| 64 |
+
})();
|
| 65 |
+
});
|
lib/agent/nodes/auditor.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Auditor Node — validates plans against organizational policy
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { chatComplete } from "../llm";
|
| 6 |
+
import { extractJSON, loadPolicy } from "../utils";
|
| 7 |
+
import { ResearchState, AuditResult, appendReasoning } from "../state";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Compares the current plan against POLICY.md.
|
| 11 |
+
* Sets `auditResult` with verdict + structured feedback.
|
| 12 |
+
* If rejected, also sets `rejectionFeedback` and increments
|
| 13 |
+
* `planRevisionCount` so the router can loop back to thinker.
|
| 14 |
+
*/
|
| 15 |
+
export async function auditorNode(
|
| 16 |
+
state: ResearchState
|
| 17 |
+
): Promise<Partial<ResearchState>> {
|
| 18 |
+
if (!state.plan) {
|
| 19 |
+
throw new Error("auditorNode called with no plan in state");
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const policy = loadPolicy();
|
| 23 |
+
|
| 24 |
+
const system = `
|
| 25 |
+
You are the Auditor node of DeepTrust.
|
| 26 |
+
You evaluate research plans against an organisational policy and return a structured verdict.
|
| 27 |
+
|
| 28 |
+
Return ONLY a valid JSON object matching:
|
| 29 |
+
{
|
| 30 |
+
"verdict": "approved" | "rejected" | "needs_revision",
|
| 31 |
+
"policyViolations": string[],
|
| 32 |
+
"suggestions": string[],
|
| 33 |
+
"auditedAt": string (ISO 8601)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
Rules:
|
| 37 |
+
- "approved" means the plan fully complies with policy.
|
| 38 |
+
- "rejected" means the plan has hard violations that cannot be patched.
|
| 39 |
+
- "needs_revision" means soft issues exist but the plan is salvageable.
|
| 40 |
+
- Treat "needs_revision" as rejection for routing purposes.
|
| 41 |
+
- Do not include markdown fences or any prose outside the JSON object.
|
| 42 |
+
`.trim();
|
| 43 |
+
|
| 44 |
+
const userMessage = `
|
| 45 |
+
POLICY:
|
| 46 |
+
${policy}
|
| 47 |
+
|
| 48 |
+
PLAN TO AUDIT:
|
| 49 |
+
${JSON.stringify(state.plan, null, 2)}
|
| 50 |
+
`.trim();
|
| 51 |
+
|
| 52 |
+
const rawThought = await chatComplete(system, userMessage);
|
| 53 |
+
|
| 54 |
+
let parsed: unknown;
|
| 55 |
+
try {
|
| 56 |
+
parsed = extractJSON(rawThought);
|
| 57 |
+
} catch {
|
| 58 |
+
throw new Error(`Auditor produced non-JSON output: ${rawThought.slice(0, 300)}`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const auditResult = AuditResult.parse({
|
| 62 |
+
...(parsed as object),
|
| 63 |
+
auditedAt: new Date().toISOString(),
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const isRejected = auditResult.verdict !== "approved";
|
| 67 |
+
|
| 68 |
+
const rejectionFeedback: string | null = isRejected
|
| 69 |
+
? [
|
| 70 |
+
`Verdict: ${auditResult.verdict}`,
|
| 71 |
+
auditResult.policyViolations.length
|
| 72 |
+
? `Violations:\n${auditResult.policyViolations.map((v) => ` - ${v}`).join("\n")}`
|
| 73 |
+
: null,
|
| 74 |
+
auditResult.suggestions.length
|
| 75 |
+
? `Suggestions:\n${auditResult.suggestions.map((s) => ` - ${s}`).join("\n")}`
|
| 76 |
+
: null,
|
| 77 |
+
]
|
| 78 |
+
.filter(Boolean)
|
| 79 |
+
.join("\n\n")
|
| 80 |
+
: null;
|
| 81 |
+
|
| 82 |
+
const reasoning = appendReasoning(state, {
|
| 83 |
+
node: "auditor",
|
| 84 |
+
summary: isRejected
|
| 85 |
+
? `Plan REJECTED (${auditResult.verdict}). ${auditResult.policyViolations.length} violation(s) found.`
|
| 86 |
+
: "Plan APPROVED — no policy violations detected.",
|
| 87 |
+
rawThought,
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
auditResult,
|
| 92 |
+
rejectionFeedback,
|
| 93 |
+
planRevisionCount: isRejected
|
| 94 |
+
? state.planRevisionCount + 1
|
| 95 |
+
: state.planRevisionCount,
|
| 96 |
+
status: isRejected ? "thinking" : "awaiting_approval",
|
| 97 |
+
reasoning,
|
| 98 |
+
updatedAt: new Date().toISOString(),
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
lib/agent/nodes/hitl-gate.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* HITL Gate Node — Human-in-the-Loop interrupt point
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { interrupt } from "@langchain/langgraph";
|
| 6 |
+
import { ResearchState, appendReasoning } from "../state";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Pauses the graph and surfaces the plan to the operator.
|
| 10 |
+
* When the graph is resumed (via .updateState()), the caller
|
| 11 |
+
* must set `state.humanApproved = true` to proceed.
|
| 12 |
+
*
|
| 13 |
+
* Using LangGraph's `interrupt()` primitive ensures the
|
| 14 |
+
* checkpoint is written BEFORE the interrupt fires, so the
|
| 15 |
+
* session can be resumed from any client or process.
|
| 16 |
+
*/
|
| 17 |
+
export async function hitlGateNode(
|
| 18 |
+
state: ResearchState
|
| 19 |
+
): Promise<Partial<ResearchState>> {
|
| 20 |
+
const reasoning = appendReasoning(state, {
|
| 21 |
+
node: "thinker", // logged under thinker namespace for UI grouping
|
| 22 |
+
summary: "⏸ HITL gate: awaiting human approval before tool execution.",
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// This call writes a checkpoint and suspends until the graph
|
| 26 |
+
// is resumed externally. The value passed to interrupt() is
|
| 27 |
+
// surfaced to the caller of `graph.stream()`.
|
| 28 |
+
interrupt({
|
| 29 |
+
message: "Plan ready for review. Set humanApproved=true to continue.",
|
| 30 |
+
plan: state.plan,
|
| 31 |
+
auditResult: state.auditResult,
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
// Code below only runs after the interrupt is resolved.
|
| 35 |
+
return {
|
| 36 |
+
reasoning,
|
| 37 |
+
updatedAt: new Date().toISOString(),
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
lib/agent/nodes/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { thinkerNode } from "./thinker";
|
| 2 |
+
export { auditorNode } from "./auditor";
|
| 3 |
+
export { hitlGateNode } from "./hitl-gate";
|
| 4 |
+
export { toolExecutorNode } from "./tool-executor";
|
| 5 |
+
export { synthesizerNode } from "./synthesizer";
|
| 6 |
+
|
lib/agent/nodes/synthesizer.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Synthesizer Node — produces the final research report
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { chatComplete } from "../llm";
|
| 6 |
+
import { ResearchState, appendReasoning } from "../state";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Reads all step outputs and produces the final research report.
|
| 10 |
+
*/
|
| 11 |
+
export async function synthesizerNode(
|
| 12 |
+
state: ResearchState
|
| 13 |
+
): Promise<Partial<ResearchState>> {
|
| 14 |
+
if (!state.plan) throw new Error("synthesizerNode called with no plan");
|
| 15 |
+
|
| 16 |
+
const system = `
|
| 17 |
+
You are the Synthesizer node of DeepTrust.
|
| 18 |
+
You receive a research plan with all step outputs filled in,
|
| 19 |
+
and you write a comprehensive, well-structured research report.
|
| 20 |
+
|
| 21 |
+
Guidelines:
|
| 22 |
+
- Lead with an executive summary.
|
| 23 |
+
- Organise findings by theme, not by tool execution order.
|
| 24 |
+
- Cite which step produced each finding.
|
| 25 |
+
- End with actionable conclusions.
|
| 26 |
+
`.trim();
|
| 27 |
+
|
| 28 |
+
const stepsContext = state.plan.steps
|
| 29 |
+
.map(
|
| 30 |
+
(s, i) =>
|
| 31 |
+
`Step ${i + 1} [${s.tool}] — "${s.input}"\nOutput: ${s.output ?? "(no output)"}`
|
| 32 |
+
)
|
| 33 |
+
.join("\n\n---\n\n");
|
| 34 |
+
|
| 35 |
+
const knowledgeBlock =
|
| 36 |
+
state.knowledgeContext?.trim() ?
|
| 37 |
+
`
|
| 38 |
+
|
| 39 |
+
User's local knowledge (use to ground or cite the report where relevant):
|
| 40 |
+
${state.knowledgeContext}
|
| 41 |
+
`
|
| 42 |
+
: "";
|
| 43 |
+
|
| 44 |
+
const userMessage = `
|
| 45 |
+
Objective: ${state.plan.objective}
|
| 46 |
+
|
| 47 |
+
Research results:
|
| 48 |
+
${stepsContext}
|
| 49 |
+
${knowledgeBlock}
|
| 50 |
+
`.trim();
|
| 51 |
+
|
| 52 |
+
const finalReport = await chatComplete(system, userMessage);
|
| 53 |
+
|
| 54 |
+
const reasoning = appendReasoning(state, {
|
| 55 |
+
node: "synthesizer",
|
| 56 |
+
summary: `Final report synthesized (${finalReport.length} chars).`,
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
finalReport,
|
| 61 |
+
status: "complete",
|
| 62 |
+
reasoning,
|
| 63 |
+
updatedAt: new Date().toISOString(),
|
| 64 |
+
};
|
| 65 |
+
}
|
| 66 |
+
|
lib/agent/nodes/thinker.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Thinker Node — produces or revises a structured ResearchPlan
|
| 3 |
+
*
|
| 4 |
+
* Uses safeParse + retry with Zod error feedback so that invalid LLM output
|
| 5 |
+
* (e.g. missing fields, wrong types) is corrected by the model instead of
|
| 6 |
+
* causing a 500. After max retries, throws so the route can stream an error event.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { chatComplete } from "../llm";
|
| 10 |
+
import { extractJSON } from "../utils";
|
| 11 |
+
import { ResearchState, ResearchPlan, appendReasoning } from "../state";
|
| 12 |
+
import { v4 as uuidv4 } from "uuid";
|
| 13 |
+
|
| 14 |
+
const MAX_PLAN_ATTEMPTS = 3;
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Format Zod errors for inclusion in the next prompt so the LLM can self-correct.
|
| 18 |
+
*/
|
| 19 |
+
function formatZodErrors(issues: { path: unknown[]; message: string }[]): string {
|
| 20 |
+
return issues
|
| 21 |
+
.map((i) => ` - ${i.path.map((p) => String(p)).join(".")}: ${i.message}`)
|
| 22 |
+
.join("\n");
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const UUID_REGEX =
|
| 26 |
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Normalize LLM output so it passes ResearchPlan: fix non-UUID step ids and
|
| 30 |
+
* missing rationales (small/local models often omit these).
|
| 31 |
+
*/
|
| 32 |
+
function normalizePlan(raw: Record<string, unknown>): Record<string, unknown> {
|
| 33 |
+
const steps = Array.isArray(raw.steps) ? raw.steps : [];
|
| 34 |
+
const normalizedSteps = steps.map((s: unknown, i: number) => {
|
| 35 |
+
const step = typeof s === "object" && s !== null ? (s as Record<string, unknown>) : {};
|
| 36 |
+
const id =
|
| 37 |
+
typeof step.id === "string" && UUID_REGEX.test(step.id) ? step.id : uuidv4();
|
| 38 |
+
const rationale =
|
| 39 |
+
typeof step.rationale === "string" && step.rationale.length > 0
|
| 40 |
+
? step.rationale
|
| 41 |
+
: typeof step.input === "string"
|
| 42 |
+
? step.input
|
| 43 |
+
: `Step ${i + 1}`;
|
| 44 |
+
return {
|
| 45 |
+
...step,
|
| 46 |
+
id,
|
| 47 |
+
rationale,
|
| 48 |
+
input: typeof step.input === "string" ? step.input : "",
|
| 49 |
+
tool: step.tool ?? "web_search",
|
| 50 |
+
};
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
const createdAt =
|
| 54 |
+
typeof raw.createdAt === "string" && raw.createdAt.length > 0
|
| 55 |
+
? raw.createdAt
|
| 56 |
+
: new Date().toISOString();
|
| 57 |
+
const estimatedTokenBudget =
|
| 58 |
+
typeof raw.estimatedTokenBudget === "number" && raw.estimatedTokenBudget > 0
|
| 59 |
+
? raw.estimatedTokenBudget
|
| 60 |
+
: 2048;
|
| 61 |
+
|
| 62 |
+
const objective =
|
| 63 |
+
typeof raw.objective === "string" && raw.objective.trim().length > 0
|
| 64 |
+
? raw.objective.trim()
|
| 65 |
+
: "Research objective";
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
...raw,
|
| 69 |
+
objective,
|
| 70 |
+
steps: normalizedSteps,
|
| 71 |
+
createdAt,
|
| 72 |
+
estimatedTokenBudget,
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Produces (or revises) a structured ResearchPlan.
|
| 78 |
+
* When `state.rejectionFeedback` is set, the model receives the
|
| 79 |
+
* Auditor's critique and is instructed to produce a corrected plan.
|
| 80 |
+
*
|
| 81 |
+
* If the LLM returns invalid JSON or fails schema validation, we retry up to
|
| 82 |
+
* MAX_PLAN_ATTEMPTS times, feeding the validation errors back into the prompt.
|
| 83 |
+
*/
|
| 84 |
+
export async function thinkerNode(
|
| 85 |
+
state: ResearchState
|
| 86 |
+
): Promise<Partial<ResearchState>> {
|
| 87 |
+
const isRevision = state.planRevisionCount > 0 && state.rejectionFeedback;
|
| 88 |
+
|
| 89 |
+
const system = `
|
| 90 |
+
You are the Thinker node of DeepTrust, an autonomous research agent.
|
| 91 |
+
Your sole job is to decompose a research question into a concrete, step-by-step plan.
|
| 92 |
+
|
| 93 |
+
Return ONLY a valid JSON object that matches this TypeScript type:
|
| 94 |
+
{
|
| 95 |
+
"objective": string,
|
| 96 |
+
"steps": Array<{
|
| 97 |
+
"id": string (UUID v4),
|
| 98 |
+
"tool": "web_search" | "document_fetch" | "code_interpreter" | "summarize",
|
| 99 |
+
"input": string,
|
| 100 |
+
"rationale": string
|
| 101 |
+
}>,
|
| 102 |
+
"estimatedTokenBudget": number,
|
| 103 |
+
"createdAt": string (ISO 8601),
|
| 104 |
+
"revision": number
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
Rules:
|
| 108 |
+
- Maximum 20 steps.
|
| 109 |
+
- Every step must have a clear rationale.
|
| 110 |
+
- Do not include markdown fences or any prose outside the JSON object.
|
| 111 |
+
`.trim();
|
| 112 |
+
|
| 113 |
+
let revisionContext = isRevision
|
| 114 |
+
? `\n\nPREVIOUS PLAN WAS REJECTED. Auditor feedback:\n${state.rejectionFeedback}\n\nRevision #${state.planRevisionCount + 1}: Produce a corrected plan.`
|
| 115 |
+
: "";
|
| 116 |
+
|
| 117 |
+
let knowledgeBlock = "";
|
| 118 |
+
if (state.knowledgeContext?.trim()) {
|
| 119 |
+
knowledgeBlock = `\n\nThe user provided the following retrieved context from their local knowledge base. Use it to inform the plan and prefer steps that leverage this context where relevant:\n\n${state.knowledgeContext}`;
|
| 120 |
+
}
|
| 121 |
+
if (state.contextUrls?.length) {
|
| 122 |
+
knowledgeBlock += `\n\nThe user also referenced these URLs (consider document_fetch steps for them where useful): ${state.contextUrls.join(", ")}`;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
let userMessage = `Research question: "${state.userQuery}"${revisionContext}${knowledgeBlock}`;
|
| 126 |
+
|
| 127 |
+
let lastRawThought: string | null = null;
|
| 128 |
+
let lastParseError: string | null = null;
|
| 129 |
+
|
| 130 |
+
for (let attempt = 1; attempt <= MAX_PLAN_ATTEMPTS; attempt++) {
|
| 131 |
+
const parseFeedback =
|
| 132 |
+
lastParseError &&
|
| 133 |
+
`\n\nYour previous response had validation errors. Fix them and return ONLY valid JSON:\n${lastParseError}`;
|
| 134 |
+
|
| 135 |
+
const rawThought = await chatComplete(
|
| 136 |
+
system,
|
| 137 |
+
userMessage + (parseFeedback ?? "")
|
| 138 |
+
);
|
| 139 |
+
lastRawThought = rawThought;
|
| 140 |
+
|
| 141 |
+
let parsed: unknown;
|
| 142 |
+
try {
|
| 143 |
+
parsed = extractJSON(rawThought);
|
| 144 |
+
} catch {
|
| 145 |
+
lastParseError = `Could not parse as JSON. Output started with: ${rawThought.slice(0, 200)}`;
|
| 146 |
+
continue;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const normalized = normalizePlan(
|
| 150 |
+
typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
| 151 |
+
);
|
| 152 |
+
const withRevision = {
|
| 153 |
+
...normalized,
|
| 154 |
+
revision: state.planRevisionCount,
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
const result = ResearchPlan.safeParse(withRevision);
|
| 158 |
+
if (result.success) {
|
| 159 |
+
const plan = result.data;
|
| 160 |
+
const reasoning = appendReasoning(state, {
|
| 161 |
+
node: "thinker",
|
| 162 |
+
summary: isRevision
|
| 163 |
+
? `Revised plan (attempt ${state.planRevisionCount + 1}): ${plan.steps.length} steps for "${plan.objective}"`
|
| 164 |
+
: `Initial plan created: ${plan.steps.length} steps for "${plan.objective}"`,
|
| 165 |
+
rawThought,
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
plan,
|
| 170 |
+
status: "thinking",
|
| 171 |
+
rejectionFeedback: null,
|
| 172 |
+
reasoning,
|
| 173 |
+
updatedAt: new Date().toISOString(),
|
| 174 |
+
};
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
lastParseError = formatZodErrors(result.error.issues);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Fallback: if the model failed to produce valid JSON after all attempts,
|
| 181 |
+
// synthesize a minimal but valid single-step plan directly from the user query
|
| 182 |
+
// so the run can still proceed instead of hard-failing.
|
| 183 |
+
const fallbackPlanResult = ResearchPlan.safeParse({
|
| 184 |
+
objective:
|
| 185 |
+
typeof state.userQuery === "string" && state.userQuery.trim().length > 0
|
| 186 |
+
? state.userQuery.trim()
|
| 187 |
+
: "Research objective",
|
| 188 |
+
steps: [
|
| 189 |
+
{
|
| 190 |
+
id: uuidv4(),
|
| 191 |
+
tool: "web_search",
|
| 192 |
+
input:
|
| 193 |
+
typeof state.userQuery === "string" && state.userQuery.trim().length > 0
|
| 194 |
+
? state.userQuery.trim()
|
| 195 |
+
: "Initial research query",
|
| 196 |
+
rationale:
|
| 197 |
+
typeof lastRawThought === "string" && lastRawThought.trim().length > 0
|
| 198 |
+
? lastRawThought.slice(0, 500)
|
| 199 |
+
: "Initial search to understand the question.",
|
| 200 |
+
},
|
| 201 |
+
],
|
| 202 |
+
estimatedTokenBudget: 2048,
|
| 203 |
+
createdAt: new Date().toISOString(),
|
| 204 |
+
revision: state.planRevisionCount,
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
if (fallbackPlanResult.success) {
|
| 208 |
+
const plan = fallbackPlanResult.data;
|
| 209 |
+
const reasoning = appendReasoning(state, {
|
| 210 |
+
node: "thinker",
|
| 211 |
+
summary: `Fallback plan created after ${MAX_PLAN_ATTEMPTS} failed JSON attempts: ${plan.steps.length} step for "${plan.objective}"`,
|
| 212 |
+
rawThought: lastRawThought ?? undefined,
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
return {
|
| 216 |
+
plan,
|
| 217 |
+
status: "thinking",
|
| 218 |
+
rejectionFeedback: null,
|
| 219 |
+
reasoning,
|
| 220 |
+
updatedAt: new Date().toISOString(),
|
| 221 |
+
};
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
throw new Error(
|
| 225 |
+
`Thinker failed to produce a valid plan after ${MAX_PLAN_ATTEMPTS} attempts. Last validation errors:\n${lastParseError ?? "unknown"}\n\nLast output (excerpt): ${(lastRawThought ?? "").slice(0, 400)}`
|
| 226 |
+
);
|
| 227 |
+
}
|
| 228 |
+
|
lib/agent/nodes/tool-executor.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tool Executor Node — executes research plan steps
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { ResearchState, ResearchPlan, ResearchStep, appendReasoning } from "../state";
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Executes one step at a time (the step at `currentStepIndex`).
|
| 9 |
+
* After each execution the graph loops back through the router;
|
| 10 |
+
* when all steps are complete it advances to synthesizer.
|
| 11 |
+
*
|
| 12 |
+
* Real tool integrations (Tavily, Playwright, etc.) replace the
|
| 13 |
+
* stub `dispatchTool` function.
|
| 14 |
+
*/
|
| 15 |
+
export async function toolExecutorNode(
|
| 16 |
+
state: ResearchState
|
| 17 |
+
): Promise<Partial<ResearchState>> {
|
| 18 |
+
if (!state.plan) throw new Error("toolExecutorNode called with no plan");
|
| 19 |
+
|
| 20 |
+
const step = state.plan.steps[state.currentStepIndex];
|
| 21 |
+
if (!step) throw new Error(`No step at index ${state.currentStepIndex}`);
|
| 22 |
+
|
| 23 |
+
// Dispatch the tool
|
| 24 |
+
const output = await dispatchTool(step.tool, step.input);
|
| 25 |
+
|
| 26 |
+
// Persist the output back into the plan's steps array immutably
|
| 27 |
+
const updatedSteps = state.plan.steps.map((s, i) =>
|
| 28 |
+
i === state.currentStepIndex
|
| 29 |
+
? { ...s, output, executedAt: new Date().toISOString() }
|
| 30 |
+
: s
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
const updatedPlan = ResearchPlan.parse({
|
| 34 |
+
...state.plan,
|
| 35 |
+
steps: updatedSteps,
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
const reasoning = appendReasoning(state, {
|
| 39 |
+
node: "tool_executor",
|
| 40 |
+
summary: `Executed step ${state.currentStepIndex + 1}/${state.plan.steps.length}: [${step.tool}] "${step.input.slice(0, 80)}…"`,
|
| 41 |
+
rawThought: output,
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
plan: updatedPlan,
|
| 46 |
+
currentStepIndex: state.currentStepIndex + 1,
|
| 47 |
+
status: "executing",
|
| 48 |
+
reasoning,
|
| 49 |
+
updatedAt: new Date().toISOString(),
|
| 50 |
+
};
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Dispatch a tool call. Replace stubs with real implementations.
|
| 55 |
+
*/
|
| 56 |
+
async function dispatchTool(
|
| 57 |
+
tool: ResearchStep["tool"],
|
| 58 |
+
input: string
|
| 59 |
+
): Promise<string> {
|
| 60 |
+
switch (tool) {
|
| 61 |
+
case "web_search":
|
| 62 |
+
// e.g. return await tavilySearch(input);
|
| 63 |
+
return `[STUB] web_search result for: "${input}"`;
|
| 64 |
+
case "document_fetch":
|
| 65 |
+
// e.g. return await fetchDocument(input);
|
| 66 |
+
return `[STUB] document_fetch result for: "${input}"`;
|
| 67 |
+
case "code_interpreter":
|
| 68 |
+
// e.g. return await runSandboxedCode(input);
|
| 69 |
+
return `[STUB] code_interpreter result for: "${input}"`;
|
| 70 |
+
case "summarize":
|
| 71 |
+
// e.g. return await summarizeText(input);
|
| 72 |
+
return `[STUB] summarize result for: "${input}"`;
|
| 73 |
+
default: {
|
| 74 |
+
const exhaustiveCheck: never = tool;
|
| 75 |
+
throw new Error(`Unknown tool: ${exhaustiveCheck}`);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
lib/agent/routing.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Routing functions — conditional edges for the state graph
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { END } from "@langchain/langgraph";
|
| 6 |
+
import { ResearchState } from "./state";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* After auditor: loop back to thinker or advance to HITL gate.
|
| 10 |
+
*/
|
| 11 |
+
export function routeAfterAudit(
|
| 12 |
+
state: ResearchState
|
| 13 |
+
): "thinker" | "hitl_gate" | typeof END {
|
| 14 |
+
// Hard stop — too many failed revisions
|
| 15 |
+
if (state.planRevisionCount >= state.maxPlanRevisions) {
|
| 16 |
+
return END;
|
| 17 |
+
}
|
| 18 |
+
if (state.auditResult?.verdict !== "approved") {
|
| 19 |
+
return "thinker";
|
| 20 |
+
}
|
| 21 |
+
return "hitl_gate";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* After HITL gate: block until human approves, then execute.
|
| 26 |
+
*/
|
| 27 |
+
export function routeAfterHitl(
|
| 28 |
+
state: ResearchState
|
| 29 |
+
): "tool_executor" | typeof END {
|
| 30 |
+
if (!state.humanApproved) {
|
| 31 |
+
// Graph should not reach here without approval; fail safe.
|
| 32 |
+
return END;
|
| 33 |
+
}
|
| 34 |
+
return "tool_executor";
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* After each tool step: keep executing or move to synthesizer.
|
| 39 |
+
*/
|
| 40 |
+
export function routeAfterToolStep(
|
| 41 |
+
state: ResearchState
|
| 42 |
+
): "tool_executor" | "synthesizer" {
|
| 43 |
+
const totalSteps = state.plan?.steps.length ?? 0;
|
| 44 |
+
if (state.currentStepIndex < totalSteps) {
|
| 45 |
+
return "tool_executor";
|
| 46 |
+
}
|
| 47 |
+
return "synthesizer";
|
| 48 |
+
}
|
| 49 |
+
|
lib/agent/state.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* state.ts — DeepTrust Research Agent
|
| 3 |
+
*
|
| 4 |
+
* Single source of truth for every field that flows through the
|
| 5 |
+
* StateGraph. Zod gives us runtime validation; the inferred TS
|
| 6 |
+
* types keep every node strictly typed with zero `any` leakage.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { z } from "zod";
|
| 10 |
+
|
| 11 |
+
// ─────────────────────────────────────────────────────────────
|
| 12 |
+
// Enumerations
|
| 13 |
+
// ─────────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
/** Every legal node name in the graph – used to type routing returns. */
|
| 16 |
+
export const NodeName = z.enum([
|
| 17 |
+
"thinker",
|
| 18 |
+
"auditor",
|
| 19 |
+
"tool_executor",
|
| 20 |
+
"synthesizer",
|
| 21 |
+
"__end__",
|
| 22 |
+
]);
|
| 23 |
+
export type NodeName = z.infer<typeof NodeName>;
|
| 24 |
+
|
| 25 |
+
/** Disposition returned by the Auditor node. */
|
| 26 |
+
export const AuditVerdict = z.enum(["approved", "rejected", "needs_revision"]);
|
| 27 |
+
export type AuditVerdict = z.infer<typeof AuditVerdict>;
|
| 28 |
+
|
| 29 |
+
/** Lifecycle of a single research run. */
|
| 30 |
+
export const RunStatus = z.enum([
|
| 31 |
+
"idle",
|
| 32 |
+
"thinking",
|
| 33 |
+
"awaiting_approval", // HITL interrupt point
|
| 34 |
+
"executing",
|
| 35 |
+
"synthesizing",
|
| 36 |
+
"complete",
|
| 37 |
+
"failed",
|
| 38 |
+
]);
|
| 39 |
+
export type RunStatus = z.infer<typeof RunStatus>;
|
| 40 |
+
|
| 41 |
+
// ─────────────────────────────────────────────────────────────
|
| 42 |
+
// Sub-schemas
|
| 43 |
+
// ─────────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* A single step inside a research plan.
|
| 47 |
+
* The Thinker produces an array of these; the Tool Executor
|
| 48 |
+
* works through them one-by-one.
|
| 49 |
+
*/
|
| 50 |
+
export const ResearchStep = z.object({
|
| 51 |
+
id: z.string().uuid(),
|
| 52 |
+
tool: z.enum(["web_search", "document_fetch", "code_interpreter", "summarize"]),
|
| 53 |
+
input: z.string().min(1),
|
| 54 |
+
rationale: z.string(),
|
| 55 |
+
/** Populated by the Tool Executor after the step runs. */
|
| 56 |
+
output: z.string().optional(),
|
| 57 |
+
executedAt: z.string().datetime().optional(),
|
| 58 |
+
});
|
| 59 |
+
export type ResearchStep = z.infer<typeof ResearchStep>;
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* The structured plan produced by the Thinker and evaluated
|
| 63 |
+
* by the Auditor before any tool is touched.
|
| 64 |
+
*/
|
| 65 |
+
export const ResearchPlan = z.object({
|
| 66 |
+
objective: z.string().min(1),
|
| 67 |
+
steps: z.array(ResearchStep).min(1).max(20),
|
| 68 |
+
estimatedTokenBudget: z.number().int().positive(),
|
| 69 |
+
createdAt: z.string().datetime(),
|
| 70 |
+
revision: z.number().int().nonnegative().default(0),
|
| 71 |
+
});
|
| 72 |
+
export type ResearchPlan = z.infer<typeof ResearchPlan>;
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* One entry in the reasoning trail — every node appends here
|
| 76 |
+
* so the UI can replay the full thought process.
|
| 77 |
+
*/
|
| 78 |
+
export const ReasoningEntry = z.object({
|
| 79 |
+
node: NodeName,
|
| 80 |
+
timestamp: z.string().datetime(),
|
| 81 |
+
summary: z.string(),
|
| 82 |
+
/** Optional verbatim model output for deep-dive inspection. */
|
| 83 |
+
rawThought: z.string().optional(),
|
| 84 |
+
});
|
| 85 |
+
export type ReasoningEntry = z.infer<typeof ReasoningEntry>;
|
| 86 |
+
|
| 87 |
+
/** Structured feedback from the Auditor when it rejects a plan. */
|
| 88 |
+
export const AuditResult = z.object({
|
| 89 |
+
verdict: AuditVerdict,
|
| 90 |
+
policyViolations: z.array(z.string()).default([]),
|
| 91 |
+
suggestions: z.array(z.string()).default([]),
|
| 92 |
+
auditedAt: z.string().datetime(),
|
| 93 |
+
});
|
| 94 |
+
export type AuditResult = z.infer<typeof AuditResult>;
|
| 95 |
+
|
| 96 |
+
// ─────────────────────────────────────────────────────────────
|
| 97 |
+
// Root State Schema
|
| 98 |
+
// ─────────────────────────────────────────────────────────────
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* ResearchState — the complete, checkpointable state object.
|
| 102 |
+
*
|
| 103 |
+
* LangGraph.js will serialise/deserialise this on every step,
|
| 104 |
+
* so every field must be JSON-safe. The checkpointer stores
|
| 105 |
+
* snapshots keyed by `threadId`, enabling full rehydration.
|
| 106 |
+
*/
|
| 107 |
+
export const ResearchState = z.object({
|
| 108 |
+
// ── Identity & Persistence ───────────────────────────────
|
| 109 |
+
/** Stable identifier — passed to the checkpointer as the config key. */
|
| 110 |
+
threadId: z.string().uuid(),
|
| 111 |
+
/** Human-readable label shown in the UI. */
|
| 112 |
+
sessionName: z.string().default("Unnamed Session"),
|
| 113 |
+
|
| 114 |
+
// ── Input ────────────────────────────────────────────────
|
| 115 |
+
/** The raw research question submitted by the user. */
|
| 116 |
+
userQuery: z.string().min(1),
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Retrieved context from the user's local knowledge base (client-side RAG).
|
| 120 |
+
* Passed into Thinker and Synthesizer so the LLM can use it when planning and reporting.
|
| 121 |
+
*/
|
| 122 |
+
knowledgeContext: z.string().optional(),
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* URLs the user added as references. Thinker can emit document_fetch steps for these.
|
| 126 |
+
*/
|
| 127 |
+
contextUrls: z.array(z.string()).optional(),
|
| 128 |
+
|
| 129 |
+
// ── Planning ─────────────────────────────────────────────
|
| 130 |
+
/** Current plan produced by the Thinker. Null before first plan. */
|
| 131 |
+
plan: ResearchPlan.nullable().default(null),
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Feedback injected into the Thinker's context when the Auditor
|
| 135 |
+
* rejects a plan. Cleared after each successful audit.
|
| 136 |
+
*/
|
| 137 |
+
rejectionFeedback: z.string().nullable().default(null),
|
| 138 |
+
|
| 139 |
+
/** How many times the plan has cycled through Thinker→Auditor. */
|
| 140 |
+
planRevisionCount: z.number().int().nonnegative().default(0),
|
| 141 |
+
|
| 142 |
+
/** Safety ceiling — prevents runaway revision loops. */
|
| 143 |
+
maxPlanRevisions: z.number().int().positive().default(5),
|
| 144 |
+
|
| 145 |
+
// ── Auditing ─────────────────────────────────────────────
|
| 146 |
+
/** The most recent audit result. Null before first audit. */
|
| 147 |
+
auditResult: AuditResult.nullable().default(null),
|
| 148 |
+
|
| 149 |
+
// ── Execution ────────────────────────────────────────────
|
| 150 |
+
/** Index into `plan.steps` for the currently-executing step. */
|
| 151 |
+
currentStepIndex: z.number().int().nonnegative().default(0),
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Whether the user has explicitly approved the plan.
|
| 155 |
+
* The graph checks this before transitioning to tool_executor.
|
| 156 |
+
*/
|
| 157 |
+
humanApproved: z.boolean().default(false),
|
| 158 |
+
|
| 159 |
+
// ── Output ───────────────────────────────────────────────
|
| 160 |
+
/** Final synthesized report. Null until synthesizer runs. */
|
| 161 |
+
finalReport: z.string().nullable().default(null),
|
| 162 |
+
|
| 163 |
+
// ── Observability ────────────────────────────────────────
|
| 164 |
+
/**
|
| 165 |
+
* Append-only log of every node's reasoning.
|
| 166 |
+
* The UI streams this list to render a live "thought process" view.
|
| 167 |
+
*/
|
| 168 |
+
reasoning: z.array(ReasoningEntry).default([]),
|
| 169 |
+
|
| 170 |
+
// ── Lifecycle ────────────────────────────────────────────
|
| 171 |
+
status: RunStatus.default("idle"),
|
| 172 |
+
|
| 173 |
+
/** ISO timestamp of the last state mutation. */
|
| 174 |
+
updatedAt: z.string().datetime(),
|
| 175 |
+
|
| 176 |
+
/** Non-null when the run terminated with an unrecoverable error. */
|
| 177 |
+
errorMessage: z.string().nullable().default(null),
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
export type ResearchState = z.infer<typeof ResearchState>;
|
| 181 |
+
|
| 182 |
+
// ─────────────────────────────────────────────────────────────
|
| 183 |
+
// Helpers
|
| 184 |
+
// ─────────────────────────────────────────────────────────────
|
| 185 |
+
|
| 186 |
+
/** Append a reasoning entry without mutating the caller's reference. */
|
| 187 |
+
export function appendReasoning(
|
| 188 |
+
state: ResearchState,
|
| 189 |
+
entry: Omit<ReasoningEntry, "timestamp">
|
| 190 |
+
): ReasoningEntry[] {
|
| 191 |
+
const stamped: ReasoningEntry = {
|
| 192 |
+
...entry,
|
| 193 |
+
timestamp: new Date().toISOString(),
|
| 194 |
+
};
|
| 195 |
+
return [...state.reasoning, stamped];
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/** Returns a fresh, validated initial state for a new session. */
|
| 199 |
+
export function createInitialState(
|
| 200 |
+
params: Pick<
|
| 201 |
+
ResearchState,
|
| 202 |
+
"threadId" | "userQuery" | "sessionName" | "knowledgeContext" | "contextUrls"
|
| 203 |
+
>
|
| 204 |
+
): ResearchState {
|
| 205 |
+
return ResearchState.parse({
|
| 206 |
+
...params,
|
| 207 |
+
updatedAt: new Date().toISOString(),
|
| 208 |
+
});
|
| 209 |
+
}
|
lib/agent/utils/extract-json.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* JSON extraction helper — handles models that add extra text
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Extracts JSON from model output that may contain extra text,
|
| 7 |
+
* markdown fences, or other non-JSON content.
|
| 8 |
+
*/
|
| 9 |
+
export function extractJSON(text: string): unknown {
|
| 10 |
+
// Try direct parse first
|
| 11 |
+
try {
|
| 12 |
+
return JSON.parse(text);
|
| 13 |
+
} catch {
|
| 14 |
+
// Continue to extraction methods
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Remove markdown code fences
|
| 18 |
+
const cleaned = text.replace(/```json\s*/gi, "").replace(/```\s*/g, "");
|
| 19 |
+
|
| 20 |
+
// Try to find JSON object boundaries
|
| 21 |
+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
| 22 |
+
if (jsonMatch) {
|
| 23 |
+
try {
|
| 24 |
+
return JSON.parse(jsonMatch[0]);
|
| 25 |
+
} catch {
|
| 26 |
+
// Continue
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Try to find JSON array boundaries
|
| 31 |
+
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
| 32 |
+
if (arrayMatch) {
|
| 33 |
+
try {
|
| 34 |
+
return JSON.parse(arrayMatch[0]);
|
| 35 |
+
} catch {
|
| 36 |
+
// Continue
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
throw new Error(`Could not extract valid JSON from: ${text.slice(0, 200)}...`);
|
| 41 |
+
}
|
| 42 |
+
|
lib/agent/utils/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { extractJSON } from "./extract-json";
|
| 2 |
+
export { loadPolicy } from "./policy";
|
| 3 |
+
|
lib/agent/utils/policy.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Policy loader — reads POLICY.md for the Auditor node
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { readFileSync } from "fs";
|
| 6 |
+
import { join } from "path";
|
| 7 |
+
|
| 8 |
+
const DEFAULT_POLICY = `
|
| 9 |
+
# DeepTrust Default Policy
|
| 10 |
+
- Never access personal, private, or confidential data sources.
|
| 11 |
+
- Never execute code that modifies the host filesystem.
|
| 12 |
+
- Never make more than 10 external HTTP requests per session.
|
| 13 |
+
- Research must be directly related to the user's stated objective.
|
| 14 |
+
- All sources must be attributable and verifiable.
|
| 15 |
+
`.trim();
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Load the policy from POLICY.md or return a default fallback.
|
| 19 |
+
*/
|
| 20 |
+
export function loadPolicy(): string {
|
| 21 |
+
try {
|
| 22 |
+
return readFileSync(join(process.cwd(), "POLICY.md"), "utf-8");
|
| 23 |
+
} catch {
|
| 24 |
+
return DEFAULT_POLICY;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
lib/knowledge/chunk.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Text chunking for RAG. Fixed-size segments with overlap.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
const DEFAULT_CHUNK_SIZE = 500;
|
| 6 |
+
const DEFAULT_OVERLAP = 80;
|
| 7 |
+
|
| 8 |
+
export function chunkText(
|
| 9 |
+
text: string,
|
| 10 |
+
chunkSize = DEFAULT_CHUNK_SIZE,
|
| 11 |
+
overlap = DEFAULT_OVERLAP
|
| 12 |
+
): { text: string; startIndex: number; endIndex: number }[] {
|
| 13 |
+
const trimmed = text.trim();
|
| 14 |
+
if (!trimmed) return [];
|
| 15 |
+
|
| 16 |
+
const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
|
| 17 |
+
let start = 0;
|
| 18 |
+
|
| 19 |
+
while (start < trimmed.length) {
|
| 20 |
+
let end = Math.min(start + chunkSize, trimmed.length);
|
| 21 |
+
if (end < trimmed.length) {
|
| 22 |
+
const lastSpace = trimmed.lastIndexOf(" ", end);
|
| 23 |
+
if (lastSpace > start) end = lastSpace;
|
| 24 |
+
}
|
| 25 |
+
const slice = trimmed.slice(start, end);
|
| 26 |
+
if (slice.length > 0) {
|
| 27 |
+
chunks.push({ text: slice, startIndex: start, endIndex: end });
|
| 28 |
+
}
|
| 29 |
+
start = end - (end < trimmed.length ? overlap : 0);
|
| 30 |
+
if (start >= end) start = end;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return chunks;
|
| 34 |
+
}
|
lib/knowledge/db.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* IndexedDB schema and access for the knowledge store.
|
| 3 |
+
* Database: deeptrust-knowledge
|
| 4 |
+
* Stores: documents, chunks
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import type { KnowledgeDocument, KnowledgeChunk } from "./types";
|
| 8 |
+
|
| 9 |
+
const DB_NAME = "deeptrust-knowledge";
|
| 10 |
+
const DB_VERSION = 1;
|
| 11 |
+
const STORE_DOCUMENTS = "documents";
|
| 12 |
+
const STORE_CHUNKS = "chunks";
|
| 13 |
+
|
| 14 |
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
| 15 |
+
|
| 16 |
+
export function openDB(): Promise<IDBDatabase> {
|
| 17 |
+
if (typeof window === "undefined") {
|
| 18 |
+
return Promise.reject(new Error("IndexedDB is only available in the browser"));
|
| 19 |
+
}
|
| 20 |
+
if (dbPromise) return dbPromise;
|
| 21 |
+
dbPromise = new Promise((resolve, reject) => {
|
| 22 |
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
| 23 |
+
req.onerror = () => reject(req.error);
|
| 24 |
+
req.onsuccess = () => resolve(req.result);
|
| 25 |
+
req.onupgradeneeded = (event) => {
|
| 26 |
+
const db = (event.target as IDBOpenDBRequest).result;
|
| 27 |
+
if (!db.objectStoreNames.contains(STORE_DOCUMENTS)) {
|
| 28 |
+
db.createObjectStore(STORE_DOCUMENTS, { keyPath: "id" });
|
| 29 |
+
}
|
| 30 |
+
if (!db.objectStoreNames.contains(STORE_CHUNKS)) {
|
| 31 |
+
const chunkStore = db.createObjectStore(STORE_CHUNKS, { keyPath: "id" });
|
| 32 |
+
chunkStore.createIndex("byDocument", "documentId", { unique: false });
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
});
|
| 36 |
+
return dbPromise;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export async function putDocument(doc: KnowledgeDocument): Promise<void> {
|
| 40 |
+
const db = await openDB();
|
| 41 |
+
return new Promise((resolve, reject) => {
|
| 42 |
+
const tx = db.transaction(STORE_DOCUMENTS, "readwrite");
|
| 43 |
+
tx.objectStore(STORE_DOCUMENTS).put(doc);
|
| 44 |
+
tx.oncomplete = () => resolve();
|
| 45 |
+
tx.onerror = () => reject(tx.error);
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export async function getDocument(id: string): Promise<KnowledgeDocument | undefined> {
|
| 50 |
+
const db = await openDB();
|
| 51 |
+
return new Promise((resolve, reject) => {
|
| 52 |
+
const tx = db.transaction(STORE_DOCUMENTS, "readonly");
|
| 53 |
+
const req = tx.objectStore(STORE_DOCUMENTS).get(id);
|
| 54 |
+
tx.oncomplete = () => resolve(req.result);
|
| 55 |
+
tx.onerror = () => reject(tx.error);
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function listDocuments(): Promise<KnowledgeDocument[]> {
|
| 60 |
+
const db = await openDB();
|
| 61 |
+
return new Promise((resolve, reject) => {
|
| 62 |
+
const tx = db.transaction(STORE_DOCUMENTS, "readonly");
|
| 63 |
+
const req = tx.objectStore(STORE_DOCUMENTS).getAll();
|
| 64 |
+
tx.oncomplete = () => resolve(req.result ?? []);
|
| 65 |
+
tx.onerror = () => reject(tx.error);
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export async function deleteDocument(id: string): Promise<void> {
|
| 70 |
+
const db = await openDB();
|
| 71 |
+
return new Promise((resolve, reject) => {
|
| 72 |
+
const tx = db.transaction([STORE_DOCUMENTS, STORE_CHUNKS], "readwrite");
|
| 73 |
+
const docStore = tx.objectStore(STORE_DOCUMENTS);
|
| 74 |
+
const chunkStore = tx.objectStore(STORE_CHUNKS);
|
| 75 |
+
docStore.delete(id);
|
| 76 |
+
const index = chunkStore.index("byDocument");
|
| 77 |
+
const range = IDBKeyRange.only(id);
|
| 78 |
+
const req = index.openCursor(range);
|
| 79 |
+
req.onsuccess = () => {
|
| 80 |
+
const cursor = req.result;
|
| 81 |
+
if (cursor) {
|
| 82 |
+
cursor.delete();
|
| 83 |
+
cursor.continue();
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
tx.oncomplete = () => resolve();
|
| 87 |
+
tx.onerror = () => reject(tx.error);
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export async function putChunks(chunks: KnowledgeChunk[]): Promise<void> {
|
| 92 |
+
if (chunks.length === 0) return;
|
| 93 |
+
const db = await openDB();
|
| 94 |
+
return new Promise((resolve, reject) => {
|
| 95 |
+
const tx = db.transaction(STORE_CHUNKS, "readwrite");
|
| 96 |
+
const store = tx.objectStore(STORE_CHUNKS);
|
| 97 |
+
for (const chunk of chunks) store.put(chunk);
|
| 98 |
+
tx.oncomplete = () => resolve();
|
| 99 |
+
tx.onerror = () => reject(tx.error);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export async function getAllChunks(): Promise<KnowledgeChunk[]> {
|
| 104 |
+
const db = await openDB();
|
| 105 |
+
return new Promise((resolve, reject) => {
|
| 106 |
+
const tx = db.transaction(STORE_CHUNKS, "readonly");
|
| 107 |
+
const req = tx.objectStore(STORE_CHUNKS).getAll();
|
| 108 |
+
tx.oncomplete = () => resolve(req.result ?? []);
|
| 109 |
+
tx.onerror = () => reject(tx.error);
|
| 110 |
+
});
|
| 111 |
+
}
|
lib/knowledge/embeddings.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Browser-side embeddings via @xenova/transformers.
|
| 3 |
+
* Lazy-loads the pipeline on first use.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const MODEL = "Xenova/all-MiniLM-L6-v2";
|
| 7 |
+
|
| 8 |
+
let pipelinePromise: Promise<unknown> | null = null;
|
| 9 |
+
|
| 10 |
+
async function getPipeline(): Promise<unknown> {
|
| 11 |
+
if (typeof window === "undefined") {
|
| 12 |
+
throw new Error("Embeddings are only available in the browser");
|
| 13 |
+
}
|
| 14 |
+
if (!pipelinePromise) {
|
| 15 |
+
const { pipeline } = await import("@xenova/transformers");
|
| 16 |
+
pipelinePromise = pipeline("feature-extraction", MODEL);
|
| 17 |
+
}
|
| 18 |
+
return pipelinePromise;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** Embed a single text. Returns normalized vector for cosine similarity. */
|
| 22 |
+
export async function embed(text: string): Promise<number[]> {
|
| 23 |
+
const pipe = (await getPipeline()) as (input: string, options?: { pooling?: string; normalize?: boolean }) => Promise<{ data: Float32Array }>;
|
| 24 |
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
| 25 |
+
const data = output.data;
|
| 26 |
+
if (!data) throw new Error("Embedding output has no data");
|
| 27 |
+
return Array.from(data);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Embed multiple texts in one batch (more efficient). */
|
| 31 |
+
export async function embedBatch(texts: string[]): Promise<number[][]> {
|
| 32 |
+
if (texts.length === 0) return [];
|
| 33 |
+
const pipe = (await getPipeline()) as (input: string | string[], options?: { pooling?: string; normalize?: boolean }) => Promise<{ data: Float32Array; dims: number[] }>;
|
| 34 |
+
const output = await pipe(texts, { pooling: "mean", normalize: true });
|
| 35 |
+
const data = output.data;
|
| 36 |
+
const dims = output.dims;
|
| 37 |
+
if (!data || !dims?.length) throw new Error("Batch embedding output has no data");
|
| 38 |
+
const dim = dims[dims.length - 1] ?? data.length;
|
| 39 |
+
const results: number[][] = [];
|
| 40 |
+
for (let i = 0; i < dims[0]; i++) {
|
| 41 |
+
const start = i * dim;
|
| 42 |
+
results.push(Array.from(data.slice(start, start + dim)));
|
| 43 |
+
}
|
| 44 |
+
return results;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
| 48 |
+
if (a.length !== b.length) return 0;
|
| 49 |
+
let dot = 0;
|
| 50 |
+
let normA = 0;
|
| 51 |
+
let normB = 0;
|
| 52 |
+
for (let i = 0; i < a.length; i++) {
|
| 53 |
+
dot += a[i] * b[i];
|
| 54 |
+
normA += a[i] * a[i];
|
| 55 |
+
normB += b[i] * b[i];
|
| 56 |
+
}
|
| 57 |
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
| 58 |
+
return denom === 0 ? 0 : dot / denom;
|
| 59 |
+
}
|
lib/knowledge/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side knowledge store for RAG.
|
| 3 |
+
* Use from "use client" components only (IndexedDB + Transformers.js run in browser).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export type { KnowledgeDocument, KnowledgeChunk, KnowledgeItemMeta, RetrieveResult, DocumentType } from "./types";
|
| 7 |
+
export { listKnowledgeItems, addPdfFile, addNote, addUrl, removeKnowledgeDocument, retrieve } from "./store";
|
| 8 |
+
export { chunkText } from "./chunk";
|
| 9 |
+
export { extractTextFromPdf } from "./pdf";
|
| 10 |
+
export { embed, embedBatch, cosineSimilarity } from "./embeddings";
|
lib/knowledge/pdf.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PDF text extraction in the browser using pdfjs-dist.
|
| 3 |
+
* Only runs client-side.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export async function extractTextFromPdf(file: File): Promise<string> {
|
| 7 |
+
if (typeof window === "undefined") {
|
| 8 |
+
throw new Error("PDF extraction is only available in the browser");
|
| 9 |
+
}
|
| 10 |
+
const pdfjsLib = await import("pdfjs-dist");
|
| 11 |
+
const GlobalWorkerOptions = (pdfjsLib as unknown as { GlobalWorkerOptions?: { workerSrc: string } }).GlobalWorkerOptions;
|
| 12 |
+
if (GlobalWorkerOptions && !GlobalWorkerOptions.workerSrc) {
|
| 13 |
+
GlobalWorkerOptions.workerSrc = "https://unpkg.com/pdfjs-dist@4.7.76/build/pdf.worker.min.mjs";
|
| 14 |
+
}
|
| 15 |
+
const arrayBuffer = await file.arrayBuffer();
|
| 16 |
+
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
| 17 |
+
const numPages = doc.numPages;
|
| 18 |
+
const parts: string[] = [];
|
| 19 |
+
for (let i = 1; i <= numPages; i++) {
|
| 20 |
+
const page = await doc.getPage(i);
|
| 21 |
+
const content = await page.getTextContent();
|
| 22 |
+
const text = content.items
|
| 23 |
+
.map((item) => ("str" in item ? item.str : ""))
|
| 24 |
+
.join("");
|
| 25 |
+
parts.push(text);
|
| 26 |
+
}
|
| 27 |
+
return parts.join("\n\n").trim();
|
| 28 |
+
}
|
lib/knowledge/store.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* High-level knowledge store: add documents, remove, list, retrieve.
|
| 3 |
+
* Coordinates IndexedDB, chunking, PDF extraction, and embeddings.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { v4 as uuidv4 } from "uuid";
|
| 7 |
+
import type { KnowledgeDocument, KnowledgeChunk, KnowledgeItemMeta, RetrieveResult } from "./types";
|
| 8 |
+
import { openDB, putDocument, putChunks, listDocuments, deleteDocument, getAllChunks } from "./db";
|
| 9 |
+
import { chunkText } from "./chunk";
|
| 10 |
+
import { extractTextFromPdf } from "./pdf";
|
| 11 |
+
import { embed, cosineSimilarity } from "./embeddings";
|
| 12 |
+
|
| 13 |
+
const TOP_K = 8;
|
| 14 |
+
|
| 15 |
+
function docToMeta(doc: KnowledgeDocument): KnowledgeItemMeta {
|
| 16 |
+
return {
|
| 17 |
+
id: doc.id,
|
| 18 |
+
type: doc.type,
|
| 19 |
+
label: doc.label,
|
| 20 |
+
meta: doc.type === "file" ? undefined : doc.url,
|
| 21 |
+
status: "indexed",
|
| 22 |
+
};
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/** List all documents as UI items. */
|
| 26 |
+
export async function listKnowledgeItems(): Promise<KnowledgeItemMeta[]> {
|
| 27 |
+
const docs = await listDocuments();
|
| 28 |
+
return docs.map(docToMeta);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/** Add a PDF file: extract text, chunk, embed, store. */
|
| 32 |
+
export async function addPdfFile(file: File): Promise<KnowledgeItemMeta> {
|
| 33 |
+
const text = await extractTextFromPdf(file);
|
| 34 |
+
const id = uuidv4();
|
| 35 |
+
const doc: KnowledgeDocument = {
|
| 36 |
+
id,
|
| 37 |
+
type: "file",
|
| 38 |
+
label: file.name,
|
| 39 |
+
createdAt: new Date().toISOString(),
|
| 40 |
+
};
|
| 41 |
+
await putDocument(doc);
|
| 42 |
+
const chunks = chunkText(text);
|
| 43 |
+
const chunksWithEmbeddings: KnowledgeChunk[] = [];
|
| 44 |
+
for (let i = 0; i < chunks.length; i++) {
|
| 45 |
+
const ch = chunks[i];
|
| 46 |
+
const embedding = await embed(ch.text);
|
| 47 |
+
chunksWithEmbeddings.push({
|
| 48 |
+
id: `${id}-chunk-${i}`,
|
| 49 |
+
documentId: id,
|
| 50 |
+
text: ch.text,
|
| 51 |
+
embedding,
|
| 52 |
+
startIndex: ch.startIndex,
|
| 53 |
+
endIndex: ch.endIndex,
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
await putChunks(chunksWithEmbeddings);
|
| 57 |
+
return docToMeta(doc);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** Add a note: chunk, embed, store. */
|
| 61 |
+
export async function addNote(label: string): Promise<KnowledgeItemMeta> {
|
| 62 |
+
const id = uuidv4();
|
| 63 |
+
const doc: KnowledgeDocument = {
|
| 64 |
+
id,
|
| 65 |
+
type: "note",
|
| 66 |
+
label: label.slice(0, 80) + (label.length > 80 ? "…" : ""),
|
| 67 |
+
createdAt: new Date().toISOString(),
|
| 68 |
+
};
|
| 69 |
+
await putDocument(doc);
|
| 70 |
+
const chunks = chunkText(label);
|
| 71 |
+
const chunksWithEmbeddings: KnowledgeChunk[] = [];
|
| 72 |
+
for (let i = 0; i < chunks.length; i++) {
|
| 73 |
+
const ch = chunks[i];
|
| 74 |
+
const embedding = await embed(ch.text);
|
| 75 |
+
chunksWithEmbeddings.push({
|
| 76 |
+
id: `${id}-chunk-${i}`,
|
| 77 |
+
documentId: id,
|
| 78 |
+
text: ch.text,
|
| 79 |
+
embedding,
|
| 80 |
+
startIndex: ch.startIndex,
|
| 81 |
+
endIndex: ch.endIndex,
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
if (chunksWithEmbeddings.length === 0) {
|
| 85 |
+
const embedding = await embed(label);
|
| 86 |
+
chunksWithEmbeddings.push({
|
| 87 |
+
id: `${id}-chunk-0`,
|
| 88 |
+
documentId: id,
|
| 89 |
+
text: label,
|
| 90 |
+
embedding,
|
| 91 |
+
startIndex: 0,
|
| 92 |
+
endIndex: label.length,
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
await putChunks(chunksWithEmbeddings);
|
| 96 |
+
return docToMeta(doc);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/** Add a URL as reference (no fetch in v1). Stored as document with one placeholder chunk so we can return contextUrls. */
|
| 100 |
+
export async function addUrl(url: string): Promise<KnowledgeItemMeta> {
|
| 101 |
+
const id = uuidv4();
|
| 102 |
+
const doc: KnowledgeDocument = {
|
| 103 |
+
id,
|
| 104 |
+
type: "url",
|
| 105 |
+
label: url,
|
| 106 |
+
url,
|
| 107 |
+
createdAt: new Date().toISOString(),
|
| 108 |
+
};
|
| 109 |
+
await putDocument(doc);
|
| 110 |
+
const embedding = await embed(`URL: ${url}`);
|
| 111 |
+
await putChunks([
|
| 112 |
+
{
|
| 113 |
+
id: `${id}-chunk-0`,
|
| 114 |
+
documentId: id,
|
| 115 |
+
text: url,
|
| 116 |
+
embedding,
|
| 117 |
+
startIndex: 0,
|
| 118 |
+
endIndex: url.length,
|
| 119 |
+
},
|
| 120 |
+
]);
|
| 121 |
+
return docToMeta(doc);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/** Remove a document and all its chunks. */
|
| 125 |
+
export async function removeKnowledgeDocument(id: string): Promise<void> {
|
| 126 |
+
await deleteDocument(id);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/** Retrieve relevant context for a query: embed query, top-k similarity, build retrievedContext + contextUrls. */
|
| 130 |
+
export async function retrieve(query: string): Promise<RetrieveResult> {
|
| 131 |
+
const chunks = await getAllChunks();
|
| 132 |
+
if (chunks.length === 0) {
|
| 133 |
+
return { retrievedContext: "", contextUrls: [] };
|
| 134 |
+
}
|
| 135 |
+
const docs = await listDocuments();
|
| 136 |
+
const docMap = new Map(docs.map((d) => [d.id, d]));
|
| 137 |
+
const queryEmbedding = await embed(query);
|
| 138 |
+
const withScore = chunks.map((ch) => ({
|
| 139 |
+
chunk: ch,
|
| 140 |
+
score: cosineSimilarity(ch.embedding, queryEmbedding),
|
| 141 |
+
}));
|
| 142 |
+
withScore.sort((a, b) => b.score - a.score);
|
| 143 |
+
const top = withScore.slice(0, TOP_K);
|
| 144 |
+
const parts: string[] = [];
|
| 145 |
+
const urlSet = new Set<string>();
|
| 146 |
+
for (const { chunk } of top) {
|
| 147 |
+
const doc = docMap.get(chunk.documentId);
|
| 148 |
+
const source = doc ? doc.label : "Unknown";
|
| 149 |
+
parts.push(`[${source}]\n${chunk.text}`);
|
| 150 |
+
if (doc?.type === "url" && doc.url) urlSet.add(doc.url);
|
| 151 |
+
}
|
| 152 |
+
return {
|
| 153 |
+
retrievedContext: parts.join("\n\n"),
|
| 154 |
+
contextUrls: Array.from(urlSet),
|
| 155 |
+
};
|
| 156 |
+
}
|
lib/knowledge/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side knowledge store types.
|
| 3 |
+
* Documents and chunks are persisted in IndexedDB.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export type DocumentType = "file" | "url" | "note";
|
| 7 |
+
|
| 8 |
+
export interface KnowledgeDocument {
|
| 9 |
+
id: string;
|
| 10 |
+
type: DocumentType;
|
| 11 |
+
label: string;
|
| 12 |
+
url?: string;
|
| 13 |
+
createdAt: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export interface KnowledgeChunk {
|
| 17 |
+
id: string;
|
| 18 |
+
documentId: string;
|
| 19 |
+
text: string;
|
| 20 |
+
embedding: number[];
|
| 21 |
+
startIndex: number;
|
| 22 |
+
endIndex: number;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/** UI-facing item (matches existing KnowledgeItem shape). */
|
| 26 |
+
export interface KnowledgeItemMeta {
|
| 27 |
+
id: string;
|
| 28 |
+
type: DocumentType;
|
| 29 |
+
label: string;
|
| 30 |
+
meta?: string;
|
| 31 |
+
status?: "pending" | "indexing" | "indexed" | "error";
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface RetrieveResult {
|
| 35 |
+
retrievedContext: string;
|
| 36 |
+
contextUrls: string[];
|
| 37 |
+
}
|
next.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
// Required for @huggingface/transformers in API routes: use Node runtime
|
| 5 |
+
// for onnxruntime-node instead of bundling (which can break or hang).
|
| 6 |
+
serverExternalPackages: ["onnxruntime-node"],
|
| 7 |
+
// Smaller Docker image: output standalone server + minimal node_modules
|
| 8 |
+
output: "standalone",
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "deeptrust",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "npm run build:worker && next build --webpack",
|
| 8 |
+
"build:worker": "tsc -p tsconfig.worker.json",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"lint": "eslint"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@anthropic-ai/sdk": "^0.78.0",
|
| 14 |
+
"@huggingface/transformers": "^3.8.1",
|
| 15 |
+
"@xenova/transformers": "^2.17.2",
|
| 16 |
+
"next": "16.1.6",
|
| 17 |
+
"pdfjs-dist": "^4.7.76",
|
| 18 |
+
"ollama": "^0.6.3",
|
| 19 |
+
"react": "19.2.3",
|
| 20 |
+
"react-dom": "19.2.3"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@langchain/core": "^1.1.28",
|
| 24 |
+
"@langchain/langgraph": "^1.1.5",
|
| 25 |
+
"@tailwindcss/postcss": "^4",
|
| 26 |
+
"@types/node": "^20",
|
| 27 |
+
"@types/react": "^19",
|
| 28 |
+
"@types/react-dom": "^19",
|
| 29 |
+
"@types/uuid": "^10.0.0",
|
| 30 |
+
"eslint": "^9",
|
| 31 |
+
"eslint-config-next": "16.1.6",
|
| 32 |
+
"install": "^0.13.0",
|
| 33 |
+
"npm": "^11.11.0",
|
| 34 |
+
"tailwindcss": "^4",
|
| 35 |
+
"typescript": "^5",
|
| 36 |
+
"uuid": "^13.0.0",
|
| 37 |
+
"zod": "^4.3.6"
|
| 38 |
+
}
|
| 39 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|