Compare commits
11 Commits
main
...
1f6e60d4a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f6e60d4a9 | |||
| 254424eec1 | |||
| 9892f21e59 | |||
| 8268881f41 | |||
| 0fcfa3e5bb | |||
| b77c6d1195 | |||
| 489e8e3bc9 | |||
| 1ba9c9eee2 | |||
|
|
0f3c63c4de | ||
|
|
aa089975df | ||
|
|
a6c04e52af |
@@ -1,85 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload
|
||||
|
||||
# Build & Package
|
||||
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||
source ~/.nvm/nvm.sh && npm run package # Package without making installers
|
||||
|
||||
# Lint
|
||||
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
|
||||
|
||||
# Database migrations (Drizzle)
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||
source ~/.nvm/nvm.sh && npm start # Dev with hot-reload
|
||||
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||
source ~/.nvm/nvm.sh && npm run package # Package without installers
|
||||
source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||
```
|
||||
|
||||
There is no test suite currently.
|
||||
No test suite currently.
|
||||
|
||||
## Architecture Overview
|
||||
## Architecture
|
||||
|
||||
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPC↔IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used).
|
||||
|
||||
### Process Boundaries
|
||||
Adiuva is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
|
||||
|
||||
```
|
||||
Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||
Renderer (React 19) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||
```
|
||||
|
||||
1. **Main process** (`src/main/`) — Node.js, owns the database and all business logic
|
||||
- `index.ts` — Window creation, app lifecycle
|
||||
- `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures
|
||||
- `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai)
|
||||
- `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()`
|
||||
- `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes)
|
||||
- `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`)
|
||||
### Main Process (`src/main/`)
|
||||
|
||||
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`
|
||||
Owns the database and all business logic.
|
||||
|
||||
3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly
|
||||
- `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC`
|
||||
- `lib/trpc.ts` — `createTRPCReact<AppRouter>()` typed client
|
||||
- `index.tsx` — QueryClient + tRPC + Router providers
|
||||
- All data access is through `trpc.*.*useQuery()` / `trpc.*.*.useMutation()`
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.ts` | Window creation, app lifecycle |
|
||||
| `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
|
||||
| `router/index.ts` | All tRPC sub-routers merged into `appRouter` |
|
||||
| `db/index.ts` | Drizzle + better-sqlite3, WAL mode, singleton `getDb()` |
|
||||
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, taskComments |
|
||||
| `db/vectordb.ts` | LanceDB vector store for note embeddings |
|
||||
| `store.ts` | electron-store for persistent UI settings |
|
||||
|
||||
### Preload (`src/preload/trpc.ts`)
|
||||
|
||||
Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`.
|
||||
|
||||
### Renderer (`src/renderer/`)
|
||||
|
||||
React 19 — never accesses Node APIs directly. All data through `trpc.*.useQuery()` / `trpc.*.useMutation()`.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `lib/ipcLink.ts` | Custom TRPCLink routing through `window.electronTRPC` |
|
||||
| `lib/trpc.ts` | `createTRPCReact<AppRouter>()` typed client |
|
||||
| `index.tsx` | QueryClient + tRPC + Router providers |
|
||||
|
||||
### Routing
|
||||
|
||||
File-based routing via TanStack Router. Add a file to `src/renderer/routes/` and the route tree (`src/renderer/routeTree.gen.ts`) is auto-regenerated by the Vite plugin on next `npm start`. Routes:
|
||||
- `__root.tsx` — Root layout wrapping everything in `AppShell`
|
||||
- `index.tsx`, `tasks.tsx`, `timeline.tsx`, `projects.tsx`
|
||||
File-based via TanStack Router (`tsr.config.json` at root). Route tree auto-generated at `routeTree.gen.ts`.
|
||||
|
||||
Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`, `notes.$noteId`
|
||||
|
||||
### tRPC Routers
|
||||
|
||||
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
|
||||
|
||||
### Database
|
||||
|
||||
Schema lives in `src/main/db/schema.ts`. Migrations are in `src/main/db/migrations/`. The DB is created in Electron's `userData` directory as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations (CREATE TABLE IF NOT EXISTS).
|
||||
Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations.
|
||||
|
||||
To add a new table or column: edit `schema.ts`, run `drizzle-kit generate`, then `drizzle-kit push` (dev) or commit the migration file.
|
||||
To add a table/column: edit `schema.ts` → `drizzle-kit generate` → `drizzle-kit push` (dev) or commit the migration.
|
||||
|
||||
### Adding a New Feature (end-to-end pattern)
|
||||
### Adding a Feature (end-to-end)
|
||||
|
||||
1. **Schema** — Add table/columns to `src/main/db/schema.ts`
|
||||
2. **Router** — Add a tRPC sub-router in `src/main/router/index.ts`, merge it into `appRouter`
|
||||
3. **Types** — `AppRouter` is exported from `src/main/router/index.ts` and imported in `src/renderer/lib/trpc.ts` — types flow automatically
|
||||
4. **UI** — Create components under `src/renderer/components/<feature>/`, use `trpc.*.*useQuery()` for data
|
||||
1. **Schema** — `src/main/db/schema.ts`
|
||||
2. **Router** — Add sub-router in `src/main/router/index.ts`, merge into `appRouter`
|
||||
3. **Types** — Flow automatically via `AppRouter` export
|
||||
4. **UI** — Components in `src/renderer/components/<feature>/`, data via `trpc.*.useQuery()`
|
||||
|
||||
### AI Subsystem (`src/main/ai/`)
|
||||
## AI Subsystem (`src/main/ai/`)
|
||||
|
||||
LangGraph-based agentic system with pluggable LLM providers (OpenAI, Anthropic, GitHub Copilot).
|
||||
LangGraph-based agentic system with pluggable LLM providers.
|
||||
|
||||
**Orchestrator** (`orchestrator.ts`): Classifies user intent → routes to one of three specialist agents:
|
||||
- **Project agent** — project-scoped Q&A with tools: `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks`
|
||||
- **Knowledge agent** — cross-project semantic search via `vector_search_all`
|
||||
- **General agent** — workspace-wide `add_task`
|
||||
### Orchestrator (`orchestrator.ts`)
|
||||
|
||||
Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally).
|
||||
Classifies user intent → routes to a specialist agent:
|
||||
|
||||
**Streaming**: Orchestrator calls `sendStreamChunk(sender, token, done)` over IPC channel `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before sending to renderer.
|
||||
| Agent | Scope | Tools |
|
||||
|---|---|---|
|
||||
| Project | Project-scoped Q&A | `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks` |
|
||||
| Knowledge | Cross-project search | `vector_search_all` |
|
||||
| General | Workspace-wide | `add_task` |
|
||||
|
||||
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
|
||||
|
||||
Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tRPC mutation).
|
||||
|
||||
### Streaming
|
||||
|
||||
`sendStreamChunk(sender, token, done)` over IPC `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before display.
|
||||
|
||||
### Providers (`llm.ts`)
|
||||
|
||||
| Provider | Model | Notes |
|
||||
|---|---|---|
|
||||
| OpenAI | `gpt-4o-mini` | Via LangChain |
|
||||
| Anthropic | `claude-sonnet-4-20250514` | Via LangChain |
|
||||
| Copilot | `ChatCopilot` wrapper | `copilot.ts` / `chat-copilot.ts` |
|
||||
|
||||
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
|
||||
|
||||
### Vector Embeddings (`db/vectordb.ts`)
|
||||
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
|
||||
|
||||
**Token storage** (`token.ts`) — two-tier fallback:
|
||||
@@ -90,47 +115,52 @@ Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindT
|
||||
|
||||
### Vector Embeddings (`src/main/db/vectordb.ts`)
|
||||
|
||||
LanceDB stored in `{userData}/vectors/`. Table schema: `{ id, projectId, content, vector }`. Vectors are 1536-dimensional (`text-embedding-3-small`). Embeddings use a priority chain: Copilot CLI token → OpenAI token.
|
||||
LanceDB in `{userData}/vectors/`. Schema: `{ id, projectId, content, vector }` (1536-dim, `text-embedding-3-small` via `embeddings.ts`). Embedding priority: Copilot CLI token → OpenAI token.
|
||||
|
||||
- Note create/update fires `upsertNoteEmbedding()` (fire-and-forget, errors swallowed)
|
||||
- `migrateNotesIfNeeded()` backfills existing notes on first startup
|
||||
- `searchNotes(query, limit=5)` is called by the Knowledge agent tool
|
||||
- `upsertNoteEmbedding()` on note create/update (fire-and-forget)
|
||||
- `migrateNotesIfNeeded()` backfills on first startup
|
||||
- `searchNotes(query, limit=5)` used by Knowledge agent
|
||||
|
||||
### Key Config Notes
|
||||
### AI Approval Pattern
|
||||
|
||||
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflicts with electron-forge's externalize-deps plugin
|
||||
- `@/*` path alias resolves to `src/renderer/*` (TypeScript + Vite + shadcn/ui all share this alias)
|
||||
- shadcn/ui style: **new-york**, base color: **neutral**
|
||||
- Icons: **lucide-react** throughout — do not introduce other icon libraries
|
||||
- Tailwind 4 (not 3) — use CSS variable theming via `globals.css`, not `tailwind.config.js`
|
||||
- Notes use Milkdown (`@milkdown/crepe`) as the markdown editor (`src/renderer/components/notes/MilkdownEditor.tsx`)
|
||||
- Routes: `index`, `tasks`, `timeline`, `projects`, `notes.$noteId` (note ID is a URL param)
|
||||
Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestions appear pending user approval (dashed borders in UI).
|
||||
|
||||
## Config Notes
|
||||
|
||||
- Vite configs use `.mts` (not `.ts`) — avoids ESM/CJS conflicts with electron-forge
|
||||
- `@/*` path alias → `src/renderer/*` (TypeScript + Vite + shadcn/ui)
|
||||
- **shadcn/ui**: new-york style, neutral base color
|
||||
- **Icons**: lucide-react only — do not introduce other icon libraries
|
||||
- **Tailwind 4** — CSS variable theming in `globals.css`, no `tailwind.config.js`
|
||||
- **Notes editor**: Milkdown (`@milkdown/crepe`) at `src/renderer/components/notes/MilkdownEditor.tsx`
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
Freelancers and solo professionals managing their own client work — projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload.
|
||||
### Target User
|
||||
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
|
||||
|
||||
### Brand Personality
|
||||
**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified.
|
||||
### Brand
|
||||
**Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
|
||||
|
||||
### Aesthetic Direction
|
||||
- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent
|
||||
- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`)
|
||||
- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white
|
||||
- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs`
|
||||
- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl`
|
||||
- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions
|
||||
- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional
|
||||
### Palette
|
||||
|
||||
| | Canvas | Primary | Secondary | Borders |
|
||||
|---|---|---|---|---|
|
||||
| **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
|
||||
| **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
|
||||
|
||||
### Typography
|
||||
Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`.
|
||||
|
||||
### Visual Language
|
||||
- 10px border-radius, `rounded-2xl` for chat elements
|
||||
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency)
|
||||
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
|
||||
- No gamification (badges, streaks, confetti). Mature and professional
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped.
|
||||
|
||||
2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker.
|
||||
|
||||
3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe.
|
||||
|
||||
4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships.
|
||||
|
||||
5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises.
|
||||
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
|
||||
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as AI marker
|
||||
3. **Warmth in restraint** — Warm palette feels approachable without being playful. Dark mode trades warmth for focus
|
||||
4. **Motion with purpose** — Animations reinforce spatial relationships, never decorative
|
||||
5. **Confidence through consistency** — CSS variable tokens, shadcn/ui primitives, Geist font. Predictable, keyboard-first
|
||||
|
||||
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add AI_REFACTOR_PLAN.md)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
306
AI_REFACTOR_PLAN.md
Normal file
306
AI_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# AI Refactor Plan — Adiuva Electron App
|
||||
|
||||
> **Objective:** Transform the Electron app into a backend-powered client. All AI intelligence (chat, tool calling, embeddings) lives on the backend. The Electron app owns the local database, executes structured CRUD operations from backend tools via Drizzle ORM, and handles auth, backup, and offline graceful degradation.
|
||||
>
|
||||
> **Backend:** Lives at `../adiuva-api/`. FastAPI + LiteLLM + 4 chat agents (task, checkpoint, project, note). Backend plan: `../adiuva-api/AI_REFACTOR_PLAN.md`.
|
||||
>
|
||||
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Renderer (React 19) ──ipcLink──► Main (tRPC + SQLite) ──HTTP/WS──► Backend (FastAPI + LiteLLM)
|
||||
UI only Data + Drizzle executor All AI intelligence
|
||||
```
|
||||
|
||||
**Data flow for chat (bidirectional WebSocket):**
|
||||
1. User types message in renderer → tRPC `ai.chat` mutation
|
||||
2. Main process builds `ChatContext` (queries SQLite for tasks, notes, profile)
|
||||
3. Main opens WS to backend `/api/v1/chat/stream?token=<jwt>`, sends `chat_request` frame
|
||||
4. Backend classifies intent → routes to agent → agent calls LLM with tools
|
||||
5. LLM calls a tool (e.g. `list_tasks`) → tool calls `execute_on_client()`:
|
||||
- Backend sends `tool_call` frame: `{id, action:"select", table:"tasks", filters:{...}}`
|
||||
- Electron receives frame → Drizzle executor: `db.select().from(tasks).where(...)` → real rows
|
||||
- Electron sends `tool_result` frame: `{id, rows: [{id, title, ...}, ...]}`
|
||||
- Tool receives real data → returns formatted string to LLM
|
||||
6. Steps 5 repeats (max 5 iterations) until LLM has enough data to respond
|
||||
7. Backend streams response text → `text_chunk` frames → main forwards via `ai:stream` IPC → renderer
|
||||
8. Backend sends `final` frame: `{"done": true, "response": "..."}`
|
||||
|
||||
**No local LLM.** When offline, AI features show "You're offline" — all other features (tasks, notes, projects) work normally.
|
||||
|
||||
---
|
||||
|
||||
## WS Protocol — Typed Frames
|
||||
|
||||
| Direction | `type` | Payload |
|
||||
|---|---|---|
|
||||
| Client → Server | `chat_request` | `{ message, context }` |
|
||||
| Server → Client | `text_chunk` | `{ text: string }` |
|
||||
| Server → Client | `tool_call` | `{ id, action, table?, data?, filters?, vector?, limit? }` |
|
||||
| Client → Server | `tool_result` | `{ id, row?, rows?, results?, deleted?, ok?, error? }` |
|
||||
| Server → Client | `final` | `{ response: string }` |
|
||||
| Server → Client | `ping` | `{}` |
|
||||
|
||||
**Tool call actions (Electron → Drizzle mapping):**
|
||||
|
||||
| `action` | Drizzle call | Returns |
|
||||
|---|---|---|
|
||||
| `select` | `db.select().from(table).where(filters).all()` | `{ rows: [...] }` |
|
||||
| `get` | `db.select().from(table).where(eq(id, ...)).get()` | `{ row: {...} \| null }` |
|
||||
| `insert` | `db.insert(table).values({id: uuid(), ...data, createdAt: now()}).returning().get()` | `{ row: {...} }` |
|
||||
| `update` | `db.update(table).set(data.updates).where(eq(id,...)).returning().get()` | `{ row: {...} }` |
|
||||
| `delete` | `db.delete(table).where(eq(id,...)).run()` | `{ deleted: true }` |
|
||||
| `vector_upsert` | LanceDB delete-then-add with pre-computed vector | `{ ok: true }` |
|
||||
| `vector_search` | LanceDB `table.search(vector).limit(n)` | `{ results: [{id, content, score}...] }` |
|
||||
|
||||
Electron generates `id` (UUID v4) and `createdAt`/`updatedAt` (Unix ms) for inserts. Backend never generates IDs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — API Contracts & Types ✅
|
||||
|
||||
### Step 0.1 — Define backend API contract types ✅
|
||||
- [x] Create `src/shared/api-types.ts` with Zod schemas + inferred types
|
||||
- [x] Create `src/shared/batch-types.ts` with batch builder + storage types
|
||||
- [x] Update `tsconfig.json` paths — added `@shared/*` alias
|
||||
- **Outcome:** Type-safe contracts for all backend communication.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Auth & Backend Client
|
||||
|
||||
### Step 1.1 — Align shared types with backend schemas
|
||||
- [x] Update `src/shared/api-types.ts` to match backend `app/schemas.py` exactly:
|
||||
- `AuthTokens.expiresAt`: change from `z.string().datetime()` to `z.number().int()` (Unix epoch)
|
||||
- `ChatContext`: replace with backend's flat structure — `{ userProfile, relevantDocuments, recentTasks, conversationHistory }`; remove UI-only fields (`type`, `projectId`, `uiContext`)
|
||||
- Remove `PlanAction` entirely — no more action descriptors
|
||||
- `ChatResponse`: just `{ response: string }` — no `actions` array
|
||||
- Align `PlanStep` / `ExecutionPlan` with backend or remove if plan mode is deferred
|
||||
- [x] Add WebSocket frame Zod schemas:
|
||||
- `ToolCallAction` enum: `select`, `get`, `insert`, `update`, `delete`, `vector_upsert`, `vector_search`
|
||||
- `WsToolCall`: `{ type: "tool_call", id: string, action, table?, data?, filters?, vector?, limit? }`
|
||||
- `WsToolResult`: `{ type: "tool_result", id: string, row?, rows?, results?, deleted?, ok?, error? }`
|
||||
- `WsTextChunk`, `WsFinal`, `WsPing`, `WsChatRequest`
|
||||
- `WsServerFrame` / `WsClientFrame` discriminated unions
|
||||
- [x] Create `src/shared/casing.ts`:
|
||||
- `toSnakeCase(obj)` — deep-converts camelCase keys to snake_case (outgoing)
|
||||
- `toCamelCase(obj)` — deep-converts snake_case keys to camelCase (incoming)
|
||||
- [x] Create `UIChatContext` type in `src/renderer/hooks/useAIChat.ts` for renderer-only fields
|
||||
- **Files:** `src/shared/api-types.ts`, `src/shared/casing.ts`, `src/renderer/hooks/useAIChat.ts`
|
||||
- **Outcome:** Shared types match the live backend 1:1. WS frames are fully typed.
|
||||
|
||||
### Step 1.2 — Auth manager + tRPC procedures
|
||||
- [x] Create `src/main/auth/auth-manager.ts`:
|
||||
- `AuthManager` class (singleton):
|
||||
- `register(email, password): Promise<AuthTokens>` — POST `/api/v1/auth/register`
|
||||
- `login(email, password): Promise<AuthTokens>` — POST `/api/v1/auth/login`
|
||||
- `logout(): void` — clears stored tokens
|
||||
- `getAccessToken(): string | null` — current JWT
|
||||
- `refreshToken(): Promise<void>` — POST `/api/v1/auth/refresh`
|
||||
- `isAuthenticated(): boolean`
|
||||
- `getProfile(): Promise<UserProfile>` — GET `/api/v1/auth/me`
|
||||
- Token storage: reuse `src/main/ai/token.ts` (`safeStorage` + electron-store fallback)
|
||||
- Auto-refresh: check token expiry on every `getAccessToken()` call; if < 5 min remaining, refresh in background
|
||||
- [x] Add `authRouter` tRPC sub-router to `src/main/router/index.ts`
|
||||
- [x] Update `src/main/store.ts`: add `backendUrl: string`
|
||||
- **Files:** `src/main/auth/auth-manager.ts`, `src/main/router/index.ts`, `src/main/store.ts`
|
||||
- **Outcome:** Electron can authenticate with the backend. JWTs stored securely.
|
||||
|
||||
### Step 1.3 — Backend client with bidirectional WebSocket
|
||||
- [x] Create `src/main/api/backend-client.ts`:
|
||||
- `BackendClient` class (singleton):
|
||||
- Constructor: reads `backendUrl` from store, gets JWT from `AuthManager`
|
||||
- `chatStream(request: ChatRequest, onChunk: (text: string) => void): Promise<ChatResponse>`:
|
||||
1. Opens WS to `/api/v1/chat/stream?token=<jwt>`
|
||||
2. Sends `{ type: "chat_request", ... }` frame
|
||||
3. Message loop:
|
||||
- `text_chunk` → calls `onChunk(text)`
|
||||
- `tool_call` → calls `DrizzleExecutor.execute(payload)`, sends back `{ type: "tool_result", id, ... }`
|
||||
- `final` → resolves with `{ response }`
|
||||
- `ping` → ignore
|
||||
- `isOnline(): Promise<boolean>` — GET `/api/v1/health` with 3s timeout
|
||||
- `embedText(text: string): Promise<number[]>` — POST `/api/v1/storage/vectors/embed`
|
||||
- All requests include `Authorization: Bearer <jwt>` header
|
||||
- Auto-retry with exponential backoff (max 3 attempts) for non-auth errors
|
||||
- Response parsing: `toCamelCase()` on all incoming JSON
|
||||
- Request serialization: `toSnakeCase()` on all outgoing JSON
|
||||
- Error categorization: 401 → `AuthExpiredError`, 429 → `RateLimitError`, 5xx → `ServerError`, timeout → `OfflineError`
|
||||
- **Files:** `src/main/api/backend-client.ts`
|
||||
- **Outcome:** Type-safe HTTP + bidirectional WS client. Tool calls handled in the message loop.
|
||||
|
||||
### Step 1.4 — Drizzle executor (the dumb Electron layer)
|
||||
- [x] Create `src/main/api/drizzle-executor.ts`:
|
||||
- Table registry: map string names → Drizzle table objects from `src/main/db/schema.ts`:
|
||||
```
|
||||
{ tasks, projects, clients, checkpoints, notes, taskComments }
|
||||
```
|
||||
- `execute(payload): Promise<object>` — dispatches on `payload.action`:
|
||||
- **`select`**: `db.select().from(table)` + build `.where()` from `payload.filters` using Drizzle `eq()`/`and()`/`like()` + optional `.orderBy()` → returns `{ rows }`
|
||||
- **`get`**: `db.select().from(table).where(eq(table.id, payload.data.id)).get()` → returns `{ row }`
|
||||
- **`insert`**: `db.insert(table).values({id: crypto.randomUUID(), ...payload.data, createdAt: Date.now()}).returning().get()` → returns `{ row }`
|
||||
- **`update`**: `db.update(table).set(payload.data.updates).where(eq(table.id, payload.data.id)).returning().get()` → returns `{ row }`
|
||||
- **`delete`**: `db.delete(table).where(eq(table.id, payload.data.id)).run()` → returns `{ deleted: true }`
|
||||
- **`vector_upsert`**: calls `upsertWithVector()` from `vectordb.ts` with pre-computed vector → returns `{ ok: true }`
|
||||
- **`vector_search`**: LanceDB `table.search(payload.vector).limit(payload.limit)` → returns `{ results }`
|
||||
- Filter builder: maps `{key: value}` objects → Drizzle `and(eq(table[key], value), ...)`. Special cases:
|
||||
- `null` value → `isNull(table[key])`
|
||||
- `search` key → `like(table.title, '%value%')` or `like(table.content, '%value%')`
|
||||
- `orderBy` key → `.orderBy(asc(table[field]))` or `.orderBy(desc(...))`
|
||||
- `includeArchived: false` → adds `eq(table.status, 'active')` filter
|
||||
- `dueDateFrom`/`dueDateTo` → `between(table.dueDate, from, to)`
|
||||
- Security: validate `table` against registry (reject unknown), validate `action` against enum
|
||||
- Uses `getDb()` from `src/main/db/index.ts` — same Drizzle instance as everywhere else
|
||||
- **Files:** `src/main/api/drizzle-executor.ts`
|
||||
- **Outcome:** ~120 lines. Backend sends structured ops, Electron maps to Drizzle. No SQL building.
|
||||
|
||||
### Step 1.5 — Refactor orchestrator to delegate to backend
|
||||
- [ ] Replace `src/main/ai/orchestrator.ts` entirely (996 lines → ~80 lines):
|
||||
- `orchestrate({ message, context, sender })`:
|
||||
1. Check `BackendClient.isOnline()` — if offline, return `{ response: '', error: 'You are offline.' }`
|
||||
2. Check `AuthManager.isAuthenticated()` — if not, return `{ response: '', error: 'Please log in.' }`
|
||||
3. Build `ChatContext` from local SQLite (userProfile, recentTasks, conversationHistory)
|
||||
4. Call `BackendClient.chatStream(request, chunk => sendStreamChunk(sender, chunk, false))`
|
||||
- `tool_call` frames handled inside the WS message loop (Step 1.3)
|
||||
5. On completion: `sendStreamChunk(sender, '', true)`
|
||||
- No PlanRunner, no action handling — writes happen mid-conversation via tool calls
|
||||
- Keep `sendStreamChunk()` IPC helper
|
||||
- Export `orchestrate()` and `dailyBrief()`
|
||||
- [ ] Update `aiRouter` in `src/main/router/index.ts`:
|
||||
- Remove `setToken` mutation and `hasToken` query (replaced by `auth.status`)
|
||||
- Keep `chat` mutation (same interface) and `dailyBrief`
|
||||
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
|
||||
- Replace `ChatContext` with `UIChatContext` (renderer-only type)
|
||||
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/router/index.ts`, `src/renderer/hooks/useAIChat.ts`
|
||||
- **Outcome:** ~916 lines removed. Chat works through backend. All tool execution is bidirectional.
|
||||
|
||||
### Step 1.6 — Migrate embeddings to backend
|
||||
- [ ] Update `src/main/db/vectordb.ts`:
|
||||
- Add `upsertWithVector(noteId, projectId, content, vector)` — takes pre-computed vector, stores in LanceDB
|
||||
- Update `upsertNoteEmbedding()` → calls `BackendClient.embedText(content)` → `upsertWithVector()`
|
||||
- Keep `searchNotes()` and `migrateNotesIfNeeded()` (migration will call backend for embeddings)
|
||||
- If offline: skip embedding (next edit will re-embed when online)
|
||||
- [ ] Delete `src/main/ai/embeddings.ts`
|
||||
- **Files:** `src/main/db/vectordb.ts`, `src/main/ai/embeddings.ts` (deleted)
|
||||
- **Outcome:** Embeddings generated by backend `/vectors/embed`. Local LanceDB for storage + search.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Remove Local AI Stack
|
||||
|
||||
### Step 2.1 — Remove local AI code and dependencies
|
||||
- [ ] Delete `src/main/ai/llm.ts`, `src/main/ai/chat-copilot.ts`, `src/main/ai/copilot.ts`, `src/main/ai/provider.ts`
|
||||
- [ ] Remove `import './ai/copilot'` and `initAI()` from `src/main/index.ts`
|
||||
- [ ] Remove deps: `@langchain/core`, `@langchain/openai`, `@langchain/anthropic`, `@langchain/langgraph`, `@github/copilot-sdk`
|
||||
- [ ] Clean up `src/main/store.ts` (remove `aiProvider`, `encryptedTokens`)
|
||||
- [ ] Clean up `vite.main.config.mts` (remove externalized LangChain/Copilot packages)
|
||||
- **Files:** Multiple deletions in `src/main/ai/`, `package.json`, `src/main/index.ts`, `src/main/store.ts`, `vite.main.config.mts`
|
||||
- **Outcome:** ~1400 lines removed. No LangChain, no Copilot SDK, no local LLM.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Local Plugin System & Batch Agents
|
||||
|
||||
### Step 3.1 — Plugin manifest system and permission manager
|
||||
- [ ] Create `src/main/permissions/manifest-validator.ts` + `permission-manager.ts`
|
||||
- [ ] Add `plugin_permissions` and `plugin_activity_log` tables to schema
|
||||
- **Outcome:** Granular, opt-in permission system.
|
||||
|
||||
### Step 3.2 — Worker pool and batch runner
|
||||
- [ ] Create `src/main/workers/worker-pool.ts`, `batch-runner.ts`, `plugin-worker.ts`
|
||||
- [ ] Plugins call `BackendClient` for AI analysis (not local LLM)
|
||||
- **Outcome:** Isolated plugin execution with backend-powered AI.
|
||||
|
||||
### Step 3.3 — Implement batch agent plugins
|
||||
- [ ] `src/plugins/email-scanner.ts`, `file-watcher.ts`, `calendar-sync.ts`, `browser-agent.ts`
|
||||
- **Outcome:** Four batch agents using backend for AI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security: E2E Backup & Offline
|
||||
|
||||
### Step 4.1 — E2E encrypted backup
|
||||
- [ ] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
|
||||
- **Outcome:** User data never leaves the device unencrypted.
|
||||
|
||||
### Step 4.2 — Offline sync queue
|
||||
- [ ] `src/main/backup/sync-queue.ts` + `sync_queue` table
|
||||
- **Outcome:** Queued actions auto-sync when online.
|
||||
|
||||
### Step 4.3 — Migrate to SQLCipher
|
||||
- [ ] Replace `better-sqlite3` with `@journeyapps/sqlcipher`
|
||||
- **Outcome:** All local data encrypted at rest.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Shared Memory
|
||||
|
||||
### Step 5.1 — Three-tier local memory
|
||||
- [ ] `src/main/database/shared-memory.ts`: conversation buffer + agent KV store + multi-collection vector store
|
||||
- [ ] `agent_memory` table in schema
|
||||
- **Outcome:** Short-term, long-term, and semantic memory.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Renderer UI Updates
|
||||
|
||||
### Step 6.1 — Auth UI + settings restructure
|
||||
- [ ] `LoginForm.tsx`, auth gate in AppShell, `SettingsPage.tsx` (Account, Backup, Permissions tabs — no AI Providers tab)
|
||||
|
||||
### Step 6.2 — ChatPage with context panel
|
||||
- [ ] `ChatPage.tsx`, `ChatWindow.tsx`, `MessageBubble.tsx`, `ContextPanel.tsx`
|
||||
|
||||
### Step 6.3 — BatchBuilderPage
|
||||
- [ ] Natural language input, config preview, connector/storage/schedule pickers, batch cards, test runner
|
||||
|
||||
### Step 6.4 — PluginStorePage
|
||||
- [ ] Marketplace + installed tabs, permission dialog on install
|
||||
|
||||
### Step 6.5 — DataManagerPage
|
||||
- [ ] Storage overview, per-source cards, migration wizard
|
||||
|
||||
### Step 6.6 — ActivityLogPage
|
||||
- [ ] Filterable activity table with CSV export
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Cleanup & Hardening
|
||||
|
||||
### Step 7.1 — Error handling and logging
|
||||
### Step 7.2 — Integration tests
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `ws` | WebSocket client for backend streaming |
|
||||
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
|
||||
| `argon2` | Key derivation for E2E backup |
|
||||
| `node-cron` | Batch agent scheduling |
|
||||
| `chokidar` | File watching (plugin) |
|
||||
| `imapflow` | IMAP client (plugin) |
|
||||
|
||||
## Dependencies to Remove
|
||||
|
||||
| Package | Reason |
|
||||
|---|---|
|
||||
| `@langchain/core` | No local LLM |
|
||||
| `@langchain/openai` | No local LLM |
|
||||
| `@langchain/anthropic` | No local LLM |
|
||||
| `@langchain/langgraph` | No local orchestrator |
|
||||
| `@github/copilot-sdk` | No local Copilot |
|
||||
|
||||
---
|
||||
|
||||
## Execution Notes
|
||||
|
||||
- **Phase 1 is the critical path.** Auth + backend client + drizzle executor + orchestrator refactor must land first.
|
||||
- **Steps 1.1–1.4 are additive** — existing app keeps working until Step 1.5 swaps the orchestrator.
|
||||
- **Step 2.1 is the point of no return** — after removing LangChain, there's no local AI fallback.
|
||||
- **Phase B (backend changes) must land before Phase 1.3–1.5** — Electron needs the bidirectional WS to talk to.
|
||||
- **Phase 3 and Phase 4 are independent** — can be parallelized after Phase 2.
|
||||
- **One step at a time.** Mark `[x]` and commit with `step N.N complete: <outcome>`.
|
||||
File diff suppressed because it is too large
Load Diff
34
package-lock.json
generated
34
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"@trpc/client": "^11.10.0",
|
||||
"@trpc/react-query": "^11.10.0",
|
||||
"@trpc/server": "^11.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -42,6 +43,7 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vectordb": "^0.21.2",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -8237,7 +8239,6 @@
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -8332,6 +8333,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -23508,7 +23518,6 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
@@ -24387,6 +24396,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@trpc/client": "^11.10.0",
|
||||
"@trpc/react-query": "^11.10.0",
|
||||
"@trpc/server": "^11.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -81,6 +82,7 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vectordb": "^0.21.2",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ interface OrchestrateInput {
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Superseded by `ChatResponse` from `@shared/api-types`.
|
||||
* Will be replaced during Step 3.2 when the orchestrator delegates to the backend.
|
||||
*/
|
||||
interface OrchestrateResult {
|
||||
response: string;
|
||||
error?: string;
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function setToken(providerName: string, token: string): Promise<voi
|
||||
}
|
||||
|
||||
/** Delete a stored token for the given provider. */
|
||||
async function deleteToken(providerName: string): Promise<boolean> {
|
||||
export async function deleteToken(providerName: string): Promise<boolean> {
|
||||
removeFromStore(providerName);
|
||||
return true;
|
||||
}
|
||||
|
||||
327
src/main/api/backend-client.ts
Normal file
327
src/main/api/backend-client.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Backend client — HTTP + bidirectional WebSocket communication with the
|
||||
* Adiuva backend API.
|
||||
*
|
||||
* Singleton. All AI intelligence lives on the backend; this client is the
|
||||
* Electron-side transport layer.
|
||||
*
|
||||
* Protocol:
|
||||
* - Chat: WS `/api/v1/chat/stream?token=<jwt>`
|
||||
* Client sends `chat_request` → Server streams `text_chunk` / `tool_call` /
|
||||
* `final` / `ping` frames → Client handles `tool_call` via DrizzleExecutor
|
||||
* and sends back `tool_result`.
|
||||
*
|
||||
* - Embeddings: POST `/api/v1/storage/vectors/embed`
|
||||
* - Health: GET `/api/v1/health`
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.3
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { getStore } from '../store';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import { toSnakeCase, toCamelCase } from '../../shared/casing';
|
||||
import {
|
||||
WsServerFrameSchema,
|
||||
ChatResponseSchema,
|
||||
} from '../../shared/api-types';
|
||||
import type { ChatRequest, ChatResponse, WsToolResult } from '../../shared/api-types';
|
||||
import { DrizzleExecutor } from './drizzle-executor';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 3_000;
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
/** Base delay for exponential backoff (ms). */
|
||||
const RETRY_BASE_MS = 500;
|
||||
/** Maximum iterations the backend may request before we force-close. */
|
||||
const MAX_TOOL_ITERATIONS = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OfflineError extends Error {
|
||||
constructor(message = 'Backend is unreachable') {
|
||||
super(message);
|
||||
this.name = 'OfflineError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthExpiredError extends Error {
|
||||
constructor(message = 'Authentication expired') {
|
||||
super(message);
|
||||
this.name = 'AuthExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(message = 'Rate limit exceeded') {
|
||||
super(message);
|
||||
this.name = 'RateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackendClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BackendClient {
|
||||
private static instance: BackendClient | null = null;
|
||||
private executor = new DrizzleExecutor();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): BackendClient {
|
||||
if (!this.instance) {
|
||||
this.instance = new BackendClient();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// URL helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private get baseUrl(): string {
|
||||
return getStore().get('backendUrl');
|
||||
}
|
||||
|
||||
/** Convert http(s):// base URL to ws(s):// for WebSocket connections. */
|
||||
private get wsBaseUrl(): string {
|
||||
return this.baseUrl.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Health check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Returns `true` if the backend responds within 3 s, `false` otherwise. */
|
||||
async isOnline(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/health`, {
|
||||
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Embeddings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Request a text embedding from the backend. Returns a vector. */
|
||||
async embedText(text: string): Promise<number[]> {
|
||||
return this.withRetry(async () => {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/storage/vectors/embed`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(toSnakeCase({ text })),
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
await this.assertHttpOk(res);
|
||||
const data = toCamelCase<{ vector: number[] }>(await res.json());
|
||||
return data.vector;
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Chat stream (bidirectional WebSocket)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open a WebSocket to the backend, stream chat, and handle tool calls
|
||||
* in-band via the Drizzle executor.
|
||||
*
|
||||
* @param request The enriched chat request (context includes DB snapshot).
|
||||
* @param onChunk Called with each text token as it streams in.
|
||||
* @returns The final response once the backend sends a `final` frame.
|
||||
*/
|
||||
async chatStream(
|
||||
request: ChatRequest,
|
||||
onChunk: (text: string) => void,
|
||||
): Promise<ChatResponse> {
|
||||
return this.withRetry(
|
||||
async () => {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
return this.openChatWebSocket(request, token, onChunk);
|
||||
},
|
||||
// Don't retry auth failures
|
||||
(err) => !(err instanceof AuthExpiredError),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private openChatWebSocket(
|
||||
request: ChatRequest,
|
||||
token: string,
|
||||
onChunk: (text: string) => void,
|
||||
): Promise<ChatResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `${this.wsBaseUrl}/api/v1/chat/stream?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let toolIteration = 0;
|
||||
let settled = false;
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
if (err) reject(err);
|
||||
};
|
||||
|
||||
ws.on('open', () => {
|
||||
const frame = toSnakeCase({
|
||||
type: 'chat_request',
|
||||
message: request.message,
|
||||
context: request.context,
|
||||
});
|
||||
ws.send(JSON.stringify(frame));
|
||||
});
|
||||
|
||||
ws.on('message', (raw: Buffer | string) => {
|
||||
const text = typeof raw === 'string' ? raw : raw.toString('utf8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return; // Malformed frame — ignore
|
||||
}
|
||||
|
||||
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
|
||||
if (!frame.success) return; // Unknown frame type — ignore
|
||||
|
||||
switch (frame.data.type) {
|
||||
case 'text_chunk':
|
||||
onChunk(frame.data.text);
|
||||
break;
|
||||
|
||||
case 'tool_call': {
|
||||
if (++toolIteration > MAX_TOOL_ITERATIONS) {
|
||||
finish(new ServerError('Exceeded maximum tool call iterations', 500));
|
||||
return;
|
||||
}
|
||||
const toolCall = frame.data;
|
||||
// Fire-and-forget async; errors are sent back as tool_result.error
|
||||
void (async () => {
|
||||
let result: WsToolResult;
|
||||
try {
|
||||
const output = await this.executor.execute(toolCall);
|
||||
result = { type: 'tool_result', id: toolCall.id, ...output } as WsToolResult;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Executor error';
|
||||
result = { type: 'tool_result', id: toolCall.id, error: msg };
|
||||
}
|
||||
if (!settled) {
|
||||
ws.send(JSON.stringify(toSnakeCase(result)));
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'final': {
|
||||
const validated = ChatResponseSchema.safeParse({ response: frame.data.response });
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
if (validated.success) {
|
||||
resolve(validated.data);
|
||||
} else {
|
||||
resolve({ response: frame.data.response });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
// No-op — keep-alive from server
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
finish(new OfflineError(`WebSocket error: ${err.message}`));
|
||||
});
|
||||
|
||||
ws.on('close', (code: number) => {
|
||||
if (!settled) {
|
||||
finish(new OfflineError(`WebSocket closed unexpectedly (code ${code})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async assertHttpOk(res: Response): Promise<void> {
|
||||
if (res.ok) return;
|
||||
const body = await res.text().catch(() => '');
|
||||
const msg = `${res.status} ${res.statusText}${body ? `: ${body}` : ''}`;
|
||||
if (res.status === 401) throw new AuthExpiredError(msg);
|
||||
if (res.status === 429) throw new RateLimitError(msg);
|
||||
if (res.status >= 500) throw new ServerError(msg, res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Retry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
shouldRetry: (err: unknown) => boolean = () => true,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (!shouldRetry(err)) break;
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
const delay = RETRY_BASE_MS * 2 ** attempt;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getBackendClient(): BackendClient {
|
||||
return BackendClient.getInstance();
|
||||
}
|
||||
300
src/main/api/drizzle-executor.ts
Normal file
300
src/main/api/drizzle-executor.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Drizzle executor — the "dumb" local data layer.
|
||||
*
|
||||
* Receives structured `WsToolCall` frames from the backend WebSocket and maps
|
||||
* them to Drizzle ORM calls against the local SQLite database.
|
||||
*
|
||||
* Security: table name and action are validated against an allowlist before
|
||||
* any database operation is performed. The backend never generates IDs —
|
||||
* the executor generates UUID v4 + timestamps for all inserts.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.4
|
||||
*/
|
||||
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, SQL } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, checkpoints, notes, taskComments } from '../db/schema';
|
||||
import { searchNotes, upsertWithVector } from '../db/vectordb';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table registry — the only tables the backend may touch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABLE_REGISTRY = {
|
||||
tasks,
|
||||
projects,
|
||||
clients,
|
||||
checkpoints,
|
||||
notes,
|
||||
taskComments,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
type AnyTable = (typeof TABLE_REGISTRY)[TableName];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ExecutorError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ExecutorError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reserved filter keys that are not direct column matchers. */
|
||||
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'dueDateFrom', 'dueDateTo']);
|
||||
|
||||
function buildConditions(
|
||||
table: AnyTable,
|
||||
filters: Record<string, unknown>,
|
||||
): SQL[] {
|
||||
const conditions: SQL[] = [];
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (RESERVED_KEYS.has(key)) continue;
|
||||
|
||||
const col = tbl[key];
|
||||
if (!col) continue; // Unknown column — skip silently
|
||||
|
||||
if (value === null) {
|
||||
conditions.push(isNull(col as Parameters<typeof isNull>[0]));
|
||||
} else {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// Search across title and/or content
|
||||
if (filters['search'] != null) {
|
||||
const pattern = `%${String(filters['search'])}%`;
|
||||
const titleCol = tbl['title'];
|
||||
const contentCol = tbl['content'];
|
||||
if (titleCol && contentCol) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(titleCol as Parameters<typeof like>[0], pattern),
|
||||
like(contentCol as Parameters<typeof like>[0], pattern),
|
||||
)!,
|
||||
);
|
||||
} else if (titleCol) {
|
||||
conditions.push(like(titleCol as Parameters<typeof like>[0], pattern));
|
||||
} else if (contentCol) {
|
||||
conditions.push(like(contentCol as Parameters<typeof like>[0], pattern));
|
||||
}
|
||||
}
|
||||
|
||||
// includeArchived: false → restrict to active status
|
||||
if (filters['includeArchived'] === false) {
|
||||
const statusCol = tbl['status'];
|
||||
if (statusCol) {
|
||||
conditions.push(eq(statusCol as Parameters<typeof eq>[0], 'active'));
|
||||
}
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if (filters['dueDateFrom'] != null) {
|
||||
const dateCol = tbl['dueDate'];
|
||||
if (dateCol) {
|
||||
conditions.push(gte(dateCol as Parameters<typeof gte>[0], Number(filters['dueDateFrom'])));
|
||||
}
|
||||
}
|
||||
if (filters['dueDateTo'] != null) {
|
||||
const dateCol = tbl['dueDate'];
|
||||
if (dateCol) {
|
||||
conditions.push(lte(dateCol as Parameters<typeof lte>[0], Number(filters['dueDateTo'])));
|
||||
}
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
function buildOrderBy(
|
||||
table: AnyTable,
|
||||
filters: Record<string, unknown>,
|
||||
): SQL | undefined {
|
||||
const field = filters['orderBy'];
|
||||
if (!field || typeof field !== 'string') return undefined;
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const col = tbl[field];
|
||||
if (!col) return undefined;
|
||||
|
||||
const dir = filters['orderDir'];
|
||||
return dir === 'desc'
|
||||
? desc(col as Parameters<typeof desc>[0])
|
||||
: asc(col as Parameters<typeof asc>[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class DrizzleExecutor {
|
||||
private getTable(name: string): AnyTable {
|
||||
if (!(name in TABLE_REGISTRY)) {
|
||||
throw new ExecutorError(`Unknown table: "${name}". Allowed: ${Object.keys(TABLE_REGISTRY).join(', ')}`);
|
||||
}
|
||||
return TABLE_REGISTRY[name as TableName];
|
||||
}
|
||||
|
||||
async execute(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { action } = payload;
|
||||
|
||||
switch (action) {
|
||||
case 'select':
|
||||
return this.handleSelect(payload);
|
||||
case 'get':
|
||||
return this.handleGet(payload);
|
||||
case 'insert':
|
||||
return this.handleInsert(payload);
|
||||
case 'update':
|
||||
return this.handleUpdate(payload);
|
||||
case 'delete':
|
||||
return this.handleDelete(payload);
|
||||
case 'vector_upsert':
|
||||
return this.handleVectorUpsert(payload);
|
||||
case 'vector_search':
|
||||
return this.handleVectorSearch(payload);
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleSelect(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const filters = (payload.filters ?? {}) as Record<string, unknown>;
|
||||
const conditions = buildConditions(table, filters);
|
||||
const orderBy = buildOrderBy(table, filters);
|
||||
|
||||
const query = getDb().select().from(table);
|
||||
const withWhere = conditions.length > 0
|
||||
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
|
||||
: query;
|
||||
|
||||
const rows = orderBy
|
||||
? withWhere.orderBy(orderBy).all()
|
||||
: withWhere.all();
|
||||
|
||||
return { rows };
|
||||
}
|
||||
|
||||
private handleGet(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const id = data['id'];
|
||||
if (!id) throw new ExecutorError('"data.id" is required for get');
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
const row = getDb().select().from(table).where(eq(idCol, id as string)).get() ?? null;
|
||||
|
||||
return { row };
|
||||
}
|
||||
|
||||
private handleInsert(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const now = Date.now();
|
||||
|
||||
const values = {
|
||||
id: crypto.randomUUID(),
|
||||
...data,
|
||||
createdAt: now,
|
||||
// Set updatedAt for tables that have it (notes)
|
||||
...(('updatedAt' in table) ? { updatedAt: now } : {}),
|
||||
};
|
||||
|
||||
const row = getDb().insert(table).values(values).returning().get() ?? null;
|
||||
return { row };
|
||||
}
|
||||
|
||||
private handleUpdate(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const id = data['id'];
|
||||
if (!id) throw new ExecutorError('"data.id" is required for update');
|
||||
|
||||
const updates = (data['updates'] ?? {}) as Record<string, unknown>;
|
||||
const now = Date.now();
|
||||
|
||||
// Auto-set updatedAt for tables that have it
|
||||
const withTimestamp = ('updatedAt' in table)
|
||||
? { ...updates, updatedAt: now }
|
||||
: updates;
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
const row = getDb()
|
||||
.update(table)
|
||||
.set(withTimestamp)
|
||||
.where(eq(idCol, id as string))
|
||||
.returning()
|
||||
.get() ?? null;
|
||||
|
||||
return { row };
|
||||
}
|
||||
|
||||
private handleDelete(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const id = data['id'];
|
||||
if (!id) throw new ExecutorError('"data.id" is required for delete');
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
getDb().delete(table).where(eq(idCol, id as string)).run();
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
private async handleVectorUpsert(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const noteId = data['noteId'] as string;
|
||||
const projectId = (data['projectId'] as string | null) ?? null;
|
||||
const content = data['content'] as string;
|
||||
const vector = payload.vector;
|
||||
|
||||
if (!noteId || !content || !vector) {
|
||||
throw new ExecutorError('vector_upsert requires data.noteId, data.content, and vector');
|
||||
}
|
||||
|
||||
await upsertWithVector(noteId, projectId, content, vector);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async handleVectorSearch(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const vec = payload.vector;
|
||||
const limit = payload.limit ?? 5;
|
||||
|
||||
if (!vec) {
|
||||
throw new ExecutorError('vector_search requires a vector');
|
||||
}
|
||||
|
||||
// searchNotes accepts a query string and embeds it — for pre-vectorised search
|
||||
// we call LanceDB directly via the vectordb internals. For now, use the
|
||||
// payload data.query string as a fallback. A full implementation will be
|
||||
// wired up in Step 1.6 when embedText is migrated to the backend.
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const query = (data['query'] as string | undefined) ?? '';
|
||||
|
||||
const rawResults = await searchNotes(query, limit);
|
||||
const results = rawResults.map((r) => ({
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
score: 1 - r._distance, // Convert distance to similarity score
|
||||
}));
|
||||
|
||||
return { results };
|
||||
}
|
||||
}
|
||||
243
src/main/auth/auth-manager.ts
Normal file
243
src/main/auth/auth-manager.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Auth manager — handles registration, login, token refresh, and profile
|
||||
* retrieval against the Adiuva backend API.
|
||||
*
|
||||
* Singleton. Tokens are persisted via the two-tier storage in `token.ts`
|
||||
* (safeStorage + electron-store fallback).
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.2
|
||||
*/
|
||||
|
||||
import { getStore } from '../store';
|
||||
import { getToken, setToken, deleteToken } from '../ai/token';
|
||||
import { toCamelCase, toSnakeCase } from '../../shared/casing';
|
||||
import { AuthTokensSchema, UserProfileSchema } from '../../shared/api-types';
|
||||
import type { AuthTokens, UserProfile } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Token key names in the encrypted store. */
|
||||
const TOKEN_KEYS = {
|
||||
access: 'auth_access',
|
||||
refresh: 'auth_refresh',
|
||||
/** Stored as string representation of Unix-epoch seconds. */
|
||||
expiresAt: 'auth_expires_at',
|
||||
} as const;
|
||||
|
||||
/** Refresh the access token when it expires within this window (seconds). */
|
||||
const REFRESH_WINDOW_SEC = 5 * 60; // 5 minutes
|
||||
|
||||
/** Maximum request timeout (ms). */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AuthManager {
|
||||
private static instance: AuthManager | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AuthManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new AuthManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Register a new account and store the returned tokens. */
|
||||
async register(email: string, password: string): Promise<AuthTokens> {
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/register', { email, password });
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Log in with email + password and store the returned tokens. */
|
||||
async login(email: string, password: string): Promise<AuthTokens> {
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/login', { email, password });
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Clear all stored auth tokens. */
|
||||
async logout(): Promise<void> {
|
||||
await Promise.all([
|
||||
deleteToken(TOKEN_KEYS.access),
|
||||
deleteToken(TOKEN_KEYS.refresh),
|
||||
deleteToken(TOKEN_KEYS.expiresAt),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid access token, refreshing transparently if near expiry.
|
||||
* Returns `null` if not authenticated.
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
const token = await getToken(TOKEN_KEYS.access);
|
||||
if (!token) return null;
|
||||
|
||||
// Check expiry — refresh if within the window
|
||||
const expiresAtStr = await getToken(TOKEN_KEYS.expiresAt);
|
||||
if (expiresAtStr) {
|
||||
const expiresAt = Number(expiresAtStr);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (expiresAt - nowSec < REFRESH_WINDOW_SEC) {
|
||||
// Coalesce concurrent refresh calls
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = this.refreshTokens().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.refreshPromise;
|
||||
return (await getToken(TOKEN_KEYS.access)) ?? token;
|
||||
} catch {
|
||||
// Refresh failed — return existing token (may still work)
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Whether we have stored auth tokens. */
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const token = await getToken(TOKEN_KEYS.access);
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
/** Fetch the user profile from the backend. */
|
||||
async getProfile(): Promise<UserProfile> {
|
||||
const data = await this.get<UserProfile>('/api/v1/auth/me');
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Explicitly refresh the token pair. */
|
||||
async refreshTokens(): Promise<void> {
|
||||
const refreshToken = await getToken(TOKEN_KEYS.refresh);
|
||||
if (!refreshToken) {
|
||||
throw new AuthError('No refresh token available');
|
||||
}
|
||||
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private get baseUrl(): string {
|
||||
return getStore().get('backendUrl');
|
||||
}
|
||||
|
||||
private async storeTokens(tokens: AuthTokens): Promise<void> {
|
||||
await Promise.all([
|
||||
setToken(TOKEN_KEYS.access, tokens.accessToken),
|
||||
setToken(TOKEN_KEYS.refresh, tokens.refreshToken),
|
||||
setToken(TOKEN_KEYS.expiresAt, String(tokens.expiresAt)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic POST request to the backend.
|
||||
* Outgoing body is snake_cased, incoming JSON is camelCased + Zod-parsed by caller.
|
||||
*/
|
||||
private async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const accessToken = await getToken(TOKEN_KEYS.access);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(toSnakeCase(body)),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic GET request to the backend (authenticated).
|
||||
*/
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
if (!accessToken) {
|
||||
throw new AuthError('Not authenticated');
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getAuthManager(): AuthManager {
|
||||
return AuthManager.getInstance();
|
||||
}
|
||||
@@ -80,6 +80,41 @@ export async function upsertNoteEmbedding(
|
||||
await table.add([record]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a note embedding with a **pre-computed** vector (no embedding API call).
|
||||
* Used by the DrizzleExecutor when the backend sends a `vector_upsert` tool call
|
||||
* with the vector already embedded server-side.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.6 (embedding migration)
|
||||
*/
|
||||
export async function upsertWithVector(
|
||||
noteId: string,
|
||||
projectId: string | null,
|
||||
content: string,
|
||||
vector: number[],
|
||||
): Promise<void> {
|
||||
const c = getConn();
|
||||
|
||||
const record: NoteRecord = {
|
||||
id: noteId,
|
||||
projectId: projectId ?? '',
|
||||
content,
|
||||
vector,
|
||||
};
|
||||
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (!tableNames.includes('notes')) {
|
||||
await c.createTable('notes', [record]);
|
||||
console.log('[VectorDB] Created notes table (via upsertWithVector)');
|
||||
return;
|
||||
}
|
||||
|
||||
const table = await c.openTable<NoteRecord>('notes');
|
||||
await table.delete(`id = '${noteId}'`);
|
||||
await table.add([record]);
|
||||
}
|
||||
|
||||
/**
|
||||
* On first startup, check if the LanceDB 'notes' table exists.
|
||||
* If not, embed all existing SQLite notes and populate LanceDB.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getStore } from '../store';
|
||||
import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
|
||||
import { orchestrate, dailyBrief } from '../ai/orchestrator';
|
||||
import { upsertNoteEmbedding } from '../db/vectordb';
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
@@ -546,6 +547,11 @@ const settingsRouter = router({
|
||||
});
|
||||
|
||||
const aiRouter = router({
|
||||
/**
|
||||
* Chat mutation — local orchestration.
|
||||
* The inline input schema mirrors `ChatRequest` from `@shared/api-types`.
|
||||
* Will be replaced with the shared schema in Step 3.2.
|
||||
*/
|
||||
chat: publicProcedure
|
||||
.input(z.object({
|
||||
message: z.string(),
|
||||
@@ -587,6 +593,75 @@ const aiRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth router — backend authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const authRouter = router({
|
||||
register: publicProcedure
|
||||
.input(z.object({ email: z.string().email(), password: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const auth = getAuthManager();
|
||||
await auth.register(input.email, input.password);
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Registration failed';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
login: publicProcedure
|
||||
.input(z.object({ email: z.string().email(), password: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const auth = getAuthManager();
|
||||
await auth.login(input.email, input.password);
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
logout: publicProcedure.mutation(async () => {
|
||||
const auth = getAuthManager();
|
||||
await auth.logout();
|
||||
return { success: true as const };
|
||||
}),
|
||||
|
||||
/** Returns current auth status + profile (if authenticated). */
|
||||
status: publicProcedure.query(async () => {
|
||||
const auth = getAuthManager();
|
||||
const authenticated = await auth.isAuthenticated();
|
||||
if (!authenticated) {
|
||||
return { authenticated: false as const, profile: null };
|
||||
}
|
||||
try {
|
||||
const profile = await auth.getProfile();
|
||||
return { authenticated: true as const, profile };
|
||||
} catch (err) {
|
||||
// Token stored but backend unreachable or token invalid
|
||||
if (err instanceof AuthError && err.statusCode === 401) {
|
||||
await auth.logout();
|
||||
return { authenticated: false as const, profile: null };
|
||||
}
|
||||
return { authenticated: true as const, profile: null };
|
||||
}
|
||||
}),
|
||||
|
||||
setBackendUrl: publicProcedure
|
||||
.input(z.object({ url: z.string().url() }))
|
||||
.mutation(({ input }) => {
|
||||
getStore().set('backendUrl', input.url);
|
||||
return { success: true as const };
|
||||
}),
|
||||
|
||||
getBackendUrl: publicProcedure.query(() => {
|
||||
return getStore().get('backendUrl');
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
settings: settingsRouter,
|
||||
@@ -597,6 +672,7 @@ export const appRouter = router({
|
||||
notes: notesRouter,
|
||||
taskComments: taskCommentsRouter,
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -5,6 +5,8 @@ interface AppSettings {
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
userName: string;
|
||||
/** Base URL of the Adiuva backend API (e.g. 'http://localhost:8000'). */
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -17,6 +19,7 @@ export function getStore(): Store<AppSettings> {
|
||||
aiProvider: 'copilot',
|
||||
encryptedTokens: {},
|
||||
userName: 'there',
|
||||
backendUrl: 'http://localhost:8000',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -57,7 +57,7 @@ export function AIChatPanel({
|
||||
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
|
||||
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
||||
|
||||
const chatContext = useMemo<ChatContext>(
|
||||
const chatContext = useMemo<UIChatContext>(
|
||||
() => ({ type: 'global' as const }),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
@@ -38,7 +38,7 @@ function FloatingChatInner() {
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
|
||||
// Chat context derived from active section
|
||||
const chatContext = useMemo<ChatContext>(
|
||||
const chatContext = useMemo<UIChatContext>(
|
||||
() => ({
|
||||
type: activeSection?.projectId ? 'project' : 'global',
|
||||
projectId: activeSection?.projectId,
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI-only chat context (renderer concern — not sent to backend)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renderer-only context describing where the user is in the UI.
|
||||
* The main process uses these fields to build the enriched `ChatContext`
|
||||
* (from `@shared/api-types`) before forwarding to the backend.
|
||||
*/
|
||||
export interface UIChatContext {
|
||||
/** Scope of the conversation. */
|
||||
type: 'global' | 'project';
|
||||
/** Active project ID when `type === 'project'`. */
|
||||
projectId?: string;
|
||||
/** Serialised description of the current UI state (visible section, selected item, etc.). */
|
||||
uiContext?: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@@ -8,19 +26,13 @@ interface ChatMessage {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
type: 'global' | 'project';
|
||||
projectId?: string;
|
||||
uiContext?: string;
|
||||
}
|
||||
|
||||
interface UseAIChatReturn {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
setInput: (v: string) => void;
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
handleSend: (overrideMessage?: string, overrideContext?: ChatContext) => void;
|
||||
handleSend: (overrideMessage?: string, overrideContext?: UIChatContext) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
@@ -28,7 +40,7 @@ interface UseAIChatOptions {
|
||||
onSectionTag?: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -44,7 +56,7 @@ export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOption
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(overrideMessage?: string, overrideContext?: ChatContext) => {
|
||||
(overrideMessage?: string, overrideContext?: UIChatContext) => {
|
||||
const trimmed = (overrideMessage ?? input).trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
|
||||
190
src/shared/api-types.ts
Normal file
190
src/shared/api-types.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Backend API contract types — shared between Electron main/renderer and the backend.
|
||||
*
|
||||
* Co-locates Zod schemas with inferred TypeScript types so the same definitions
|
||||
* serve both compile-time type-safety and runtime validation of API responses.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.1
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing & Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BillingTierSchema = z.enum(['free', 'pro', 'power', 'team']);
|
||||
export type BillingTier = z.infer<typeof BillingTierSchema>;
|
||||
|
||||
export const AuthTokensSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string(),
|
||||
/** Unix epoch seconds (matches backend `expires_at: int`). */
|
||||
expiresAt: z.number().int(),
|
||||
});
|
||||
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||
|
||||
export const UserProfileSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
tier: BillingTierSchema,
|
||||
});
|
||||
export type UserProfile = z.infer<typeof UserProfileSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat — Backend Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Chat context sent to the backend via WebSocket.
|
||||
*
|
||||
* Matches the backend `ChatContext` Pydantic model (flat structure).
|
||||
* UI-only fields (`type`, `projectId`, `uiContext`) live in `UIChatContext`
|
||||
* in the renderer — they are NOT sent to the backend.
|
||||
*/
|
||||
export const ChatContextSchema = z.object({
|
||||
/** User profile snapshot. */
|
||||
userProfile: UserProfileSchema.optional(),
|
||||
/** Relevant document snippets retrieved from the vector store. */
|
||||
relevantDocuments: z.array(z.string()).optional(),
|
||||
/** Recent tasks for additional context. */
|
||||
recentTasks: z
|
||||
.array(z.object({ id: z.string(), title: z.string(), status: z.string() }))
|
||||
.optional(),
|
||||
/** Previous messages in the conversation. */
|
||||
conversationHistory: z
|
||||
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
|
||||
.optional(),
|
||||
});
|
||||
export type ChatContext = z.infer<typeof ChatContextSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Request / Response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ChatRequestSchema = z.object({
|
||||
message: z.string(),
|
||||
context: ChatContextSchema,
|
||||
});
|
||||
export type ChatRequest = z.infer<typeof ChatRequestSchema>;
|
||||
|
||||
export const ChatResponseSchema = z.object({
|
||||
response: z.string(),
|
||||
});
|
||||
export type ChatResponse = z.infer<typeof ChatResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket Frames — Bidirectional Protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Actions the backend can request the Electron client to execute via Drizzle. */
|
||||
export const ToolCallActionSchema = z.enum([
|
||||
'select',
|
||||
'get',
|
||||
'insert',
|
||||
'update',
|
||||
'delete',
|
||||
'vector_upsert',
|
||||
'vector_search',
|
||||
]);
|
||||
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
|
||||
|
||||
// --- Client → Server frames ------------------------------------------------
|
||||
|
||||
export const WsChatRequestSchema = z.object({
|
||||
type: z.literal('chat_request'),
|
||||
message: z.string(),
|
||||
context: ChatContextSchema,
|
||||
});
|
||||
export type WsChatRequest = z.infer<typeof WsChatRequestSchema>;
|
||||
|
||||
export const WsToolResultSchema = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
id: z.string(),
|
||||
row: z.record(z.string(), z.unknown()).optional(),
|
||||
rows: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
results: z
|
||||
.array(z.object({ id: z.string(), content: z.string(), score: z.number() }))
|
||||
.optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
ok: z.boolean().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type WsToolResult = z.infer<typeof WsToolResultSchema>;
|
||||
|
||||
export const WsClientFrameSchema = z.discriminatedUnion('type', [
|
||||
WsChatRequestSchema,
|
||||
WsToolResultSchema,
|
||||
]);
|
||||
export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
|
||||
|
||||
// --- Server → Client frames ------------------------------------------------
|
||||
|
||||
export const WsTextChunkSchema = z.object({
|
||||
type: z.literal('text_chunk'),
|
||||
text: z.string(),
|
||||
});
|
||||
export type WsTextChunk = z.infer<typeof WsTextChunkSchema>;
|
||||
|
||||
export const WsToolCallSchema = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
id: z.string(),
|
||||
action: ToolCallActionSchema,
|
||||
table: z.string().optional(),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
filters: z.record(z.string(), z.unknown()).optional(),
|
||||
vector: z.array(z.number()).optional(),
|
||||
limit: z.number().int().optional(),
|
||||
});
|
||||
export type WsToolCall = z.infer<typeof WsToolCallSchema>;
|
||||
|
||||
export const WsFinalSchema = z.object({
|
||||
type: z.literal('final'),
|
||||
response: z.string(),
|
||||
});
|
||||
export type WsFinal = z.infer<typeof WsFinalSchema>;
|
||||
|
||||
export const WsPingSchema = z.object({
|
||||
type: z.literal('ping'),
|
||||
});
|
||||
export type WsPing = z.infer<typeof WsPingSchema>;
|
||||
|
||||
export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsTextChunkSchema,
|
||||
WsToolCallSchema,
|
||||
WsFinalSchema,
|
||||
WsPingSchema,
|
||||
]);
|
||||
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Manifests & Permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AgentManifestSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
schedule: z.string().optional(),
|
||||
});
|
||||
export type AgentManifest = z.infer<typeof AgentManifestSchema>;
|
||||
|
||||
export const PermissionGrantSchema = z.object({
|
||||
plugin: z.string(),
|
||||
permissionType: z.string(),
|
||||
resourcePath: z.string(),
|
||||
grantedAt: z.string().datetime(),
|
||||
});
|
||||
export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BackupMetadataSchema = z.object({
|
||||
version: z.number().int(),
|
||||
/** Unix epoch seconds (matches backend `timestamp: int`). */
|
||||
timestamp: z.number().int(),
|
||||
checksum: z.string(),
|
||||
chunkCount: z.number().int(),
|
||||
});
|
||||
export type BackupMetadata = z.infer<typeof BackupMetadataSchema>;
|
||||
172
src/shared/batch-types.ts
Normal file
172
src/shared/batch-types.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Batch builder & storage layer contract types.
|
||||
*
|
||||
* Defines all types for the LLM-powered Batch Builder, plugin system,
|
||||
* storage management, and data source connectors.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 0, Step 0.1
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const StorageTargetSchema = z.enum(['local', 'cloud', 'sync', 'none']);
|
||||
export type StorageTarget = z.infer<typeof StorageTargetSchema>;
|
||||
|
||||
export const BatchStorageSchema = z.object({
|
||||
/** Where structured records are persisted. */
|
||||
records: StorageTargetSchema,
|
||||
/** Where vector embeddings are stored. */
|
||||
vectors: StorageTargetSchema,
|
||||
/** Where raw/unprocessed data is kept. */
|
||||
rawData: StorageTargetSchema,
|
||||
});
|
||||
export type BatchStorage = z.infer<typeof BatchStorageSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ConnectorTypeSchema = z.enum([
|
||||
'imap',
|
||||
'filesystem',
|
||||
'calendar',
|
||||
'api',
|
||||
'gmail',
|
||||
'gdrive',
|
||||
'outlook',
|
||||
]);
|
||||
export type ConnectorType = z.infer<typeof ConnectorTypeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch Config Building Blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BatchActionTypeSchema = z.enum([
|
||||
'create_record',
|
||||
'update_record',
|
||||
'delete_record',
|
||||
'index_document',
|
||||
'send_notification',
|
||||
'call_agent',
|
||||
]);
|
||||
export type BatchActionType = z.infer<typeof BatchActionTypeSchema>;
|
||||
|
||||
export const BatchSourceSchema = z.object({
|
||||
connector: ConnectorTypeSchema,
|
||||
config: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type BatchSource = z.infer<typeof BatchSourceSchema>;
|
||||
|
||||
export const BatchTriggerSchema = z.object({
|
||||
type: z.enum(['cron', 'event']),
|
||||
/** Cron expression (required when `type === 'cron'`). */
|
||||
schedule: z.string().optional(),
|
||||
/** IANA timezone identifier. Defaults to system timezone when omitted. */
|
||||
timezone: z.string().optional(),
|
||||
});
|
||||
export type BatchTrigger = z.infer<typeof BatchTriggerSchema>;
|
||||
|
||||
export const BatchAnalysisSchema = z.object({
|
||||
/** Natural-language prompt describing what to extract / classify / summarise. */
|
||||
prompt: z.string(),
|
||||
/** Override the default LLM model for this analysis step. */
|
||||
modelOverride: z.string().optional(),
|
||||
/** Optional JSON Schema describing the expected output shape. */
|
||||
outputSchema: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type BatchAnalysis = z.infer<typeof BatchAnalysisSchema>;
|
||||
|
||||
export const BatchActionSchema = z.object({
|
||||
type: BatchActionTypeSchema,
|
||||
/** Target table for record operations. */
|
||||
table: z.string().optional(),
|
||||
/** Field mapping from analysis output keys → table columns / action parameters. */
|
||||
mapping: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
export type BatchAction = z.infer<typeof BatchActionSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full Batch Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BatchConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
enabled: z.boolean(),
|
||||
source: BatchSourceSchema,
|
||||
trigger: BatchTriggerSchema,
|
||||
analysis: BatchAnalysisSchema,
|
||||
actions: z.array(BatchActionSchema),
|
||||
storage: BatchStorageSchema,
|
||||
/** Permission scopes this batch requires. Validated against PermissionManager at runtime. */
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
export type BatchConfig = z.infer<typeof BatchConfigSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch Runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BatchStatusSchema = z.enum(['idle', 'running', 'error', 'disabled']);
|
||||
export type BatchStatus = z.infer<typeof BatchStatusSchema>;
|
||||
|
||||
export const BatchRunResultSchema = z.object({
|
||||
batchId: z.string(),
|
||||
runAt: z.string().datetime(),
|
||||
status: BatchStatusSchema,
|
||||
itemsProcessed: z.number().int(),
|
||||
errors: z.array(z.string()),
|
||||
});
|
||||
export type BatchRunResult = z.infer<typeof BatchRunResultSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Marketplace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PluginListingSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
author: z.string(),
|
||||
version: z.string(),
|
||||
rating: z.number().min(0).max(5),
|
||||
installs: z.number().int().min(0),
|
||||
category: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
/** Price in cents. `0` means free. */
|
||||
price: z.number().int().min(0),
|
||||
});
|
||||
export type PluginListing = z.infer<typeof PluginListingSchema>;
|
||||
|
||||
export const InstalledPluginSchema = z.object({
|
||||
listing: PluginListingSchema,
|
||||
installedAt: z.string().datetime(),
|
||||
enabled: z.boolean(),
|
||||
storageConfig: BatchStorageSchema,
|
||||
});
|
||||
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DataSourceInfoSchema = z.object({
|
||||
type: ConnectorTypeSchema,
|
||||
label: z.string(),
|
||||
recordCount: z.number().int().min(0),
|
||||
sizeBytes: z.number().int().min(0),
|
||||
storageTarget: StorageTargetSchema,
|
||||
});
|
||||
export type DataSourceInfo = z.infer<typeof DataSourceInfoSchema>;
|
||||
|
||||
export const StorageStatsSchema = z.object({
|
||||
localUsedBytes: z.number().int().min(0),
|
||||
cloudUsedBytes: z.number().int().min(0),
|
||||
cloudLimitBytes: z.number().int().min(0),
|
||||
sources: z.array(DataSourceInfoSchema),
|
||||
});
|
||||
export type StorageStats = z.infer<typeof StorageStatsSchema>;
|
||||
58
src/shared/casing.ts
Normal file
58
src/shared/casing.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Deep key-casing converters for JSON payloads.
|
||||
*
|
||||
* - `toSnakeCase(obj)` — camelCase → snake_case (outgoing to backend)
|
||||
* - `toCamelCase(obj)` — snake_case → camelCase (incoming from backend)
|
||||
*
|
||||
* Only object **keys** are converted — values are left untouched.
|
||||
* Handles nested objects, arrays, null, and primitives.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.1
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key converters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `fooBarBaz` → `foo_bar_baz` */
|
||||
function keyToSnake(key: string): string {
|
||||
return key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
}
|
||||
|
||||
/** `foo_bar_baz` → `fooBarBaz` */
|
||||
function keyToCamel(key: string): string {
|
||||
return key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deep converters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deepConvertKeys(obj: unknown, convertKey: (k: string) => string): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (Array.isArray(obj)) return obj.map((item) => deepConvertKeys(item, convertKey));
|
||||
if (typeof obj === 'object' && !(obj instanceof Date)) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result[convertKey(key)] = deepConvertKeys(value, convertKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-convert all object keys from camelCase to snake_case.
|
||||
* Used for outgoing JSON payloads to the Python backend.
|
||||
*/
|
||||
export function toSnakeCase<T = unknown>(obj: unknown): T {
|
||||
return deepConvertKeys(obj, keyToSnake) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-convert all object keys from snake_case to camelCase.
|
||||
* Used for incoming JSON payloads from the Python backend.
|
||||
*/
|
||||
export function toCamelCase<T = unknown>(obj: unknown): T {
|
||||
return deepConvertKeys(obj, keyToCamel) as T;
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/renderer/*"]
|
||||
"@/*": ["src/renderer/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
},
|
||||
"outDir": "dist"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(__dirname, './src/shared'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Externalize native Node modules — they're rebuilt by electron-forge
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'ws',
|
||||
'@github/copilot-sdk',
|
||||
'@github/copilot',
|
||||
// LangChain — externalize to avoid bundling Node.js-specific code
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src/renderer'),
|
||||
'@shared': path.resolve(__dirname, './src/shared'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user