Compare commits
6 Commits
65a08838c9
...
0c21f47a59
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c21f47a59 | |||
| 7256f1ef4e | |||
| bf635d9c30 | |||
| 5add259348 | |||
| 198fd62ef2 | |||
| 34a771bee3 |
@@ -1,436 +0,0 @@
|
||||
# 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
|
||||
- [x] Replace `src/main/ai/orchestrator.ts` entirely (996 lines → ~190 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()`
|
||||
- [x] 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`
|
||||
- [x] Update `src/renderer/components/ai/AIChatPanel.tsx`:
|
||||
- Replace `trpc.ai.hasToken.useQuery()` with `trpc.auth.status.useQuery()`
|
||||
- Update auth-gate condition and daily brief trigger to use `authStatusQuery.data?.authenticated`
|
||||
- Replace `KeyRound` icon + provider-config messaging with `LogIn` icon + login messaging
|
||||
- **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
|
||||
- [x] 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)
|
||||
- Add `searchNotesByVector(vector, limit)` for direct pre-computed-vector search
|
||||
- [x] Update `src/main/api/drizzle-executor.ts`: use `searchNotesByVector` with pre-computed vector from tool call payload
|
||||
- [x] Delete `src/main/ai/embeddings.ts`
|
||||
- **Files:** `src/main/db/vectordb.ts`, `src/main/api/drizzle-executor.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 ✅
|
||||
- [x] Delete `src/main/ai/llm.ts`, `src/main/ai/chat-copilot.ts`, `src/main/ai/copilot.ts`, `src/main/ai/provider.ts`
|
||||
- [x] Remove `import './ai/copilot'` and `initAI()` from `src/main/index.ts`
|
||||
- [x] Remove deps: `@langchain/core`, `@langchain/openai`, `@langchain/anthropic`, `@langchain/langgraph`, `@github/copilot-sdk`
|
||||
- [x] Clean up `src/main/store.ts` (remove `aiProvider`; kept `encryptedTokens` — still used by `token.ts` → `auth-manager.ts` for JWT storage)
|
||||
- [x] Clean up `vite.main.config.mts` (remove externalized LangChain/Copilot packages)
|
||||
- [x] Clean up `forge.config.ts` (remove LangChain/Copilot from `externalPackages`; remove copilot-sdk clipboard cleanup block)
|
||||
- **Files:** `src/main/ai/{llm,chat-copilot,copilot,provider}.ts` (deleted), `package.json`, `src/main/index.ts`, `src/main/store.ts`, `vite.main.config.mts`, `forge.config.ts`
|
||||
- **Outcome:** 34 npm packages removed. No LangChain, no Copilot SDK, no local LLM.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Agent System (Local Directory + Cloud Connectors)
|
||||
|
||||
> Two agent types at launch: **Local Directory Agent** (watches folders, Electron reads + pre-processes, backend runs AI) and **Cloud Connector Agent** (Gmail, Teams — 100% backend-managed). All configs live on the backend (synced, device-bound for local agents). Backend triggers agent runs via new WS frames when Electron is connected. Extracted data inserts into existing tables (tasks, notes, checkpoints) with `isAiSuggested=1`. Configuration prompts are built via a dedicated "Chatbot Journey" (multi-turn AI conversation on a dedicated page).
|
||||
>
|
||||
> **Backend Phase 3 plan:** `../adiuva-api/AI_REFACTOR_PLAN.md` Phase 3 section.
|
||||
|
||||
```
|
||||
Cloud Agent Flow:
|
||||
Backend cron ──► Backend fetches Gmail/Teams ──► Backend AI analyzes
|
||||
──► WS tool_call(insert, table:'tasks') ──► Electron persists locally
|
||||
|
||||
Local Agent Flow:
|
||||
Backend detects Electron online ──► WS agent_run frame (config + prompt)
|
||||
──► Electron reads files + pre-processes ──► WS agent_data frame (content)
|
||||
──► Backend AI analyzes with user prompt ──► WS tool_call(insert) ──► Electron persists
|
||||
```
|
||||
|
||||
Key constraints:
|
||||
- Local agents only run when Electron is active AND on the device where the path was configured
|
||||
- Cloud agents only push results when Electron is connected (no server-side content storage)
|
||||
- All AI communication goes through the backend (no local LLM)
|
||||
- Tier gating: free=2 active, pro=10, power/team=unlimited
|
||||
|
||||
### Step 3.1 — WS frame types + agent handler ✅
|
||||
- [x] Update `src/shared/api-types.ts`:
|
||||
- Add `WsAgentRun` schema: `{ type: "agent_run", run_id, agent_id, config: { paths, file_extensions, prompt_template, data_types } }`
|
||||
- Add `WsAgentData` schema: `{ type: "agent_data", run_id, files: [{ path, name, content, metadata }] }`
|
||||
- Add `WsAgentComplete` schema: `{ type: "agent_complete", run_id, files_read, errors }`
|
||||
- Add `WsDeviceHello` schema: `{ type: "device_hello", device_id, agent_ids }`
|
||||
- Extend `WsServerFrame` discriminated union with `agent_run`
|
||||
- Extend `WsClientFrame` with `agent_data`, `agent_complete`, `device_hello`
|
||||
- [x] Update `src/main/api/backend-client.ts`:
|
||||
- In WS message loop, handle `agent_run` frames:
|
||||
1. Read files from configured paths using the local agent handler (Step 3.2)
|
||||
2. Send `agent_data` frames back with pre-processed content
|
||||
3. Continue handling `tool_call` frames for DB inserts as usual
|
||||
- **Files:** `src/shared/api-types.ts`, `src/main/api/backend-client.ts`
|
||||
- **Outcome:** Electron can receive agent trigger frames and respond with file data.
|
||||
|
||||
### Step 3.2 — Local file reader ✅
|
||||
- [x] Create `src/main/agents/file-reader.ts`:
|
||||
- `readDirectory(paths: string[], extensions: string[]): AsyncGenerator<FileData>` — recursively reads configured directories, filters by extension
|
||||
- `preProcess(filePath: string): { name, content, metadata }`:
|
||||
- `.txt`, `.md`, `.eml` — read as text
|
||||
- `.pdf` — text extraction (dep: `pdf-parse`)
|
||||
- `.docx` — text extraction (dep: `mammoth`)
|
||||
- `.csv`, `.json` — read as structured text
|
||||
- Binary files: skip with warning
|
||||
- Respects path boundaries (no symlink escape, no `..` traversal)
|
||||
- Chunks large files (>50KB) to stay within LLM context limits
|
||||
- Returns `{ path, name, content, metadata: { size, mtime, extension } }`
|
||||
- [x] Update `BackendClient.handleAgentRun()` to call `readAgentFiles()` and return `{ files, errors, filesRead }`
|
||||
- **Files:** `src/main/agents/file-reader.ts`, `src/main/api/backend-client.ts`, `package.json` (`pdf-parse`, `mammoth` added)
|
||||
- **Dependencies:** `pdf-parse`, `mammoth`
|
||||
- **Outcome:** Electron can safely read + pre-process local files for AI analysis.
|
||||
|
||||
### Step 3.3 — Device ID management ✅
|
||||
- [x] Update `src/main/store.ts`: add `deviceId: string` (UUID generated once on first launch and persisted)
|
||||
- [x] Add `getDeviceId()` helper — lazily generates UUID v4 on first call, persists it; subsequent calls return the same value
|
||||
- [x] Add `settings.deviceId` tRPC query to `settingsRouter` — renderer can read the device ID; Step 3.4 (agent router) injects it into local agent config creation calls to the backend
|
||||
- [x] Electron sends `deviceId` when creating local agent configs → backend stores it (Step 3.4)
|
||||
- [x] When backend triggers a local agent run, it checks `config.device_id` matches the connected Electron's `deviceId` (Step 3.5)
|
||||
- **Files:** `src/main/store.ts`, `src/main/router/index.ts`
|
||||
- **Outcome:** Local agents are device-bound. Only triggered on the correct machine.
|
||||
|
||||
### Step 3.4 — Agent tRPC router ✅
|
||||
- [x] Add `agentRouter` to `src/main/router/index.ts`:
|
||||
- `agent.catalog` — query: proxy to backend `GET /api/v1/agents/catalog`
|
||||
- `agent.local.list` / `agent.local.create` / `agent.local.update` / `agent.local.delete` — proxy to backend with `deviceId` injected
|
||||
- `agent.cloud.list` / `agent.cloud.create` / `agent.cloud.update` / `agent.cloud.delete` — proxy to backend
|
||||
- `agent.runs` — query: proxy to backend run log
|
||||
- `agent.runNow` — mutation: proxy to backend manual trigger
|
||||
- `agent.journey.start` / `agent.journey.message` — proxy chatbot journey endpoints
|
||||
- All proxy calls include JWT from AuthManager + snake_case/camelCase conversion
|
||||
- [x] Also added response schemas to `src/shared/api-types.ts`: `AgentCatalogItemSchema`, `LocalAgentConfigSchema`, `CloudAgentConfigSchema`, `AgentRunLogSchema`, `JourneyMessageSchema`
|
||||
- [x] Added `proxyGet/proxyPost/proxyPut/proxyDelete` methods to `BackendClient` (authenticated, casing-converted HTTP proxies)
|
||||
- **Files:** `src/main/router/index.ts`, `src/shared/api-types.ts`, `src/main/api/backend-client.ts`
|
||||
- **Outcome:** Renderer can manage agents through tRPC — all requests proxied to backend.
|
||||
|
||||
### Step 3.5 — Persistent WS connection for agent triggers ✅
|
||||
- [x] Update `src/main/api/backend-client.ts`:
|
||||
- `connectPersistent()` — opens persistent WS to `/api/v1/ws/device?token=<jwt>` on app start
|
||||
- On connect: sends `device_hello` frame with `deviceId` and active agent IDs
|
||||
- Handles incoming `agent_run` frames → dispatches to file reader → sends `agent_data` back
|
||||
- Handles `tool_call` frames for DB inserts (same as chat WS)
|
||||
- `handleAgentRunAndSend()` — validates device ID, calls `handleAgentRun()`, sends `agent_data` + `agent_complete` frames
|
||||
- Auto-reconnects on disconnect with exponential backoff (1s → 2s → 4s → 8s → 16s → 30s cap)
|
||||
- Heartbeat WS-level ping every 30s; pong/message timeout triggers force-reconnect
|
||||
- `disconnectPersistent()` — disables reconnect, clears timers, closes WS cleanly
|
||||
- [x] Call `connectPersistent()` from `src/main/index.ts` after auth check on app startup
|
||||
- [x] `will-quit` handler in `src/main/index.ts` calls `disconnectPersistent()` for clean exit
|
||||
- [x] `authRouter.login` calls `connectPersistent()` on success
|
||||
- [x] `authRouter.logout` calls `disconnectPersistent()`
|
||||
- [x] Device ID validation in `handleAgentRunAndSend()` (completes Step 3.3 final checkbox)
|
||||
|
||||
### Step 3.6 — Agent Library page ✅
|
||||
- [x] Created `src/renderer/routes/settings.tsx`:
|
||||
- Settings page with 2-column layout (left nav: General, Account, Agents, Appearance)
|
||||
- Agents section is the agent library — catalog grid + my agents list with status indicators
|
||||
- Settings icon in sidebar navigates to `/settings` (replaced dropdown)
|
||||
- `validateSearch` for deep-link to specific section (e.g. `?section=account`)
|
||||
- [x] Added route to `src/renderer/routeTree.gen.ts`
|
||||
- [x] Updated sidebar nav in `src/renderer/components/layout/AppShell.tsx` (Settings is now a link)
|
||||
|
||||
### Step 3.7 — Agent config dialogs ✅
|
||||
- [x] `LocalAgentConfigPanel` component (inline, inside expanded agent row in Settings → Agents):
|
||||
- Native `dialog.showOpenDialog` directory picker (via new `dialog:showOpenDialog` IPC + `window.electronDialog` bridge)
|
||||
- File extension filter (preset groups + custom)
|
||||
- Data type selector (checkboxes: tasks, notes, checkpoints, projects)
|
||||
- Schedule picker (preset: every 15min, hourly, 6h, daily, manual)
|
||||
- "Customize AI Prompt" button → opens Chatbot Journey dialog
|
||||
- [x] `CloudAgentConfigPanel` component (inline, inside expanded agent row):
|
||||
- Provider badge + OAuth placeholder note
|
||||
- Data type selector + schedule picker
|
||||
- "Customize AI Prompt" button
|
||||
- [x] `AddAgentDialog` for creating new agents from the catalog
|
||||
- [x] Added `dialog:showOpenDialog` IPC handler in `src/main/index.ts` + `window.electronDialog` exposed in `src/preload/trpc.ts` + type declared in `src/renderer/lib/ipcLink.ts`
|
||||
- **Files:** `src/renderer/routes/settings.tsx`, `src/main/index.ts`, `src/preload/trpc.ts`, `src/renderer/lib/ipcLink.ts`
|
||||
- **Outcome:** Users can fully configure local and cloud agents from the Settings → Agents section.
|
||||
|
||||
### Step 3.8 — Chatbot Journey page ✅
|
||||
- [x] `JourneyDialog` component in `src/renderer/routes/settings.tsx`:
|
||||
- Dialog with spring-animated chat interface (message list, input, send button)
|
||||
- Starts via `agent.journey.start` (passes `agentType` + optional `agentId`) on mount
|
||||
- Multi-turn via `agent.journey.message` tRPC calls
|
||||
- Shows generated prompt preview when `done === true` / `promptTemplate` present
|
||||
- "Save & apply" button: saves promptTemplate to agent via `agent.local.update` / `agent.cloud.update`
|
||||
- Works in both Create flow (from `AddAgentDialog`) and Edit flow (from expanded agent row)
|
||||
- **Files:** `src/renderer/routes/settings.tsx`
|
||||
- **Outcome:** Users configure AI prompts through a guided conversation, directly inside agent config.
|
||||
|
||||
### Step 3.9 — Agent run logs UI ✅
|
||||
- [x] Create `src/renderer/components/agents/AgentRunLog.tsx`:
|
||||
- Per-agent run history: timestamp, status badge, items processed/created, errors
|
||||
- Lazy-loaded (only fetches when agent row is expanded), limit 10 runs
|
||||
- Skeleton loading state + "No runs yet" empty state
|
||||
- Per-run expandable error list (click to reveal all error strings)
|
||||
- Duration display (completedAt - startedAt formatted as Xs / Xm Ys)
|
||||
- Data via `agent.runs` tRPC query
|
||||
- [x] Integrated into `AgentRow` in `src/renderer/routes/settings.tsx` — replaced inline block
|
||||
- **Files:** `src/renderer/components/agents/AgentRunLog.tsx`, `src/renderer/routes/settings.tsx`
|
||||
- **Outcome:** Users see full history and status of each agent's runs with expandable error details.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security: E2E Backup & Offline
|
||||
|
||||
### Step 4.1 — E2E encrypted backup
|
||||
- [x] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
|
||||
- **Outcome:** User data never leaves the device unencrypted.
|
||||
|
||||
### Step 4.2 — Offline sync queue
|
||||
- [x] `src/main/backup/sync-queue.ts` + `sync_queue` table
|
||||
- **Outcome:** Queued actions auto-sync when online.
|
||||
|
||||
> **Step 4.3 (SQLCipher) — Dropped.** OS-level FDE covers at-rest encryption for a local-first desktop app. Backups already E2E encrypted via Argon2id + AES-256-GCM. Native module build complexity, ~10% perf overhead, and key management UX friction not justified by the threat model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Shared Memory ❌ DEPRECATED
|
||||
|
||||
> **Superseded by V3 architecture.** The backend now implements a 4-tier memory system (Core, Associative, Episodic, Proactive) with per-user Fernet encryption — see `../adiuva-api/V3_MIGRATION_PLAN.md` Steps 6–7. Memory lives server-side, not in Electron SQLite. The Electron orchestrator's `buildChatContext()` is removed in V3 (server fetches data on-demand via tool_call reverse API). Chat history is handled by `conversationHistory` passed in `home_request` frames.
|
||||
>
|
||||
> **See:** `V3_ELECTRON_MIGRATION_PLAN.md` for the replacement architecture.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Renderer UI Updates
|
||||
|
||||
### Step 6.1 — Auth UI + settings restructure ✅
|
||||
- [x] `LoginForm.tsx` — centered login/register screen (`src/renderer/components/auth/LoginForm.tsx`)
|
||||
- [x] Auth gate in `AppShell` — shows `LoginForm` when `auth.status` returns `authenticated: false`; passes through while loading to avoid flicker; `staleTime: 5min` to avoid hammering backend
|
||||
- [x] `SettingsPage.tsx` Account section simplified — login form removed (AppShell handles it), always shows profile + sign out
|
||||
|
||||
### Step 6.2 — ChatPage with context panel ❌ DEPRECATED
|
||||
> **Superseded by V3.** Home chat with block rendering (charts, entities, tables, timelines) and FloatingChat with domain navigation replace this. See `V3_ELECTRON_MIGRATION_PLAN.md` Steps 4–7.
|
||||
|
||||
### 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 |
|
||||
| `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>`.
|
||||
@@ -1,400 +0,0 @@
|
||||
# V3 Electron Migration Plan — Multi-Agent AI Productivity App
|
||||
|
||||
> Incremental migration of the Electron app to v3 streaming architecture.
|
||||
> Each step is self-contained, testable, and backwards-compatible until the final cutover.
|
||||
> The backend (`../adiuva-api`) v3 migration is already complete (Steps 1–7).
|
||||
> No test suite — each step is verified manually via the running app.
|
||||
|
||||
---
|
||||
|
||||
## General Rules
|
||||
|
||||
**Code Cleanup**: As you implement each step, remove any code that becomes unused or obsolete. This includes:
|
||||
- Old functions/methods that are superseded by new ones
|
||||
- Deprecated imports or modules
|
||||
- Dead code paths
|
||||
|
||||
This keeps the codebase clean and prevents confusion. When removing code, note it in the commit message if significant.
|
||||
|
||||
---
|
||||
|
||||
## Decisions Log
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| WS topology | Merge chat into persistent device WS (no more separate `/api/v1/chat/stream` WS) |
|
||||
| Context building | Remove `buildChatContext()` — server fetches data via reverse API `tool_call` round-trips |
|
||||
| IPC channel | Keep single `ai:stream` channel, change payload shape to discriminated union by `type` field |
|
||||
| Floating surface | Reuse existing `FloatingChat.tsx` — adapt to v3 floating pipeline |
|
||||
| Block rendering | New `blocks/` directory under `components/ai/` for chart, entity, table, timeline components |
|
||||
| useAIChat | Shared hook handles v3 frames for both Home and Floating (no mode split — divergence is in the rendering components) |
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — V3 Frame Types (`api-types.ts`)
|
||||
|
||||
**Goal**: Define the v3 frame vocabulary so all subsequent steps can import typed frames.
|
||||
|
||||
**Changes**:
|
||||
- `src/shared/api-types.ts`:
|
||||
- Add Client → Server frame schemas:
|
||||
- `WsHomeRequest(type: 'home_request', message, conversationHistory?)`
|
||||
- `WsFloatingRequest(type: 'floating_request', message, scope: { type: 'task'|'project'|'note'|'checkpoint', id? })`
|
||||
- Add Server → Client frame schemas:
|
||||
- `WsStreamStart(type: 'stream_start', requestId)`
|
||||
- `WsStreamText(type: 'stream_text', requestId, chunk)`
|
||||
- `WsStreamBlock(type: 'stream_block', requestId, blockType: 'chart'|'entity_ref'|'table'|'timeline', data: Record<string, unknown>)`
|
||||
- `WsStreamEnd(type: 'stream_end', requestId, mutations?)`
|
||||
- `WsFloatingDomain(type: 'floating_domain', requestId, domain: 'tasks'|'notes'|'checkpoints'|'projects')`
|
||||
- Add block data interfaces:
|
||||
- `ChartBlockData { chartType: 'area'|'bar'|'line'|'pie'|'radar'|'radial', title, data: Record<string, unknown>[], config: Record<string, { label: string, color: string }> }`
|
||||
- `EntityRefBlockData { entity: 'task'|'project'|'note'|'checkpoint', items: Record<string, unknown>[] }`
|
||||
- `TableBlockData { headers: string[], rows: string[][] }`
|
||||
- `TimelineBlockData { checkpoints: { id: string, title: string, date: number }[] }`
|
||||
- Add new frames to `WsClientFrameSchema` and `WsServerFrameSchema` discriminated unions
|
||||
- Keep all existing v2 frame types (backward compat until Step 3 removes them)
|
||||
|
||||
**Files touched**: `src/shared/api-types.ts`
|
||||
|
||||
**Test**: App compiles with no type errors. Existing chat still works (v2 frames untouched).
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm run lint
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 1 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-1: add v3 ws frame types (api-types.ts)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Unified WS Chat Transport (`backend-client.ts`)
|
||||
|
||||
**Goal**: Route Home and Floating chat through the persistent device WS instead of opening a separate per-chat WebSocket.
|
||||
|
||||
**Changes**:
|
||||
- `src/main/api/backend-client.ts`:
|
||||
- Add `private streamListeners: Map<string, StreamListener>` — keyed by `requestId`, each holding callbacks: `{ onStart, onText, onBlock, onEnd, onDomain, onError }`
|
||||
- Add `sendHomeRequest(message, conversationHistory?) -> { requestId, promise }`:
|
||||
1. Generates `requestId` (UUID)
|
||||
2. Registers a `StreamListener` in the map
|
||||
3. Sends `{ type: 'home_request', message, conversation_history }` on persistent WS
|
||||
4. Returns a promise that resolves when `stream_end` arrives (or rejects on error/timeout)
|
||||
- Add `sendFloatingRequest(message, scope) -> { requestId, promise }`:
|
||||
1. Same pattern but sends `{ type: 'floating_request', message, scope }`
|
||||
- Extend the persistent WS `on('message')` handler to dispatch v3 frames:
|
||||
- `stream_start` → call `listener.onStart()`
|
||||
- `stream_text` → call `listener.onText(chunk)`
|
||||
- `stream_block` → call `listener.onBlock(blockType, data)`
|
||||
- `stream_end` → call `listener.onEnd(mutations)`, remove listener, resolve promise
|
||||
- `floating_domain` → call `listener.onDomain(domain)`
|
||||
- `tool_call` frames already handled — no change needed (same persistent WS)
|
||||
- **Remove** `chatStream()` method and `openChatWebSocket()` private method (v2 per-chat WS)
|
||||
- **Remove** related imports: `ChatRequest`, `ChatResponse`, `ChatResponseSchema`
|
||||
|
||||
**Files touched**: `src/main/api/backend-client.ts`
|
||||
|
||||
**Test**: App starts, persistent WS connects, existing agent runs + tool calls still work. Chat is broken at this point (orchestrator still calls removed `chatStream()`) — that's expected, fixed in Step 3.
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start
|
||||
# Verify: [DeviceWS] Connected. in console
|
||||
# Verify: agent_run still works if you have agents configured
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 2 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-2: unify chat onto persistent device ws (backend-client.ts)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Orchestrator + IPC Bridge Refactor (`orchestrator.ts`, `preload/trpc.ts`, `router/index.ts`)
|
||||
|
||||
**Goal**: Orchestrator sends v3 frames via BackendClient and forwards typed stream events to the renderer via IPC.
|
||||
|
||||
**Changes**:
|
||||
- `src/main/ai/orchestrator.ts`:
|
||||
- **Remove** `buildChatContext()` entirely (server fetches data via tool_call reverse API)
|
||||
- **Remove** `sendStreamChunk()` helper
|
||||
- Replace `orchestrate()` with v3 version:
|
||||
1. Check connectivity + auth (unchanged)
|
||||
2. Call `client.sendHomeRequest(message, conversationHistory)` with stream callbacks:
|
||||
- `onStart(requestId)` → `sender.send('ai:stream', { type: 'stream_start', requestId })`
|
||||
- `onText(chunk)` → `sender.send('ai:stream', { type: 'stream_text', requestId, chunk })`
|
||||
- `onBlock(blockType, data)` → `sender.send('ai:stream', { type: 'stream_block', requestId, blockType, data })`
|
||||
- `onEnd()` → `sender.send('ai:stream', { type: 'stream_end', requestId })`
|
||||
3. Return `{ response: 'ok' }` (actual content streamed via IPC)
|
||||
- Add `orchestrateFloating()`:
|
||||
1. Same connectivity + auth checks
|
||||
2. Call `client.sendFloatingRequest(message, scope)` with stream callbacks:
|
||||
- Same as above, plus `onDomain(domain)` → `sender.send('ai:stream', { type: 'floating_domain', requestId, domain })`
|
||||
3. Return `{ response: 'ok' }`
|
||||
- Update `dailyBrief()` to use the v3 `orchestrate()` path
|
||||
- `src/preload/trpc.ts`:
|
||||
- Change `onStreamChunk` payload type from `{ token: string; done: boolean }` to the v3 discriminated union: `{ type: 'stream_start'|'stream_text'|'stream_block'|'stream_end'|'floating_domain', ... }`
|
||||
- Rename export to `onStreamEvent` (breaking change for renderer — fixed in Step 4)
|
||||
- **Remove** `onAction` channel handler (superseded by `stream_block` mutation frames)
|
||||
- `src/main/router/index.ts`:
|
||||
- Update `aiRouter.chat` input to accept optional `mode: 'home' | 'floating'` and optional `scope` for floating
|
||||
- Route to `orchestrate()` or `orchestrateFloating()` based on mode
|
||||
- Keep `dailyBrief` mutation (calls updated `dailyBrief()`)
|
||||
|
||||
**Files touched**: `src/main/ai/orchestrator.ts`, `src/preload/trpc.ts`, `src/main/router/index.ts`
|
||||
|
||||
**Test**: App starts. Sending a chat message from Home triggers `home_request` on persistent WS. Backend streams `stream_start` → `stream_text`* → `stream_end`. Renderer is broken (still expects v2 payloads) — fixed in Step 4.
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start
|
||||
# Open DevTools → Console: verify [DeviceWS] sends home_request frame
|
||||
# Verify stream_text frames appear in console logs
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 3 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-3: refactor orchestrator + ipc bridge to v3 frames"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Renderer Streaming Hook (`useAIChat.ts`)
|
||||
|
||||
**Goal**: `useAIChat` handles v3 typed stream events and produces structured messages with interleaved text + blocks.
|
||||
|
||||
**Changes**:
|
||||
- `src/renderer/hooks/useAIChat.ts`:
|
||||
- Update `ChatMessage` type:
|
||||
```ts
|
||||
interface StreamBlock {
|
||||
id: string;
|
||||
blockType: 'chart' | 'entity_ref' | 'table' | 'timeline';
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string; // accumulated text segments
|
||||
blocks: StreamBlock[]; // interleaved blocks (ordered by arrival)
|
||||
error?: boolean;
|
||||
}
|
||||
```
|
||||
- Replace `window.electronAI.onStreamChunk()` subscription with `window.electronAI.onStreamEvent()`:
|
||||
- `stream_start` → init streaming state, store `requestId`
|
||||
- `stream_text` → append `chunk` to `streamingContentRef` (same as before)
|
||||
- `stream_block` → append `{ id, blockType, data }` to `streamingBlocksRef`
|
||||
- `stream_end` → finalize message with accumulated text + blocks, cleanup
|
||||
- `floating_domain` → call `options?.onDomainSignal?.(domain)` callback
|
||||
- Add `streamingBlocks` state (exposed in return) for live block rendering during stream
|
||||
- Keep `[SECTION:xxx]` tag parsing for backward compat (remove later when floating_domain fully replaces it)
|
||||
- Update `UseAIChatReturn` to include `streamingBlocks: StreamBlock[]`
|
||||
|
||||
**Files touched**: `src/renderer/hooks/useAIChat.ts`
|
||||
|
||||
**Test**: Home chat works end-to-end with text streaming. Text appears word-by-word as before. Blocks array is populated (but not rendered yet — Step 5). FloatingChat also works (shares hook).
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start
|
||||
# Type a message in Home chat → text streams in
|
||||
# Check React DevTools: message.blocks array exists (may be empty if backend sends text-only)
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 4 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-4: update useAIChat for v3 structured streaming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Block Renderer Components (`components/ai/blocks/`)
|
||||
|
||||
**Goal**: Visual components that render `stream_block` data inline in chat messages.
|
||||
|
||||
**Changes**:
|
||||
- `src/renderer/components/ai/blocks/ChatChartBlock.tsx` (new):
|
||||
- Receives `ChartBlockData` (`chartType`, `title`, `data`, `config`)
|
||||
- Renders the appropriate shadcn/ui chart component based on `chartType`:
|
||||
- `area` → `AreaChart`, `bar` → `BarChart`, `line` → `LineChart`, `pie` → `PieChart`, `radar` → `RadarChart`, `radial` → `RadialChart`
|
||||
- Uses `ChartContainer` + `ChartTooltip` from shadcn/ui
|
||||
- Wrapped in a card with title, scale-and-fade entrance animation
|
||||
- Install any missing shadcn chart components if needed
|
||||
- `src/renderer/components/ai/blocks/ChatEntityBlock.tsx` (new):
|
||||
- Receives `EntityRefBlockData` (`entity`, `items`)
|
||||
- **Reuses existing components** — no new card renderers:
|
||||
- `task` → `TaskRow` from `components/tasks/TaskRow.tsx` (compact mode, read-only)
|
||||
- `project` → `Item` + `ItemMedia` + `ItemContent` from `components/ui/item.tsx` (same pattern as `ProjectDetail.tsx`)
|
||||
- `note` → `Item` + `ItemContent` (same pattern as note cards in `ProjectDetail.tsx`, with `FileText` icon)
|
||||
- `checkpoint` → `Item` with dashed-border variant + `Sparkles` icon for AI-suggested (same pattern as pending checkpoints in `ProjectDetail.tsx`)
|
||||
- Also reuses `PriorityBadge` from `components/tasks/PriorityBadge.tsx` for task priority display
|
||||
- Maps server data shape to each component's expected props
|
||||
- `src/renderer/components/ai/blocks/ChatTableBlock.tsx` (new):
|
||||
- Receives `TableBlockData` (`headers`, `rows`)
|
||||
- Renders a simple styled table (shadcn Table component)
|
||||
- `src/renderer/components/ai/blocks/ChatTimelineBlock.tsx` (new):
|
||||
- Receives `TimelineBlockData` (`checkpoints`)
|
||||
- **Reuses `GanttChart`** from `components/timeline/GanttChart.tsx` (compact mode, read-only, no context menu)
|
||||
- Maps `TimelineBlockData.checkpoints` to `GanttCheckpoint[]` interface
|
||||
- `src/renderer/components/ai/blocks/index.tsx` (new):
|
||||
- `BlockRenderer` component: switches on `blockType`, renders the appropriate block component
|
||||
- Wraps each block in a `motion.div` with scale-and-fade entrance (spring: stiffness 400, damping 30)
|
||||
|
||||
**Files touched**: `src/renderer/components/ai/blocks/` (5 new files)
|
||||
|
||||
**Test**: Components render correctly when given mock data. Can test by temporarily hardcoding a block in a chat message.
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start
|
||||
# Temporarily add a mock block to a message in useAIChat to verify rendering
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 5 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-5: add block renderer components (chart, entity, table, timeline)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Home Chat Block Rendering (`AIChatPanel.tsx`)
|
||||
|
||||
**Goal**: Home chat renders blocks inline between text segments.
|
||||
|
||||
**Changes**:
|
||||
- `src/renderer/components/ai/AIChatPanel.tsx`:
|
||||
- Import `BlockRenderer` from `./blocks`
|
||||
- Update assistant message rendering:
|
||||
- After `ChatMarkdown` (text content), render `message.blocks.map(block => <BlockRenderer key={block.id} ... />)`
|
||||
- Blocks appear below/between text in the order they arrived
|
||||
- Update streaming state rendering:
|
||||
- Show `streamingBlocks` (from `useAIChat`) as they arrive during streaming (pop-in effect)
|
||||
- Each block gets a scale-and-fade entrance animation
|
||||
- Daily brief: if the brief response includes blocks, render them in the expandable toast
|
||||
|
||||
**Files touched**: `src/renderer/components/ai/AIChatPanel.tsx`
|
||||
|
||||
**Test**: Send a Home chat message that triggers the backend to return blocks (e.g., "Show me task status for project X" — should produce entity_ref or chart blocks). Text streams in, then blocks pop in when complete.
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start
|
||||
# Ask: "Show me my task status" or "Give me a summary of project X"
|
||||
# Verify: text streams word-by-word, chart/entity blocks pop in after
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 6 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-6: integrate block rendering in home chat (AIChatPanel.tsx)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Floating Domain Navigation (`FloatingChat.tsx`)
|
||||
|
||||
**Goal**: FloatingChat sends `floating_request` and handles `floating_domain` for background page navigation. Text-only rendering (no blocks).
|
||||
|
||||
**Changes**:
|
||||
- `src/renderer/components/ai/FloatingChat.tsx`:
|
||||
- Update `useAIChat` call to pass `onDomainSignal` callback:
|
||||
- Maps domain to route: `tasks → /tasks`, `projects → /projects`, `checkpoints → /timeline`, `notes → /notes`
|
||||
- Calls `navigate()` to the target route (background navigation — panel stays open)
|
||||
- Replaces the `SECTION_ROUTES` + `[SECTION:xxx]` tag mechanism with the deterministic `floating_domain` signal
|
||||
- Update `chatContext` construction to include `scope`:
|
||||
- When opened on a specific entity (e.g., double-click task #42): `scope: { type: 'task', id: 'task_42' }`
|
||||
- When opened on an area (e.g., tasks list): `scope: { type: 'task' }` (no id)
|
||||
- Messages render text-only — explicitly do **not** render `message.blocks` (floating is text-only per v3 spec)
|
||||
- Remove `SECTION_ROUTES` constant and `handleSectionTag` callback (replaced by `floating_domain`)
|
||||
- Remove `onSectionTag` option from `useAIChat` call (cleanup — if no other consumer uses it, remove from hook too)
|
||||
|
||||
**Files touched**: `src/renderer/components/ai/FloatingChat.tsx`, possibly `src/renderer/hooks/useAIChat.ts` (remove `onSectionTag` if unused)
|
||||
|
||||
**Test**: Double-click an entity → FloatingChat opens → type a question → floating_domain signal arrives → background page navigates → text streams in the panel.
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start
|
||||
# Double-click a task → FloatingChat opens
|
||||
# Ask: "What's the checkpoint status?"
|
||||
# Verify: background navigates to /timeline, text streams in floating
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 7 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-7: floating domain navigation in floating chat (FloatingChat.tsx)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Cleanup
|
||||
|
||||
**Goal**: Remove all v2 chat artifacts that are no longer used.
|
||||
|
||||
**Changes**:
|
||||
- `src/shared/api-types.ts`:
|
||||
- Remove v2 chat schemas: `WsChatRequestSchema`, `WsTextChunkSchema`, `WsFinalSchema`, `ChatContextSchema`, `ChatRequestSchema`, `ChatResponseSchema`
|
||||
- Remove from `WsClientFrameSchema` and `WsServerFrameSchema` discriminated unions
|
||||
- `src/main/api/backend-client.ts`:
|
||||
- Remove any leftover v2 imports or dead code
|
||||
- `src/main/ai/orchestrator.ts`:
|
||||
- Remove `OrchestrateResult` interface if no longer needed
|
||||
- Remove `AI_STREAM_CHANNEL` constant (now in preload only)
|
||||
- `src/preload/trpc.ts`:
|
||||
- Remove `onAction` channel if still present
|
||||
- `src/renderer/hooks/useAIChat.ts`:
|
||||
- Remove `onSectionTag` option if fully replaced by `onDomainSignal`
|
||||
- `src/main/router/index.ts`:
|
||||
- Clean up `aiRouter.chat` input schema (remove `uiContext` field — no longer sent)
|
||||
|
||||
**Files touched**: Multiple (cleanup pass)
|
||||
|
||||
**Test**: Full app smoke test — Home chat, Floating chat, daily brief, agent runs all work.
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm run lint && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [x] Step 8 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
git commit -m "step-8: remove v2 chat artifacts (cleanup)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Step | Component | Effort | Depends On |
|
||||
|------|-----------|--------|------------|
|
||||
| 1 | V3 Frame Types | Low | — |
|
||||
| 2 | Unified WS Transport | High | Step 1 |
|
||||
| 3 | Orchestrator + IPC Bridge | Medium | Step 2 |
|
||||
| 4 | Renderer Streaming Hook | Medium | Step 3 |
|
||||
| 5 | Block Renderer Components | High | Step 1 (types only) |
|
||||
| 6 | Home Chat Blocks | Medium | Steps 4, 5 |
|
||||
| 7 | Floating Domain Navigation | Medium | Step 4 |
|
||||
| 8 | Cleanup | Low | Steps 6, 7 |
|
||||
|
||||
Steps 1–4 form the streaming pipeline (serial dependency chain).
|
||||
Step 5 can run in parallel with Steps 2–4 (only needs types from Step 1).
|
||||
Steps 6 and 7 can run in parallel after Step 4 + 5.
|
||||
Step 8 is the final cleanup after everything works.
|
||||
|
||||
### What stays untouched
|
||||
- `src/main/api/drizzle-executor.ts` — already v3-compatible (reverse API)
|
||||
- `src/main/ai/token.ts` — unchanged
|
||||
- `src/main/agents/file-reader.ts` — unchanged
|
||||
- `src/main/db/` — no schema changes
|
||||
- `src/renderer/routes/` — no route changes
|
||||
- All existing tRPC routers (tasks, projects, notes, checkpoints, etc.) — unchanged
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { getLocalAgents, saveLocalAgent, getDeviceId } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import { getDb } from '../db';
|
||||
import { agentRuns } from '../db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -76,11 +78,12 @@ async function tickAgentScheduler(): Promise<void> {
|
||||
|
||||
try {
|
||||
const activeAgents = agents.length;
|
||||
await getBackendClient().proxyPost(
|
||||
const response = await getBackendClient().proxyPost<{ id: string }>(
|
||||
'/api/v1/agents/trigger',
|
||||
{
|
||||
directory: agent.directory,
|
||||
deviceId: getDeviceId(),
|
||||
agentId: agent.id,
|
||||
whatToExtract: agent.dataTypes,
|
||||
batchInterval: agent.scheduleCron,
|
||||
customAgentPrompt: agent.promptTemplate,
|
||||
@@ -88,6 +91,19 @@ async function tickAgentScheduler(): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Create the run row immediately so it appears in history even if
|
||||
// the agent finds nothing to create/update.
|
||||
if (response?.id) {
|
||||
try {
|
||||
await getDb().insert(agentRuns).values({
|
||||
id: response.id,
|
||||
agentId: agent.id,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
}).onConflictDoNothing();
|
||||
} catch { /* ignore — row may already exist */ }
|
||||
}
|
||||
|
||||
// Mark the run time so we don't re-trigger until the next interval
|
||||
saveLocalAgent({ ...agent, lastRunAt: now });
|
||||
console.log(`[AgentScheduler] Triggered agent "${agent.name}" (id=${agent.id}).`);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getStore, getDeviceId, getLocalAgents } from '../store';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import { toSnakeCase, toCamelCase } from '../../shared/casing';
|
||||
@@ -30,6 +31,49 @@ import type {
|
||||
WsFloatingDomain,
|
||||
} from '../../shared/api-types';
|
||||
import { DrizzleExecutor } from './drizzle-executor';
|
||||
import { getDb } from '../db';
|
||||
import { agentRuns, agentRunActions } from '../db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent run logging helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABLE_TO_ENTITY: Record<string, string> = {
|
||||
tasks: 'task',
|
||||
notes: 'note',
|
||||
projects: 'project',
|
||||
timeline_events: 'timeline',
|
||||
task_comments: 'comment',
|
||||
};
|
||||
|
||||
function extractEntityTitle(data: Record<string, unknown> | undefined): string | null {
|
||||
if (!data) return null;
|
||||
return (data.title as string) ?? (data.name as string) ?? (data.content as string)?.slice(0, 60) ?? null;
|
||||
}
|
||||
|
||||
async function recordRunAction(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
verb: string,
|
||||
entityType: string,
|
||||
entityId: string | null,
|
||||
entityTitle: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await getDb().insert(agentRunActions).values({
|
||||
id: crypto.randomUUID(),
|
||||
runId,
|
||||
agentId,
|
||||
verb,
|
||||
entityType,
|
||||
entityId: entityId ?? null,
|
||||
entityTitle: entityTitle ?? null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[RunLog] Failed to record action:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-mode logger
|
||||
@@ -37,31 +81,31 @@ import { DrizzleExecutor } from './drizzle-executor';
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
|
||||
function logHttp(method: string, url: string, body?: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
const bodyStr = body !== undefined ? `\n body: ${JSON.stringify(body)}` : '';
|
||||
console.log(`[BE ▶ HTTP] ${method} ${url}${bodyStr}`);
|
||||
}
|
||||
|
||||
function logHttpResponse(method: string, url: string, status: number, body?: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
const bodyStr = body !== undefined ? `\n response: ${JSON.stringify(body).slice(0, 500)}` : '';
|
||||
console.log(`[BE ◀ HTTP] ${status} ${method} ${url}${bodyStr}`);
|
||||
}
|
||||
|
||||
function truncateForLog(payload: unknown): string {
|
||||
const str = JSON.stringify(payload);
|
||||
return str.length > 200 ? `${str.slice(0, 200)}…` : str;
|
||||
}
|
||||
|
||||
function logHttp(method: string, url: string, body?: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
const bodyStr = body !== undefined ? `\n body: ${truncateForLog(body)}` : '';
|
||||
console.log(`[BE > HTTP] ${method} ${url}${bodyStr}`);
|
||||
}
|
||||
|
||||
function logHttpResponse(method: string, url: string, status: number, body?: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
const bodyStr = body !== undefined ? `\n response: ${truncateForLog(body)}` : '';
|
||||
console.log(`[BE < HTTP] ${status} ${method} ${url}${bodyStr}`);
|
||||
}
|
||||
|
||||
function logWsSend(payload: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
console.log(`[BE ▶ WS] ${truncateForLog(payload)}`);
|
||||
console.log(`[BE > WS] ${truncateForLog(payload)}`);
|
||||
}
|
||||
|
||||
function logWsRecv(payload: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
console.log(`[BE ◀ WS] ${truncateForLog(payload)}`);
|
||||
console.log(`[BE < WS] ${truncateForLog(payload)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -723,6 +767,19 @@ export class BackendClient {
|
||||
logWsSend(result);
|
||||
ws.send(JSON.stringify(toSnakeCase(result)));
|
||||
}
|
||||
|
||||
// Log mutating actions from batch agent runs
|
||||
const rc = toolCall.runContext;
|
||||
if (rc && ['insert', 'update', 'delete'].includes(toolCall.action)) {
|
||||
const entityType = TABLE_TO_ENTITY[toolCall.table ?? ''];
|
||||
if (entityType) {
|
||||
const verb = toolCall.action === 'insert' ? 'created'
|
||||
: toolCall.action === 'update' ? 'updated' : 'deleted';
|
||||
const entityId = ('row' in result && result.row?.id as string) ?? null;
|
||||
const entityTitle = extractEntityTitle(toolCall.data);
|
||||
await recordRunAction(rc.runId, rc.agentId, verb, entityType, entityId, entityTitle);
|
||||
}
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
@@ -756,6 +813,21 @@ export class BackendClient {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'run_complete': {
|
||||
const { runContext, status } = frame.data;
|
||||
void (async () => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.update(agentRuns)
|
||||
.set({ status: status === 'success' ? 'completed' : status === 'partial' ? 'partial' : 'failed', completedAt: Date.now() })
|
||||
.where(eq(agentRuns.id, runContext.runId));
|
||||
} catch (err) {
|
||||
console.warn('[RunLog] Failed to close run:', err);
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'journey_reply': {
|
||||
const jl = this.journeyListeners.get(frame.data.sessionId);
|
||||
if (jl) {
|
||||
|
||||
@@ -80,6 +80,25 @@ const MIGRATION_SQL = `
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_run_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
verb TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
entity_title TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
@@ -79,6 +79,32 @@ export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||
export type TimelineEvent = InferSelectModel<typeof timelineEvents>;
|
||||
export type NewTimelineEvent = InferInsertModel<typeof timelineEvents>;
|
||||
|
||||
export const agentRuns = sqliteTable('agent_runs', {
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id').notNull(),
|
||||
status: text('status', { enum: ['running', 'completed', 'failed', 'partial'] }).notNull().default('running'),
|
||||
startedAt: integer('started_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const agentRunActions = sqliteTable('agent_run_actions', {
|
||||
id: text('id').primaryKey(),
|
||||
runId: text('run_id').notNull(),
|
||||
agentId: text('agent_id').notNull(),
|
||||
/** 'created' | 'updated' | 'deleted' | 'commented' */
|
||||
verb: text('verb').notNull(),
|
||||
/** 'task' | 'note' | 'project' | 'timeline' | 'comment' */
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
entityTitle: text('entity_title'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type AgentRun = InferSelectModel<typeof agentRuns>;
|
||||
export type NewAgentRun = InferInsertModel<typeof agentRuns>;
|
||||
export type AgentRunAction = InferSelectModel<typeof agentRunActions>;
|
||||
export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
|
||||
|
||||
export const syncQueue = sqliteTable('sync_queue', {
|
||||
id: text('id').primaryKey(),
|
||||
/** Action to retry (currently only 'backup'). */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
|
||||
import { eq, asc, desc, inArray, and, or, like, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/sqlite-core';
|
||||
import { getDb } from '../db';
|
||||
import { clients, projects, tasks, timelineEvents, notes, taskComments } from '../db/schema';
|
||||
import { clients, projects, tasks, timelineEvents, notes, taskComments, agentRuns, agentRunActions } from '../db/schema';
|
||||
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent } from '../store';
|
||||
import type { LocalAgentLocalConfig } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
@@ -892,28 +892,72 @@ const agentRouter = router({
|
||||
local: agentLocalRouter,
|
||||
cloud: agentCloudRouter,
|
||||
|
||||
/** Run log — history for all agents or a specific agent. */
|
||||
/** Run history — queries local SQLite (data written by backend-client on tool_call/run_complete). */
|
||||
runs: publicProcedure
|
||||
.input(z.object({
|
||||
agentId: z.string().optional(),
|
||||
agentId: z.string(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
}).optional())
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (input?.agentId) params.set('agent_id', input.agentId);
|
||||
if (input?.limit !== undefined) params.set('limit', String(input.limit));
|
||||
if (input?.offset !== undefined) params.set('offset', String(input.offset));
|
||||
const qs = params.size > 0 ? `?${params.toString()}` : '';
|
||||
return await getBackendClient().proxyGet<AgentRunLog[]>(`/api/v1/agents/runs${qs}`);
|
||||
const db = getDb();
|
||||
const limit = input.limit ?? 20;
|
||||
const offset = input.offset ?? 0;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agentRuns)
|
||||
.where(eq(agentRuns.agentId, input.agentId))
|
||||
.orderBy(desc(agentRuns.startedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// Compute per-run action counts in one query
|
||||
const runIds = rows.map(r => r.id);
|
||||
const actionRows = runIds.length > 0
|
||||
? await db.select({ runId: agentRunActions.runId, verb: agentRunActions.verb, entityType: agentRunActions.entityType })
|
||||
.from(agentRunActions)
|
||||
.where(inArray(agentRunActions.runId, runIds))
|
||||
: [];
|
||||
|
||||
type ActionCounts = { created: number; updated: number; deleted: number };
|
||||
const countsByRun = new Map<string, ActionCounts>();
|
||||
for (const a of actionRows) {
|
||||
if (!countsByRun.has(a.runId)) countsByRun.set(a.runId, { created: 0, updated: 0, deleted: 0 });
|
||||
const c = countsByRun.get(a.runId)!;
|
||||
if (a.verb === 'created') c.created++;
|
||||
else if (a.verb === 'updated') c.updated++;
|
||||
else if (a.verb === 'deleted') c.deleted++;
|
||||
}
|
||||
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
actionCounts: countsByRun.get(r.id) ?? { created: 0, updated: 0, deleted: 0 },
|
||||
}));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load run logs';
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load run history';
|
||||
console.error('[Agent] runs error:', msg);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
/** Individual actions for a specific run — loaded lazily when a run is expanded. */
|
||||
runActions: publicProcedure
|
||||
.input(z.object({ runId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
return await db
|
||||
.select()
|
||||
.from(agentRunActions)
|
||||
.where(eq(agentRunActions.runId, input.runId))
|
||||
.orderBy(asc(agentRunActions.createdAt));
|
||||
} catch (err) {
|
||||
console.error('[Agent] runActions error:', err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
/** Check whether the user's plan allows creating a new agent. */
|
||||
canCreate: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
@@ -937,17 +981,29 @@ const agentRouter = router({
|
||||
const agent = getLocalAgent(input.id);
|
||||
if (!agent) return { data: null, error: 'Agent not found' };
|
||||
const activeAgents = getLocalAgents().length;
|
||||
const result = await getBackendClient().proxyPost<{ id: string; agentId: string; agentType: string; status: string; itemsProcessed: number; itemsCreated: number; errors: string[]; startedAt: number; completedAt: number | null }>(
|
||||
const result = await getBackendClient().proxyPost<{ id: string }>(
|
||||
'/api/v1/agents/trigger',
|
||||
{
|
||||
directory: agent.directory,
|
||||
deviceId: getDeviceId(),
|
||||
agentId: agent.id,
|
||||
whatToExtract: agent.dataTypes,
|
||||
batchInterval: agent.scheduleCron,
|
||||
customAgentPrompt: agent.promptTemplate,
|
||||
activeAgents,
|
||||
},
|
||||
);
|
||||
// Create the run row so it appears in history even with zero mutations
|
||||
if (result?.id) {
|
||||
try {
|
||||
await getDb().insert(agentRuns).values({
|
||||
id: result.id,
|
||||
agentId: agent.id,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
}).onConflictDoNothing();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to trigger agent run';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Play, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Play, Trash2, ChevronDown, ChevronUp, History } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { AgentRunLog } from '@/components/agents/AgentRunLog';
|
||||
import type { CloudAgentConfig } from '../../../../shared/api-types';
|
||||
import type { LocalAgentConfig } from './types';
|
||||
import { SCHEDULE_OPTIONS, formatTs } from './types';
|
||||
import { LocalAgentConfigPanel } from './LocalAgentConfigPanel';
|
||||
import { CloudAgentConfigPanel } from './CloudAgentConfigPanel';
|
||||
import { AgentRunHistorySheet } from './AgentRunHistorySheet';
|
||||
|
||||
export function AgentRow({
|
||||
agent,
|
||||
@@ -26,6 +27,7 @@ export function AgentRow({
|
||||
onRunNow: () => void;
|
||||
onOpenJourney: () => void;
|
||||
}) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const scheduleLabel = SCHEDULE_OPTIONS.find(s => s.value === agent.scheduleCron)?.label ?? agent.scheduleCron;
|
||||
const lastRunLabel = agent.lastRunAt ? formatTs(agent.lastRunAt) : 'Never';
|
||||
const kindLabel = agent.agentType === 'local' ? 'Local' : `Cloud · ${(agent as CloudAgentConfig).provider}`;
|
||||
@@ -56,10 +58,16 @@ export function AgentRow({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run now
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run now
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setHistoryOpen(true)} className="h-8">
|
||||
<History className="size-3.5 mr-1.5" />
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} title="Delete agent" className="h-8 w-8 p-0">
|
||||
<Trash2 className="size-3.5 text-muted-foreground" />
|
||||
@@ -71,18 +79,23 @@ export function AgentRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded config / logs */}
|
||||
{/* Expanded config */}
|
||||
{expanded && (
|
||||
<div className="border-t px-4 py-4 flex flex-col gap-5 bg-muted/20">
|
||||
<div className="border-t px-4 py-4 bg-muted/20">
|
||||
{agent.agentType === 'local' ? (
|
||||
<LocalAgentConfigPanel agent={agent as LocalAgentConfig & { agentType: 'local' }} onOpenJourney={onOpenJourney} />
|
||||
) : (
|
||||
<CloudAgentConfigPanel agent={agent as CloudAgentConfig & { agentType: 'cloud' }} onOpenJourney={onOpenJourney} />
|
||||
)}
|
||||
|
||||
<AgentRunLog agentId={agent.id} expanded={expanded} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentRunHistorySheet
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
239
src/renderer/components/settings/AgentRunHistorySheet.tsx
Normal file
239
src/renderer/components/settings/AgentRunHistorySheet.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2, XCircle, AlertCircle, Loader2, Clock,
|
||||
ChevronDown, ChevronRight, Plus, Pencil, Trash2, History,
|
||||
} from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '../ui/empty';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types inferred from router return
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RunSummary = {
|
||||
id: string;
|
||||
agentId: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'partial';
|
||||
startedAt: number;
|
||||
completedAt: number | null | undefined;
|
||||
actionCounts: { created: number; updated: number; deleted: number };
|
||||
};
|
||||
|
||||
type RunAction = {
|
||||
id: string;
|
||||
runId: string;
|
||||
verb: string;
|
||||
entityType: string;
|
||||
entityId: string | null | undefined;
|
||||
entityTitle: string | null | undefined;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 shrink-0 text-[10px] py-0">
|
||||
<CheckCircle2 className="size-2.5" /> Done
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1 shrink-0 text-[10px] py-0">
|
||||
<XCircle className="size-2.5" /> Failed
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 shrink-0 text-[10px] py-0">
|
||||
<Loader2 className="size-2.5 animate-spin" /> Running
|
||||
</Badge>
|
||||
);
|
||||
case 'partial':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-amber-600 shrink-0 text-[10px] py-0">
|
||||
<AlertCircle className="size-2.5" /> Partial
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline" className="shrink-0 text-[10px] py-0">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTs(ts: number) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: number, completedAt: number | null | undefined) {
|
||||
if (!completedAt) return null;
|
||||
const diffSec = Math.round((completedAt - startedAt) / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const m = Math.floor(diffSec / 60);
|
||||
const s = diffSec % 60;
|
||||
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
function actionSummary(counts: RunSummary['actionCounts']): string {
|
||||
const parts: string[] = [];
|
||||
if (counts.created > 0) parts.push(`${counts.created} created`);
|
||||
if (counts.updated > 0) parts.push(`${counts.updated} updated`);
|
||||
if (counts.deleted > 0) parts.push(`${counts.deleted} deleted`);
|
||||
return parts.join(' · ') || 'No changes';
|
||||
}
|
||||
|
||||
const VERB_ICON: Record<string, React.ReactNode> = {
|
||||
created: <Plus className="size-3 text-emerald-500 shrink-0" />,
|
||||
updated: <Pencil className="size-3 text-blue-500 shrink-0" />,
|
||||
deleted: <Trash2 className="size-3 text-destructive shrink-0" />,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expanded run actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RunActionList({ runId }: { runId: string }) {
|
||||
const query = trpc.agent.runActions.useQuery({ runId });
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="px-3 pb-3 flex flex-col gap-1.5">
|
||||
{[0, 1, 2].map(i => <Skeleton key={i} className="h-5 w-full rounded" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actions = (query.data ?? []) as RunAction[];
|
||||
if (actions.length === 0) {
|
||||
return <p className="px-3 pb-3 text-xs text-muted-foreground">No actions recorded.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 flex flex-col gap-1">
|
||||
{actions.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 text-xs">
|
||||
{VERB_ICON[a.verb] ?? <span className="size-3 shrink-0" />}
|
||||
<span className="text-muted-foreground capitalize">{a.verb}</span>
|
||||
<span className="text-foreground/70 capitalize">{a.entityType}</span>
|
||||
{a.entityTitle && (
|
||||
<span className="text-foreground truncate max-w-[200px]">{a.entityTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single run row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RunRow({ run }: { run: RunSummary }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const duration = formatDuration(run.startedAt, run.completedAt);
|
||||
const hasActions = run.actionCounts.created + run.actionCounts.updated + run.actionCounts.deleted > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 overflow-hidden">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2.5 flex items-start gap-2"
|
||||
onClick={() => hasActions && setExpanded(v => !v)}
|
||||
disabled={!hasActions}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{statusBadge(run.status)}
|
||||
<span className="text-xs text-muted-foreground">{formatTs(run.startedAt)}</span>
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />{duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{actionSummary(run.actionCounts)}</p>
|
||||
</div>
|
||||
{hasActions && (
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{expanded
|
||||
? <ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3.5 text-muted-foreground" />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && <RunActionList runId={run.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AgentRunHistorySheet({
|
||||
agentId,
|
||||
agentName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const runsQuery = trpc.agent.runs.useQuery(
|
||||
{ agentId, limit: 30 },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const runs = (runsQuery.data ?? []) as RunSummary[];
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-5 pt-5 pb-4">
|
||||
<SheetTitle className="text-base font-semibold">{agentName}</SheetTitle>
|
||||
<p className="text-xs text-muted-foreground -mt-1">Run history</p>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-5 py-4 flex flex-col gap-2">
|
||||
{runsQuery.isPending && (
|
||||
<>
|
||||
{[0, 1, 2, 4].map(i => <Skeleton key={i} className="h-16 w-full rounded-lg" />)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && runs.length === 0 && (
|
||||
<Empty className="border-none py-12">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<History />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-sm">No runs yet</EmptyTitle>
|
||||
<EmptyDescription className="text-xs">
|
||||
Runs will appear here after the agent executes.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && runs.map(run => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,13 @@ export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
|
||||
|
||||
// --- Server → Client frames ------------------------------------------------
|
||||
|
||||
export const RunContextSchema = z.object({
|
||||
type: z.literal('agent_batch'),
|
||||
runId: z.string(),
|
||||
agentId: z.string(),
|
||||
});
|
||||
export type RunContext = z.infer<typeof RunContextSchema>;
|
||||
|
||||
export const WsToolCallSchema = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
id: z.string(),
|
||||
@@ -141,6 +148,8 @@ export const WsToolCallSchema = z.object({
|
||||
filters: z.record(z.string(), z.unknown()).optional(),
|
||||
vector: z.array(z.number()).optional(),
|
||||
limit: z.number().int().optional(),
|
||||
/** Present on tool calls originating from a scheduled agent batch run. */
|
||||
runContext: RunContextSchema.optional(),
|
||||
});
|
||||
export type WsToolCall = z.infer<typeof WsToolCallSchema>;
|
||||
|
||||
@@ -234,6 +243,14 @@ export interface TimelineBlockData {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Sent by the backend when a scheduled agent batch run finishes. */
|
||||
export const WsRunCompleteSchema = z.object({
|
||||
type: z.literal('run_complete'),
|
||||
runContext: RunContextSchema,
|
||||
status: z.enum(['success', 'error', 'partial']),
|
||||
});
|
||||
export type WsRunComplete = z.infer<typeof WsRunCompleteSchema>;
|
||||
|
||||
export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsToolCallSchema,
|
||||
WsPingSchema,
|
||||
@@ -242,6 +259,7 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsStreamEndSchema,
|
||||
WsFloatingDomainSchema,
|
||||
WsJourneyReplySchema,
|
||||
WsRunCompleteSchema,
|
||||
]);
|
||||
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user