Compare commits
216 Commits
1f6e60d4a9
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a4cfb07a5 | ||
|
|
6adb13ff88 | ||
|
|
ff1208fd3c | ||
|
|
3d4aef7fe3 | ||
|
|
5cd895f04e | ||
|
|
49b1d60fca | ||
|
|
b258ec3de5 | ||
|
|
f0a18d7011 | ||
|
|
9b66dc3329 | ||
|
|
c1b1b289c1 | ||
|
|
6aa7cb3d22 | ||
|
|
1f60931a0f | ||
|
|
42a457f973 | ||
|
|
e6357b0d61 | ||
|
|
63fc3cfa43 | ||
|
|
d50be8e7af | ||
|
|
d6b1a86e95 | ||
|
|
ca669a1c5c | ||
|
|
ffd0e97508 | ||
|
|
2bc9617b14 | ||
|
|
3aa7aa0d50 | ||
|
|
8a6befd481 | ||
|
|
652a6b830d | ||
|
|
b2b9607f64 | ||
|
|
bdc9411782 | ||
|
|
8529c3f0b6 | ||
|
|
732235c93a | ||
|
|
539beaf225 | ||
|
|
f9eb4b41b6 | ||
|
|
4e42ac8b04 | ||
|
|
869e0d82ee | ||
|
|
49c0ae2413 | ||
|
|
4b5f379126 | ||
|
|
aad8292f9e | ||
|
|
44a21d662d | ||
|
|
ae2cef4335 | ||
|
|
57462af4f4 | ||
|
|
425025ad68 | ||
|
|
b879760013 | ||
|
|
21aa1db07e | ||
|
|
81fe6d29e2 | ||
|
|
b2d7fa1723 | ||
|
|
4c641ab93a | ||
|
|
84720ff23c | ||
|
|
d7307e146a | ||
|
|
7d4059ca4b | ||
|
|
9691842e79 | ||
|
|
094840e671 | ||
|
|
e8592b25a8 | ||
|
|
27b385df53 | ||
|
|
e170844f17 | ||
|
|
27c1194384 | ||
|
|
26ea095f60 | ||
|
|
751d16a9f4 | ||
|
|
285214a2d2 | ||
|
|
89645f2abd | ||
|
|
7dadeb88fe | ||
|
|
13531fec40 | ||
|
|
e254efd420 | ||
|
|
6d79911414 | ||
|
|
69a859e19f | ||
|
|
098ce86c76 | ||
|
|
9ef809ba02 | ||
|
|
024d572ebb | ||
|
|
d24f09bbea | ||
|
|
56fe6c0754 | ||
|
|
c76de207d7 | ||
|
|
4e89a7a96c | ||
|
|
0fc3aa421e | ||
|
|
c10fbe22d7 | ||
|
|
e3e0b06fb6 | ||
|
|
b3d85b93f1 | ||
|
|
659607a1e9 | ||
|
|
80a0d2c56f | ||
|
|
66448a25f4 | ||
|
|
93144b9de8 | ||
|
|
b0c415f90f | ||
|
|
8a2225da7c | ||
|
|
e0c5971d20 | ||
|
|
a499d55636 | ||
|
|
c36890cc8b | ||
|
|
b80ba0434b | ||
|
|
01d3735dd1 | ||
|
|
e0bcb2fe0a | ||
|
|
a1c83a6134 | ||
|
|
bd5e3076ed | ||
|
|
316b8fa66a | ||
|
|
6f907f6a96 | ||
|
|
93caf0116d | ||
|
|
15af8d54e6 | ||
|
|
c4ed7b3482 | ||
|
|
066d407a5f | ||
|
|
c2826ae4be | ||
|
|
adb1cc81ef | ||
|
|
a4fd10e640 | ||
|
|
efa3051c61 | ||
|
|
72e09501de | ||
|
|
875fe625b5 | ||
|
|
dac1d50b02 | ||
|
|
e104ffc3ab | ||
|
|
1cffb9bdbf | ||
|
|
bae84f1a48 | ||
|
|
938c8eef8a | ||
|
|
50d01c7aec | ||
|
|
ef04bec66f | ||
|
|
2e9ec31d83 | ||
|
|
ca290225b9 | ||
|
|
a5ec0647ec | ||
|
|
57f5470f0d | ||
|
|
33e5edc2ba | ||
|
|
fadda94135 | ||
|
|
5fa3df9c16 | ||
|
|
b48ceea0af | ||
|
|
9e31cfa78e | ||
|
|
c63c94b561 | ||
|
|
cbdb37f5a5 | ||
|
|
05de7405ba | ||
|
|
68286b61bd | ||
|
|
a7fbc4c7e3 | ||
|
|
1a5605569c | ||
|
|
ef71710244 | ||
|
|
ca78a4cbc0 | ||
|
|
b652248404 | ||
|
|
f5ac37867c | ||
|
|
37878df992 | ||
|
|
9e90791743 | ||
|
|
dd3f1442b0 | ||
|
|
a5556743f0 | ||
|
|
ca231e7b7c | ||
|
|
a5a6e25a89 | ||
|
|
df8cbb5c35 | ||
|
|
d0b344beec | ||
|
|
1f4adfca90 | ||
|
|
259ab50b25 | ||
|
|
a04c2434b6 | ||
|
|
c291fc689a | ||
|
|
b61a6de73a | ||
|
|
f2a68ee5f6 | ||
|
|
0c43f5633f | ||
|
|
4ebf0d4062 | ||
|
|
244d53f93d | ||
|
|
8dceacc2ce | ||
|
|
7244810fe1 | ||
|
|
e9c790e017 | ||
|
|
9b32d834b3 | ||
|
|
333b6cb769 | ||
|
|
87c444e78d | ||
|
|
811759dddb | ||
|
|
275edab4bf | ||
|
|
0371a46731 | ||
|
|
cd8f6a6751 | ||
|
|
dd98aaaf4d | ||
|
|
20bc28e59b | ||
|
|
5d112c8dfd | ||
|
|
27bc9d90af | ||
|
|
016c44c6f0 | ||
|
|
02a0f3635b | ||
|
|
109551f713 | ||
| f129b3ba43 | |||
| 7f0c6f45b0 | |||
|
|
2caea8e21d | ||
|
|
b23c4ef255 | ||
|
|
801ae43000 | ||
| bd9af5ddd6 | |||
| 3ae9e450be | |||
| 7616153345 | |||
| 0c21f47a59 | |||
| 7256f1ef4e | |||
| bf635d9c30 | |||
| 5add259348 | |||
| 198fd62ef2 | |||
| 34a771bee3 | |||
| 65a08838c9 | |||
| 8b5a05a16e | |||
| 6a87590176 | |||
| cd4644637b | |||
| 9fd441e7d7 | |||
| b7ddc95171 | |||
| 488dab7aa1 | |||
| a52e5362b3 | |||
| 582ad389e1 | |||
| 3283cc9ad5 | |||
|
|
396fd2faa4 | ||
| fc71ee6e02 | |||
| 96d49abd9a | |||
| 3bc08c6de7 | |||
| 8fe2b1c43e | |||
| 43cfb694e7 | |||
| e9347c5e5a | |||
| 3bc8ad32cd | |||
| 038cd48285 | |||
| 8830793105 | |||
| fa1cd36670 | |||
| c61d572023 | |||
| 7af6f0d9e0 | |||
| 7fd1e85adb | |||
| 34e725135d | |||
| 1caa930977 | |||
| d36ca43804 | |||
| b06f5f6022 | |||
| c3f298e384 | |||
| 733a3c16a8 | |||
| aec83c30d2 | |||
| 3a7a85c617 | |||
| 3051e6e0a9 | |||
| 4cd382b829 | |||
| 0d6c688015 | |||
| b804629f91 | |||
| b860e794a3 | |||
| 6f73824e7e | |||
| e132459fef | |||
| 43b031de5b | |||
| e769ff2806 | |||
| 0c8f0c429a | |||
| 35d7c3e710 | |||
| 89df7e48ad |
@@ -15,7 +15,7 @@ No test suite currently.
|
||||
|
||||
## Architecture
|
||||
|
||||
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).
|
||||
AdiuvAI 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 19) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||
@@ -31,8 +31,8 @@ Owns the database and all business logic.
|
||||
| `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 |
|
||||
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, noteEdits, taskComments |
|
||||
| `db/notes-backfill.ts` | Startup backfill: generates aiSummary for notes with null summary |
|
||||
| `store.ts` | electron-store for persistent UI settings |
|
||||
|
||||
### Preload (`src/preload/trpc.ts`)
|
||||
@@ -57,11 +57,11 @@ Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`
|
||||
|
||||
### tRPC Routers
|
||||
|
||||
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
|
||||
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `noteEdits`, `taskComments`, `ai`
|
||||
|
||||
### Database
|
||||
|
||||
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.
|
||||
Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuvai.db`. On startup, `initDb()` runs non-destructive migrations.
|
||||
|
||||
To add a table/column: edit `schema.ts` → `drizzle-kit generate` → `drizzle-kit push` (dev) or commit the migration.
|
||||
|
||||
@@ -83,7 +83,7 @@ Classifies user intent → routes to a specialist agent:
|
||||
| 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` |
|
||||
| Knowledge | Cross-project search | `list_notes` + `get_note` (aiSummary-based navigation) |
|
||||
| General | Workspace-wide | `add_task` |
|
||||
|
||||
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
|
||||
@@ -104,22 +104,26 @@ Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tR
|
||||
|
||||
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:
|
||||
1. electron-store + `safeStorage` — encrypted at rest (preferred)
|
||||
2. Plain electron-store — last resort (e.g. WSL with no keyring)
|
||||
|
||||
**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records.
|
||||
### Notes AI Navigation (aiSummary index)
|
||||
|
||||
### Vector Embeddings (`src/main/db/vectordb.ts`)
|
||||
Notes have `aiSummary` (≤250 char, nullable) and `aiSummaryUpdatedAt` columns. Generated by backend `POST /api/v1/scouts/notes/summarize` (gpt-4o-mini, Langfuse `note_summary` prompt).
|
||||
|
||||
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.
|
||||
- `list_notes` tool output includes the summary per note so AI can navigate without reading full content.
|
||||
- `notes-backfill.ts` generates missing summaries on startup (throttled 1 req/s, skipped when offline).
|
||||
- Summary is regenerated fire-and-forget on note create/update and on HITL approve.
|
||||
|
||||
- `upsertNoteEmbedding()` on note create/update (fire-and-forget)
|
||||
- `migrateNotesIfNeeded()` backfills on first startup
|
||||
- `searchNotes(query, limit=5)` used by Knowledge agent
|
||||
### Notes HITL (`noteEdits` table)
|
||||
|
||||
AI-proposed note edits go to `noteEdits` instead of directly modifying `notes.content`:
|
||||
- `type: append | insert | replace` — append adds at end; insert after `anchorBefore` text; replace replaces `anchorText`.
|
||||
- `status: pending | approved | rejected` — pending shows in UI with dashed border + Approve/Reject.
|
||||
- On approve: content merged into `notes.content`; summary regenerated. If anchor not found (note edited since proposal), auto-rejects.
|
||||
- `propose_note_edit` backend tool → drizzle-executor `propose_note_edit` case → inserts `noteEdits` row.
|
||||
- `noteEditsRouter` in `router/index.ts`: `list`, `listPending`, `approve`, `reject`.
|
||||
|
||||
### AI Approval Pattern
|
||||
|
||||
@@ -136,31 +140,34 @@ Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestion
|
||||
|
||||
## Design Context
|
||||
|
||||
### Target User
|
||||
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
|
||||
### Users
|
||||
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier. They open the app mid-workday — often stressed — so the interface must feel immediately grounding and in control.
|
||||
|
||||
### Brand
|
||||
**Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
|
||||
### Brand Personality
|
||||
**Calm. Intelligent. Warm.** A thoughtful companion, not a flashy tool. Confident and understated — never loud, gamified, or corporate. Fully original aesthetic (no external design system references; this look is intentional and owned).
|
||||
|
||||
### Palette
|
||||
### Emotional Goal
|
||||
When a user opens AdiuvAI, the first impression should communicate **"everything is under control"** — calm clarity over urgency. The design should lower cognitive load, not raise it.
|
||||
|
||||
| | 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)
|
||||
### Aesthetic Direction
|
||||
- Light mode: pinkish-white canvas `#f4edf3`, golden yellow primary `#fbc881`, slate blue-gray secondary `#8a8ea9`, dusty lavender borders `#c8c3cd`
|
||||
- Dark mode: near-black `#0c0c0c`, pure white primary, dark gray `#323232` surfaces
|
||||
- Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`
|
||||
- 10px border-radius (`rounded-lg`), `rounded-2xl` for chat/AI elements
|
||||
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency, gradient border via padding-box/border-box technique)
|
||||
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
|
||||
- No gamification (badges, streaks, confetti). Mature and professional
|
||||
- Dashed borders + Sparkles icon = AI-pending state marker
|
||||
|
||||
### Accessibility
|
||||
Best-effort — not formally audited. Maintain reasonable contrast and keyboard operability without targeting a specific WCAG level.
|
||||
|
||||
### Current Design Focus
|
||||
**Polish and refinement.** The overall direction is solid; the priority is elevating specific areas that feel rough or inconsistent — tighter spacing, more intentional hierarchy, better empty/loading states, and smoother motion.
|
||||
|
||||
### Design Principles
|
||||
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
|
||||
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density. Never sacrifice legibility for style.
|
||||
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as the sole AI marker. Surface AI capabilities without making them the hero.
|
||||
3. **Warmth in restraint** — The warm palette feels approachable without being playful. Dark mode trades warmth for focus. Neither mode should feel cold or aggressive.
|
||||
4. **Motion with purpose** — Spring animations reinforce spatial relationships and acknowledge state changes. Never purely decorative. Respect reduced-motion preferences where possible.
|
||||
5. **Polish over features** — Every surface should feel considered. Prefer refining what exists over introducing new complexity. The right amount of visual weight is the minimum needed.
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add AI_REFACTOR_PLAN.md)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"Read(//home/rmusso/adiuvai-api/**)",
|
||||
"mcp__shadcn__get_item_examples_from_registries",
|
||||
"mcp__shadcn__view_items_in_registries",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npx eslint --ext .ts,.tsx src/renderer/components/ai/blocks/)",
|
||||
"WebFetch(domain:ui.shadcn.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/electron",
|
||||
"plugin:import/typescript"
|
||||
|
||||
10
.gitignore
vendored
@@ -91,6 +91,16 @@ typings/
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
# Web SPA build
|
||||
dist-web/
|
||||
|
||||
# local config files
|
||||
.vscode/
|
||||
.agents/
|
||||
src/renderer/routeTree.gen.ts
|
||||
|
||||
# Local dev SQLite (used by drizzle-kit push for schema verification)
|
||||
dev.db
|
||||
dev.db-journal
|
||||
dev.db-shm
|
||||
dev.db-wal
|
||||
|
||||
11
.mcp.json
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,306 +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
|
||||
- [ ] 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>`.
|
||||
385
assets/logo/brand-showcase.html
Normal file
@@ -0,0 +1,385 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>adiuvAI — Brand Identity</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg-light: #f4edf3; --bg-dark: #0c0c0c;
|
||||
--text: #040404; --text-light: #fbfbfb;
|
||||
--primary: #fbc881; --secondary: #8a8ea9;
|
||||
--muted: #c8c3cd; --border: #c8c3cd;
|
||||
--radius: 10px;
|
||||
}
|
||||
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg-light); color: var(--text); -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 0 32px; }
|
||||
.section-label { font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--primary); margin-bottom: 12px; }
|
||||
|
||||
/* ── COMPASS ANIMATION ── */
|
||||
.compass-needle { animation: compass-settle 5s ease-in-out infinite; transform-origin: 32px 32px; }
|
||||
@keyframes compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero { background: var(--bg-dark); padding: 96px 32px 80px; text-align: center; }
|
||||
.hero-mark { width: 96px; height: 96px; margin: 0 auto 32px; }
|
||||
.hero-label { font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--primary); margin-bottom: 16px; }
|
||||
.hero-name { font-size: clamp(40px,6vw,64px); font-weight: 700; color: var(--text-light); letter-spacing: -2px; line-height: 1; margin-bottom: 16px; }
|
||||
.hero-name span { color: var(--primary); }
|
||||
.hero-tagline { font-size: 16px; color: rgba(251,251,251,.45); max-width: 420px; margin: 0 auto; line-height: 1.6; }
|
||||
|
||||
/* ── SECTIONS ── */
|
||||
.section { padding: 72px 32px; border-bottom: 1px solid var(--border); }
|
||||
.section:last-of-type { border-bottom: none; }
|
||||
.section-dark { background: var(--bg-dark); border-bottom: 1px solid #1a1a1a; }
|
||||
.section h2 { font-size: 22px; font-weight: 600; color: var(--text); margin-bottom: 8px; letter-spacing: -.5px; }
|
||||
.section-dark h2 { color: var(--text-light); }
|
||||
.section > p { font-size: 14px; color: var(--secondary); line-height: 1.7; max-width: 560px; margin-bottom: 40px; }
|
||||
.section-dark > p { color: rgba(251,251,251,.4); }
|
||||
|
||||
/* ── CONCEPT GRID ── */
|
||||
.concept-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: start; }
|
||||
@media (max-width:700px) { .concept-grid { grid-template-columns: 1fr; } }
|
||||
.concept-mark-wrapper { background: var(--bg-dark); border-radius: var(--radius); padding: 56px 48px; display: flex; justify-content: center; align-items: center; }
|
||||
.concept-text .point { display: flex; gap: 12px; margin-bottom: 22px; align-items: flex-start; }
|
||||
.point-icon { width: 26px; height: 26px; background: rgba(251,200,129,.1); border: 1px solid rgba(251,200,129,.22); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; font-size: 12px; }
|
||||
.point-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
|
||||
.point-desc { font-size: 13px; color: var(--secondary); line-height: 1.65; margin: 0; }
|
||||
|
||||
/* ── VARIANTS ── */
|
||||
.variants-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.variant-full { grid-column: 1 / -1; }
|
||||
@media (max-width:700px) { .variants-grid { grid-template-columns: 1fr; } }
|
||||
.logo-card { border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); transition: transform .18s ease, box-shadow .18s ease; }
|
||||
.logo-card:hover { transform: translateY(-3px); box-shadow: 0 8px 32px rgba(0,0,0,.10); }
|
||||
.logo-card-dark { border-color: #1e1e1e; }
|
||||
.logo-card-dark:hover { box-shadow: 0 8px 32px rgba(0,0,0,.5); }
|
||||
.card-inner { padding: 40px 32px; display: flex; align-items: center; justify-content: center; min-height: 120px; }
|
||||
.card-inner-light { background: var(--bg-light); }
|
||||
.card-inner-dark { background: var(--bg-dark); }
|
||||
.card-inner-white { background: #fff; }
|
||||
.card-inner img { max-width: 100%; max-height: 72px; object-fit: contain; }
|
||||
.card-meta { padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--border); background: #fff; }
|
||||
.card-meta-dark { border-top-color: #1e1e1e; background: #0e0e0e; }
|
||||
.card-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--secondary); }
|
||||
.card-meta-dark .card-label { color: rgba(251,251,251,.3); }
|
||||
.card-filename { font-size: 11px; font-family: monospace; color: var(--muted); }
|
||||
.card-meta-dark .card-filename { color: rgba(251,251,251,.2); }
|
||||
|
||||
/* ── PALETTE ── */
|
||||
.palette-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(140px,1fr)); gap: 12px; }
|
||||
.color-chip { border-radius: var(--radius); overflow: hidden; border: 1px solid rgba(0,0,0,.06); }
|
||||
.color-swatch { height: 80px; display: flex; align-items: flex-end; padding: 8px 10px; }
|
||||
.color-hex { font-size: 11px; font-family: monospace; font-weight: 500; opacity: .65; }
|
||||
.color-info { padding: 10px; background: #fff; }
|
||||
.color-name { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
|
||||
.color-role { font-size: 11px; color: var(--secondary); }
|
||||
|
||||
/* ── TYPE ── */
|
||||
.type-specimen { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 40px; }
|
||||
.type-row { padding: 20px 0; border-bottom: 1px solid #f0eaef; }
|
||||
.type-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.type-meta { font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; }
|
||||
.type-xl { font-size: 48px; font-weight: 700; letter-spacing: -2px; line-height: 1; }
|
||||
.type-xl span { color: var(--primary); }
|
||||
.type-lg { font-size: 28px; font-weight: 600; letter-spacing: -.8px; }
|
||||
.type-md { font-size: 16px; font-weight: 400; line-height: 1.6; }
|
||||
.type-sm { font-size: 12px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--secondary); }
|
||||
|
||||
/* ── DEV REF ── */
|
||||
.dev-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
@media (max-width:700px) { .dev-grid { grid-template-columns: 1fr; } }
|
||||
.code-block { background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: var(--radius); padding: 24px; overflow-x: auto; }
|
||||
.code-block pre { font-family: monospace; font-size: 12px; line-height: 1.7; color: #e1e2e8; }
|
||||
.ck { color: #a1c9fd; } .cs { color: #fbc881; } .cc { color: rgba(225,226,232,.28); font-style: italic; }
|
||||
.file-tree { background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: var(--radius); padding: 24px; }
|
||||
.file-tree-title { font-size: 11px; font-weight: 600; letter-spacing: .10em; text-transform: uppercase; color: rgba(251,251,251,.28); margin-bottom: 16px; }
|
||||
.fi { display: flex; gap: 10px; margin-bottom: 10px; align-items: flex-start; }
|
||||
.fi:last-child { margin-bottom: 0; }
|
||||
.fi-icon { font-size: 12px; flex-shrink: 0; margin-top: 1px; }
|
||||
.fi-name { font-family: monospace; font-size: 12px; color: #fbc881; flex-shrink: 0; }
|
||||
.fi-desc { font-size: 12px; color: rgba(225,226,232,.3); line-height: 1.5; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
.footer { background: var(--bg-dark); padding: 48px 32px; text-align: center; }
|
||||
.footer-name { font-size: 20px; font-weight: 700; color: var(--text-light); letter-spacing: -.5px; margin-bottom: 4px; }
|
||||
.footer-name span { color: var(--primary); }
|
||||
.footer-sub { font-size: 12px; color: rgba(251,251,251,.28); letter-spacing: .06em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero-mark">
|
||||
<svg viewBox="0 0 64 64" fill="none" width="96" height="96">
|
||||
<g class="compass-needle">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.75"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#FFFFFF" opacity="0.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="hero-label">Brand Identity</p>
|
||||
<h1 class="hero-name">adiuv<span>AI</span></h1>
|
||||
<p class="hero-tagline">Il tuo compasso nel lavoro quotidiano — l'AI che ti indica sempre la direzione giusta.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DESIGN CONCEPT -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<p class="section-label">Design Concept</p>
|
||||
<h2>Il Compasso</h2>
|
||||
<p>Non il gesto dell'aiuto, ma il suo significato più profondo: qualcuno che ti indica la strada. Un ago di bussola che oscilla e si ferma sempre a nord.</p>
|
||||
|
||||
<div class="concept-grid">
|
||||
|
||||
<div class="concept-mark-wrapper">
|
||||
<svg viewBox="0 0 64 64" fill="none" width="140" height="140">
|
||||
<g class="compass-needle">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.65"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="#fff" stroke-width="0.5" opacity="0.15"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#fff" opacity="0.2"/>
|
||||
</g>
|
||||
<!-- Annotations -->
|
||||
<text x="34" y="14" font-size="4" fill="rgba(251,200,129,.6)" font-family="Inter,sans-serif" letter-spacing=".05em">NORD · AI</text>
|
||||
<line x1="32" y1="17" x2="32" y2="20" stroke="rgba(251,200,129,.4)" stroke-width=".6"/>
|
||||
<text x="34" y="52" font-size="4" fill="rgba(255,255,255,.35)" font-family="Inter,sans-serif" letter-spacing=".05em">SUD · YOU</text>
|
||||
<line x1="32" y1="47" x2="32" y2="50" stroke="rgba(255,255,255,.2)" stroke-width=".6"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="concept-text">
|
||||
<div class="point">
|
||||
<div class="point-icon">▲</div>
|
||||
<div>
|
||||
<div class="point-title">Nord dorato = l'AI</div>
|
||||
<p class="point-desc">La punta superiore (#fbc881) punta sempre verso l'alto — verso l'obiettivo. È l'AI: calda, orientata, che guida senza invadere.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<div class="point-icon">▼</div>
|
||||
<div>
|
||||
<div class="point-title">Sud scuro = l'utente</div>
|
||||
<p class="point-desc">La punta inferiore (#040404) è ancorata alla realtà. L'utente con le sue attività, i suoi progetti, il suo lavoro concreto.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<div class="point-icon">↺</div>
|
||||
<div>
|
||||
<div class="point-title">L'oscillazione (animazione)</div>
|
||||
<p class="point-desc">Il mark oscilla leggermente come un vero ago prima di fermarsi — trovare il nord. Un dettaglio quasi impercettibile, fedele alla brand personality "calma, mai appariscente".</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<div class="point-icon">◇</div>
|
||||
<div>
|
||||
<div class="point-title">Il diamante (forma)</div>
|
||||
<p class="point-desc">Due triangoli che formano un rombo. Forma archetipica, funziona a 16px come a 512px. La divisione orizzontale racconta la relazione senza bisogno di parole.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LOGO VARIANTS -->
|
||||
<section class="section" style="background:#faf5f9;">
|
||||
<div class="container">
|
||||
<p class="section-label">Logo Variants</p>
|
||||
<h2>7 File Canonici</h2>
|
||||
<p>Ogni variante usa gli stessi due triangoli — cambiano solo colore e scala.</p>
|
||||
|
||||
<div class="variants-grid">
|
||||
|
||||
<div class="logo-card variant-full">
|
||||
<div class="card-inner card-inner-light" style="min-height:100px;">
|
||||
<img src="logo-full.svg" alt="adiuvAI full logo">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">Full Logo</span>
|
||||
<span class="card-filename">logo-full.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card logo-card-dark variant-full">
|
||||
<div class="card-inner card-inner-dark" style="min-height:100px;">
|
||||
<img src="logo-white.svg" alt="adiuvAI white">
|
||||
</div>
|
||||
<div class="card-meta card-meta-dark">
|
||||
<span class="card-label">White Variant</span>
|
||||
<span class="card-filename">logo-white.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card logo-card-dark">
|
||||
<div class="card-inner card-inner-dark" style="min-height:140px;">
|
||||
<img src="logo-mark.svg" alt="mark" style="width:80px;height:80px;">
|
||||
</div>
|
||||
<div class="card-meta card-meta-dark">
|
||||
<span class="card-label">Mark</span>
|
||||
<span class="card-filename">logo-mark.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card">
|
||||
<div class="card-inner card-inner-white" style="min-height:140px;">
|
||||
<img src="logo-icon.svg" alt="icon" style="max-width:110px;max-height:110px;">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">App Icon</span>
|
||||
<span class="card-filename">logo-icon.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card">
|
||||
<div class="card-inner card-inner-light">
|
||||
<img src="logo-wordmark.svg" alt="wordmark" style="max-height:40px;">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">Wordmark</span>
|
||||
<span class="card-filename">logo-wordmark.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card logo-card-dark">
|
||||
<div class="card-inner card-inner-dark" style="flex-direction:column;gap:8px;">
|
||||
<img src="favicon.svg" alt="favicon" style="width:64px;height:64px;image-rendering:pixelated;">
|
||||
<span style="font-size:10px;color:rgba(251,251,251,.25);font-family:monospace;">16×16 px</span>
|
||||
</div>
|
||||
<div class="card-meta card-meta-dark">
|
||||
<span class="card-label">Favicon</span>
|
||||
<span class="card-filename">favicon.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card variant-full">
|
||||
<div class="card-inner card-inner-white" style="min-height:100px;">
|
||||
<img src="logo-black.svg" alt="black">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">Black Variant</span>
|
||||
<span class="card-filename">logo-black.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- COLOR PALETTE -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<p class="section-label">Color Palette</p>
|
||||
<h2>Colori Brand</h2>
|
||||
<p>Estratti direttamente da globals.css. Canvas rosato caldo in light; monocromo rigoroso in dark.</p>
|
||||
<div class="palette-grid">
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#fbc881;"><span class="color-hex" style="color:rgba(4,4,4,.5);">#fbc881</span></div>
|
||||
<div class="color-info"><div class="color-name">Golden</div><div class="color-role">Nord · AI · Accent</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#f4edf3;"><span class="color-hex" style="color:rgba(4,4,4,.35);">#f4edf3</span></div>
|
||||
<div class="color-info"><div class="color-name">Canvas Light</div><div class="color-role">Sfondo light mode</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#0c0c0c;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#0c0c0c</span></div>
|
||||
<div class="color-info"><div class="color-name">Canvas Dark</div><div class="color-role">Sfondo dark mode</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#040404;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#040404</span></div>
|
||||
<div class="color-info"><div class="color-name">Ink</div><div class="color-role">Sud · utente · testo</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#fbfbfb;border:1px solid #e8e0e7;"><span class="color-hex" style="color:rgba(4,4,4,.28);">#fbfbfb</span></div>
|
||||
<div class="color-info"><div class="color-name">Paper</div><div class="color-role">Testo dark mode</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#8a8ea9;"><span class="color-hex" style="color:rgba(251,251,251,.65);">#8a8ea9</span></div>
|
||||
<div class="color-info"><div class="color-name">Slate</div><div class="color-role">Secondario · muted</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#c8c3cd;"><span class="color-hex" style="color:rgba(4,4,4,.38);">#c8c3cd</span></div>
|
||||
<div class="color-info"><div class="color-name">Lavender</div><div class="color-role">Bordi · muted</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#323232;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#323232</span></div>
|
||||
<div class="color-info"><div class="color-name">Graphite</div><div class="color-role">Dark surfaces</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TYPOGRAPHY -->
|
||||
<section class="section" style="background:#faf5f9;">
|
||||
<div class="container">
|
||||
<p class="section-label">Typography</p>
|
||||
<h2>Geist · System Sans-Serif</h2>
|
||||
<p>Geometrico, pulito, sicuro. @fontsource/geist nell'app; system-ui come fallback.</p>
|
||||
<div class="type-specimen">
|
||||
<div class="type-row"><div class="type-meta">Display · 48px · 700</div><div class="type-xl">adiuv<span>AI</span></div></div>
|
||||
<div class="type-row"><div class="type-meta">Heading · 28px · 600</div><div class="type-lg">Workspace intelligente, locale, caldo.</div></div>
|
||||
<div class="type-row"><div class="type-meta">Body · 16px · 400</div><div class="type-md">adiuvAI organizza i tuoi progetti, task e note in un workspace calmo — con un AI che ti guida silenziosamente verso gli obiettivi.</div></div>
|
||||
<div class="type-row"><div class="type-meta">Label · 11px · 600 · Tracked</div><div class="type-sm">Brand Identity · Design System · 2026</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DEVELOPER REFERENCE -->
|
||||
<section class="section-dark section">
|
||||
<div class="container">
|
||||
<p class="section-label">Developer Reference</p>
|
||||
<h2>Tailwind Config · File Tree</h2>
|
||||
<p>Token da aggiungere al blocco @theme di globals.css (Tailwind 4).</p>
|
||||
<div class="dev-grid">
|
||||
<div class="code-block">
|
||||
<pre><span class="cc">// globals.css — @theme inline</span>
|
||||
<span class="ck">--brand-golden</span>: <span class="cs">#fbc881</span>; <span class="cc">/* nord · AI */</span>
|
||||
<span class="ck">--brand-canvas</span>: <span class="cs">#f4edf3</span>; <span class="cc">/* light bg */</span>
|
||||
<span class="ck">--brand-void</span>: <span class="cs">#0c0c0c</span>; <span class="cc">/* dark bg */</span>
|
||||
<span class="ck">--brand-ink</span>: <span class="cs">#040404</span>; <span class="cc">/* sud · user */</span>
|
||||
<span class="ck">--brand-slate</span>: <span class="cs">#8a8ea9</span>; <span class="cc">/* secondary */</span>
|
||||
<span class="ck">--brand-lavender</span>: <span class="cs">#c8c3cd</span>; <span class="cc">/* border */</span>
|
||||
<span class="ck">--brand-graphite</span>: <span class="cs">#323232</span>; <span class="cc">/* dark surface */</span>
|
||||
<span class="ck">--brand-paper</span>: <span class="cs">#fbfbfb</span>; <span class="cc">/* light text */</span></pre>
|
||||
</div>
|
||||
<div class="file-tree">
|
||||
<div class="file-tree-title">assets/logo/</div>
|
||||
<div class="fi"><span class="fi-icon">◇</span><span class="fi-name">logo-mark.svg</span><span class="fi-desc">Compasso canonico · 64×64 · animato</span></div>
|
||||
<div class="fi"><span class="fi-icon">◻</span><span class="fi-name">logo-full.svg</span><span class="fi-desc">Mark + wordmark · 320×72 · animato</span></div>
|
||||
<div class="fi"><span class="fi-icon">T</span><span class="fi-name">logo-wordmark.svg</span><span class="fi-desc">Solo testo · 200×40</span></div>
|
||||
<div class="fi"><span class="fi-icon">▣</span><span class="fi-name">logo-icon.svg</span><span class="fi-desc">App icon · 512×512</span></div>
|
||||
<div class="fi"><span class="fi-icon">·</span><span class="fi-name">favicon.svg</span><span class="fi-desc">Semplificato · 16×16</span></div>
|
||||
<div class="fi"><span class="fi-icon">◻</span><span class="fi-name">logo-white.svg</span><span class="fi-desc">Variante bianca · su sfondo scuro</span></div>
|
||||
<div class="fi"><span class="fi-icon">◻</span><span class="fi-name">logo-black.svg</span><span class="fi-desc">Variante nera · su sfondo chiaro</span></div>
|
||||
<div class="fi" style="margin-top:12px;padding-top:12px;border-top:1px solid #1e1e1e;">
|
||||
<span class="fi-icon">🌐</span><span class="fi-name">brand-showcase.html</span><span class="fi-desc">Questa pagina</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-name">adiuv<span>AI</span></div>
|
||||
<div class="footer-sub">Brand Identity · roberto · 2026</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
10
assets/logo/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<!--
|
||||
adiuvAI — Favicon 16×16
|
||||
Same compass needle, scaled to canvas.
|
||||
North: M8,1 L13,8 L3,8 Z
|
||||
South: M3,8 L13,8 L8,15 Z
|
||||
-->
|
||||
<path d="M8,1 L13,8 L3,8 Z" fill="#fbc881"/>
|
||||
<path d="M3,8 L13,8 L8,15 Z" fill="#040404"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
13
assets/logo/logo-black.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
|
||||
<!-- adiuvAI — Black variant (light backgrounds, no color) -->
|
||||
<g transform="translate(2,2)">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#1A1A1A" opacity="0.55"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#1A1A1A"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#1A1A1A" opacity="0.2"/>
|
||||
</g>
|
||||
<text x="65" y="42"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#1A1A1A" opacity="0.7">adiuv</tspan><tspan font-weight="700" fill="#1A1A1A">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 646 B |
35
assets/logo/logo-full.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
|
||||
<!--
|
||||
adiuvAI — Full logo (mark + wordmark)
|
||||
Mark: translate(4,4) — canonical paths from logo-mark.svg
|
||||
-->
|
||||
<style>
|
||||
.compass-needle {
|
||||
animation: compass-settle 5s ease-in-out infinite;
|
||||
transform-origin: 32px 32px;
|
||||
}
|
||||
@keyframes compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<g transform="translate(2,2)">
|
||||
<g class="compass-needle">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32"
|
||||
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<text x="65" y="42"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#040404">adiuv</tspan><tspan font-weight="700" fill="#fbc881">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/logo/logo-icon.ico
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/logo/logo-icon.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
14
assets/logo/logo-icon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!--
|
||||
adiuvAI — App icon 512×512
|
||||
Mark scaled 6.5× — translate(48,48) scale(6.5)
|
||||
-->
|
||||
<rect width="512" height="512" rx="112" fill="#f4edf3"/>
|
||||
<g transform="translate(48,48) scale(6.5)">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32"
|
||||
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
41
assets/logo/logo-mark.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!--
|
||||
adiuvAI — "Il Compasso" (The Compass Needle)
|
||||
|
||||
A compass needle split at its equator:
|
||||
North (top) → golden = the AI, always pointing toward your goal
|
||||
South (bottom) → dark = the user, grounded in reality
|
||||
|
||||
CANONICAL PATHS (derive all variants from these):
|
||||
North: M32,4 L48,32 L16,32 Z
|
||||
South: M16,32 L48,32 L32,60 Z
|
||||
Center: line x1=16 y1=32 x2=48 y2=32 (1px hairline separator)
|
||||
|
||||
The shape oscillates like a compass finding north — settles on upward guidance.
|
||||
-->
|
||||
<style>
|
||||
.compass-needle {
|
||||
animation: compass-settle 5s ease-in-out infinite;
|
||||
transform-origin: 32px 32px;
|
||||
}
|
||||
@keyframes compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<g class="compass-needle">
|
||||
<!-- North — AI (golden) -->
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<!-- South — Human (dark) -->
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<!-- Hairline equator -->
|
||||
<line x1="16" y1="32" x2="48" y2="32"
|
||||
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
|
||||
<!-- Center pivot -->
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
assets/logo/logo-white.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
|
||||
<!-- adiuvAI — White variant (dark backgrounds) -->
|
||||
<g transform="translate(2,2)">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.85"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#FFFFFF" opacity="0.25"/>
|
||||
</g>
|
||||
<text x="65" y="42"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#FFFFFF" opacity="0.85">adiuv</tspan><tspan font-weight="700" fill="#FFFFFF">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 637 B |
7
assets/logo/logo-wordmark.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 105 30" fill="none">
|
||||
<text x="2" y="25"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#040404">adiuv</tspan><tspan font-weight="700" fill="#fbc881">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
BIN
assets/screenshot/home.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
assets/screenshot/home_chat.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
assets/screenshot/projects.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
assets/screenshot/task.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
@@ -1,7 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/main/db/schema.ts',
|
||||
out: './src/main/db/migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: `file:${path.resolve('./dev.db')}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,12 +15,8 @@ import { execSync } from 'node:child_process';
|
||||
// Keep this list in sync with the Vite external array.
|
||||
const externalPackages = [
|
||||
'better-sqlite3',
|
||||
'@github/copilot-sdk',
|
||||
'@langchain/core',
|
||||
'@langchain/langgraph',
|
||||
'@langchain/openai',
|
||||
'@langchain/anthropic',
|
||||
'vectordb',
|
||||
'@lancedb/lancedb',
|
||||
'ws',
|
||||
'electron-squirrel-startup',
|
||||
'electron-store',
|
||||
];
|
||||
@@ -30,7 +26,23 @@ const config: ForgeConfig = {
|
||||
asar: {
|
||||
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
|
||||
},
|
||||
name: 'adiuva',
|
||||
name: 'adiuvAI',
|
||||
// icon path without extension — Forge picks .ico (Win), .icns (Mac), .png (Linux)
|
||||
icon: 'assets/logo/logo-icon',
|
||||
// Ship Drizzle's generated migrations as a sibling of the asar so the
|
||||
// runtime migrator (drizzle-orm/better-sqlite3/migrator) can read them at
|
||||
// `<resourcesPath>/migrations/` in packaged builds. See src/main/db/index.ts.
|
||||
extraResource: ['./src/main/db/migrations'],
|
||||
// Deep-link protocol for OAuth callback: adiuvai://oauth/callback?code=...
|
||||
// macOS: written into Info.plist by Forge automatically.
|
||||
// Windows: registered by the Squirrel installer via packagerConfig.protocols.
|
||||
// Dev: app.setAsDefaultProtocolClient() in index.ts handles both platforms.
|
||||
protocols: [
|
||||
{
|
||||
name: 'AdiuvAI',
|
||||
schemes: ['adiuvai'],
|
||||
},
|
||||
],
|
||||
},
|
||||
rebuildConfig: {},
|
||||
hooks: {
|
||||
@@ -70,20 +82,20 @@ const config: ForgeConfig = {
|
||||
|
||||
const targetKey = `${platform}-${arch}`;
|
||||
|
||||
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<platform>-<arch>-*).
|
||||
// @lancedb/lancedb uses platform-specific optional deps (@lancedb/lancedb-<platform>-<arch>-*).
|
||||
// npm install on Linux only pulls the Linux variant. Force-install the target's.
|
||||
const platformNativePackages: Record<string, Record<string, string>> = {
|
||||
'win32-x64': {
|
||||
'@lancedb/vectordb-win32-x64-msvc': '',
|
||||
'@lancedb/lancedb-win32-x64-msvc': '',
|
||||
},
|
||||
'linux-x64': {
|
||||
'@lancedb/vectordb-linux-x64-gnu': '',
|
||||
'@lancedb/lancedb-linux-x64-gnu': '',
|
||||
},
|
||||
'darwin-x64': {
|
||||
'@lancedb/vectordb-darwin-x64': '',
|
||||
'@lancedb/lancedb-darwin-x64': '',
|
||||
},
|
||||
'darwin-arm64': {
|
||||
'@lancedb/vectordb-darwin-arm64': '',
|
||||
'@lancedb/lancedb-darwin-arm64': '',
|
||||
},
|
||||
};
|
||||
const nativePkgs = platformNativePackages[targetKey];
|
||||
@@ -92,7 +104,7 @@ const config: ForgeConfig = {
|
||||
const nmPath = path.join(buildPath, 'node_modules', '@lancedb');
|
||||
if (fs.existsSync(nmPath)) {
|
||||
for (const entry of fs.readdirSync(nmPath)) {
|
||||
if (entry.startsWith('vectordb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
|
||||
if (entry.startsWith('lancedb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
|
||||
fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true });
|
||||
console.log(`[forge] Removed non-target native package: @lancedb/${entry}`);
|
||||
}
|
||||
@@ -108,8 +120,7 @@ const config: ForgeConfig = {
|
||||
}
|
||||
|
||||
// Remove cross-platform prebuilt binaries that don't match the target.
|
||||
// Packages like @github/copilot ship prebuilds for all platforms;
|
||||
// keeping foreign-arch .node files breaks rpmbuild's strip step.
|
||||
// Keeping foreign-arch .node files breaks rpmbuild's strip step.
|
||||
const nodeModulesPath = path.join(buildPath, 'node_modules');
|
||||
const findPrebuilds = (dir: string): string[] => {
|
||||
const results: string[] = [];
|
||||
@@ -137,26 +148,6 @@ const config: ForgeConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
// @github/copilot ships @teddyzhu/clipboard-* platform packages outside
|
||||
// of prebuilds/. Remove non-target variants to avoid bundling wrong binaries.
|
||||
const clipboardDir = path.join(buildPath, 'node_modules', '@github', 'copilot', 'clipboard', 'node_modules', '@teddyzhu');
|
||||
if (fs.existsSync(clipboardDir)) {
|
||||
const targetClipboardMap: Record<string, string> = {
|
||||
'win32-x64': 'clipboard-win32-x64-msvc',
|
||||
'win32-arm64': 'clipboard-win32-arm64-msvc',
|
||||
'linux-x64': 'clipboard-linux-x64-gnu',
|
||||
'linux-arm64': 'clipboard-linux-arm64-gnu',
|
||||
'darwin-x64': 'clipboard-darwin-x64',
|
||||
'darwin-arm64': 'clipboard-darwin-arm64',
|
||||
};
|
||||
const wantedPkg = targetClipboardMap[targetKey];
|
||||
for (const entry of fs.readdirSync(clipboardDir)) {
|
||||
if (entry.startsWith('clipboard-') && entry !== wantedPkg) {
|
||||
fs.rmSync(path.join(clipboardDir, entry), { recursive: true, force: true });
|
||||
console.log(`[forge] Removed non-target clipboard package: @teddyzhu/${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Post-rebuild: fix native binaries for cross-compilation ──────
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Adiuva</title>
|
||||
<title>adiuvAI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
2046
package-lock.json
generated
27
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "adiuva",
|
||||
"productName": "Adiuva",
|
||||
"name": "adiuvai",
|
||||
"productName": "adiuvAI",
|
||||
"version": "0.1.0",
|
||||
"description": "Local-first intelligent desktop workspace",
|
||||
"main": ".vite/build/main.js",
|
||||
@@ -11,7 +11,10 @@
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"knip": "knip"
|
||||
"knip": "knip",
|
||||
"dev:web": "vite --config vite.web.config.mts",
|
||||
"build:web": "vite build --config vite.web.config.mts",
|
||||
"preview:web": "vite preview --config vite.web.config.mts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "roberto",
|
||||
@@ -40,21 +43,17 @@
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"shadcn": "^4.0.8",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@github/copilot-sdk": "^0.1.25",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@langchain/anthropic": "^1.3.19",
|
||||
"@langchain/core": "^1.1.27",
|
||||
"@langchain/langgraph": "^1.1.5",
|
||||
"@langchain/openai": "^1.2.9",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
@@ -72,16 +71,24 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vectordb": "^0.21.2",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
|
||||
179
scripts/seed-fake-data.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Seed script: inserts fake clients, projects, tasks, timeline events, and notes
|
||||
into the local adiuvAI SQLite database.
|
||||
|
||||
Usage: python scripts/seed-fake-data.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
import random
|
||||
import time
|
||||
|
||||
# ── locate the database ──────────────────────────────────────────────────
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if not appdata:
|
||||
raise RuntimeError("APPDATA environment variable not found (Windows only)")
|
||||
db_path = os.path.join(appdata, "adiuvAI", "adiuvai.db")
|
||||
|
||||
if not os.path.isfile(db_path):
|
||||
raise FileNotFoundError(f"Database not found at {db_path}. Is the app installed / run at least once?")
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
def uid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def ts(days_ago=0):
|
||||
"""Timestamp in ms, optionally shifted into the past."""
|
||||
return int((time.time() - days_ago * 86400) * 1000)
|
||||
|
||||
# ── fake data definitions ────────────────────────────────────────────────
|
||||
CLIENTS = [
|
||||
{"name": "Acme Corp", "industry": "Manufacturing"},
|
||||
{"name": "Globex Inc", "industry": "Technology"},
|
||||
{"name": "Initech Solutions", "industry": "Finance"},
|
||||
{"name": "Umbrella Labs", "industry": "Healthcare"},
|
||||
{"name": "Wayne Enterprises", "industry": "Defense & Engineering"},
|
||||
]
|
||||
|
||||
PROJECTS_PER_CLIENT = [
|
||||
# (name, status)
|
||||
[("Website Redesign", "active"), ("ERP Migration", "active")],
|
||||
[("AI Chatbot MVP", "active"), ("Cloud Infrastructure", "archived")],
|
||||
[("Compliance Audit Tool", "active"),],
|
||||
[("Patient Portal v2", "active"), ("Lab Inventory System", "active"), ("R&D Dashboard", "archived")],
|
||||
[("Bat-Signal Network", "active"), ("Vehicle Fleet Tracker", "active")],
|
||||
]
|
||||
|
||||
TASK_TEMPLATES = [
|
||||
("Design homepage mockup", "Create wireframes and high-fidelity mockups for the landing page", "todo", "high"),
|
||||
("Set up CI/CD pipeline", "Configure GitHub Actions with build, test, deploy stages", "in-progress", "high"),
|
||||
("Write unit tests for auth", "Cover login, register, and token refresh flows", "todo", "medium"),
|
||||
("Database schema review", "Review ERD and optimize indexes for production workload", "done", "medium"),
|
||||
("Implement search feature", "Full-text search across projects and notes", "todo", "low"),
|
||||
("Fix timezone bug", "Date picker shows wrong day for UTC+offset users", "in-progress", "high"),
|
||||
("API rate limiting", "Add sliding-window rate limiter to public endpoints", "todo", "medium"),
|
||||
("Onboarding walkthrough", "Build step-by-step tour for new users", "todo", "low"),
|
||||
("Performance profiling", "Identify and fix top 3 slow queries", "done", "high"),
|
||||
("Accessibility audit", "Ensure WCAG 2.1 AA compliance across all pages", "todo", "medium"),
|
||||
("Mobile responsive layout", "Adapt dashboard for tablets and phones", "in-progress", "medium"),
|
||||
("Export to PDF", "Allow users to export reports and invoices to PDF", "todo", "low"),
|
||||
]
|
||||
|
||||
TIMELINE_TEMPLATES = [
|
||||
("Project Kickoff", 0, None),
|
||||
("Design Phase Complete", 14, None),
|
||||
("Alpha Release", 30, None),
|
||||
("Beta Testing", 45, 60),
|
||||
("User Acceptance Testing", 60, 75),
|
||||
("Production Launch", 90, None),
|
||||
("Post-Launch Review", 100, None),
|
||||
]
|
||||
|
||||
NOTE_TEMPLATES = [
|
||||
("Meeting Notes — Kickoff",
|
||||
"## Attendees\n- Product Owner\n- Dev Lead\n- Designer\n\n## Key Decisions\n1. Use React + TypeScript stack\n2. Two-week sprint cycles\n3. MVP scope: auth, dashboard, CRUD\n\n## Action Items\n- [ ] Set up repo\n- [ ] Create Figma workspace\n- [ ] Schedule daily standups"),
|
||||
|
||||
("Architecture Decision Record",
|
||||
"## ADR-001: Database Choice\n\n**Status:** Accepted\n\n**Context:** Need a lightweight, embedded database for local-first architecture.\n\n**Decision:** SQLite with WAL mode via better-sqlite3.\n\n**Consequences:**\n- Fast reads, good enough writes\n- No external service dependency\n- Limited concurrent write throughput (acceptable for single-user app)"),
|
||||
|
||||
("Sprint Retrospective",
|
||||
"## What went well\n- Shipped auth flow ahead of schedule\n- Good collaboration between design and dev\n\n## What could improve\n- Too many context switches mid-sprint\n- Need clearer acceptance criteria on tickets\n\n## Actions\n- Tech lead to review tickets before sprint start\n- Block Friday afternoons for deep work"),
|
||||
|
||||
("Research: API Integrations",
|
||||
"## Potential Integrations\n\n### Stripe\n- Webhooks for subscription events\n- Customer portal for self-service\n\n### SendGrid\n- Transactional emails (welcome, reset password)\n- Monthly digest newsletter\n\n### Sentry\n- Error tracking in production\n- Performance monitoring\n\n**Next step:** Create proof-of-concept for Stripe integration"),
|
||||
]
|
||||
|
||||
# ── insert data ───────────────────────────────────────────────────────────
|
||||
client_ids = []
|
||||
project_ids = []
|
||||
|
||||
print("\n── Creating clients ──")
|
||||
for i, c in enumerate(CLIENTS):
|
||||
cid = uid()
|
||||
client_ids.append(cid)
|
||||
cur.execute(
|
||||
"INSERT INTO clients (id, parent_id, name, industry, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(cid, None, c["name"], c["industry"], ts(random.randint(60, 180)))
|
||||
)
|
||||
print(f" ✓ {c['name']}")
|
||||
|
||||
print("\n── Creating projects ──")
|
||||
for i, proj_list in enumerate(PROJECTS_PER_CLIENT):
|
||||
for pname, pstatus in proj_list:
|
||||
pid = uid()
|
||||
project_ids.append((pid, client_ids[i]))
|
||||
cur.execute(
|
||||
"INSERT INTO projects (id, client_id, name, status, ai_summary, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(pid, client_ids[i], pname, pstatus, None, ts(random.randint(30, 90)))
|
||||
)
|
||||
print(f" ✓ {pname} → {CLIENTS[i]['name']}")
|
||||
|
||||
print("\n── Creating tasks ──")
|
||||
task_count = 0
|
||||
for pid, _cid in project_ids:
|
||||
# 3-5 random tasks per project
|
||||
selected = random.sample(TASK_TEMPLATES, k=random.randint(3, 5))
|
||||
for title, desc, status, priority in selected:
|
||||
tid = uid()
|
||||
due = ts(-random.randint(5, 45)) # future dates
|
||||
cur.execute(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, due_date, is_ai_suggested, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(tid, pid, title, desc, status, priority, random.choice(["Alice", "Bob", "Carol", None]),
|
||||
due, 0, ts(random.randint(1, 30)))
|
||||
)
|
||||
task_count += 1
|
||||
print(f" ✓ {task_count} tasks created across {len(project_ids)} projects")
|
||||
|
||||
print("\n── Creating timeline events ──")
|
||||
event_count = 0
|
||||
for pid, _cid in project_ids:
|
||||
base_days_ago = random.randint(10, 60)
|
||||
for title, offset, end_offset in TIMELINE_TEMPLATES:
|
||||
eid = uid()
|
||||
event_date = ts(base_days_ago - offset) # spread into future
|
||||
end_date = ts(base_days_ago - end_offset) if end_offset else None
|
||||
is_completed = 1 if offset < 20 else 0
|
||||
cur.execute(
|
||||
"INSERT INTO timeline_events (id, project_id, title, date, end_date, is_completed, is_ai_suggested, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(eid, pid, title, event_date, end_date, is_completed, 0, ts(random.randint(10, 60)))
|
||||
)
|
||||
event_count += 1
|
||||
print(f" ✓ {event_count} timeline events created")
|
||||
|
||||
print("\n── Creating notes ──")
|
||||
note_count = 0
|
||||
for pid, _cid in project_ids:
|
||||
# 1-3 notes per project
|
||||
selected = random.sample(NOTE_TEMPLATES, k=random.randint(1, 3))
|
||||
for title, content in selected:
|
||||
nid = uid()
|
||||
created = ts(random.randint(1, 30))
|
||||
cur.execute(
|
||||
"INSERT INTO notes (id, project_id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(nid, pid, title, content, created, created)
|
||||
)
|
||||
note_count += 1
|
||||
print(f" ✓ {note_count} notes created")
|
||||
|
||||
# ── commit & close ────────────────────────────────────────────────────────
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"""
|
||||
═══════════════════════════════════════
|
||||
Seed complete!
|
||||
{len(CLIENTS)} clients
|
||||
{len(project_ids)} projects
|
||||
{task_count} tasks
|
||||
{event_count} timeline events
|
||||
{note_count} notes
|
||||
═══════════════════════════════════════
|
||||
Restart adiuvAI to see the data.
|
||||
""")
|
||||
95
skills-lock.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"adapt": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "a884f9cc4adb0b3da02d0f8becb1c36245adec7dcc087cd44e6054113755ac6e"
|
||||
},
|
||||
"animate": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "ce0f9cc82930d5c3e674918d363aa095870d70951d136f0f72e252f5954bbc85"
|
||||
},
|
||||
"audit": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "85ff89a25110dd68ebb30b45c67b33b8f2d2bb123d407d957329a2931f0a6878"
|
||||
},
|
||||
"bolder": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "46e3a6a52b8bb694ca01dae4d98be4d85ab35e2ba95eee93bcb472ff6c98a70c"
|
||||
},
|
||||
"clarify": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "3eec88b6f38165fda2a091cdb46f78311347aa0af8d9fa40112124fdaae3bd43"
|
||||
},
|
||||
"colorize": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "da21ea34a9ba5aac8c87b6df23ad5b273bf60b708e5493e6bf4727fa172d2346"
|
||||
},
|
||||
"critique": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "033e4a42923fc97741626421c0873fe25b90674076d3f6a45a9dc3a307f1918f"
|
||||
},
|
||||
"delight": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "f46bb3c71cfe635a7742b94516ba53f0c5bfac65430513e99f1162d6d4e2e71d"
|
||||
},
|
||||
"distill": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "eb53dd6f18bbeb4d1b2986eaa858c9014b3c50b8ed9fcb68d841450c0b48bd12"
|
||||
},
|
||||
"extract": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "3c7ecd324b70ce07d525a2f8ecc0cda566b16612f1b413f121e82a65ccee38a2"
|
||||
},
|
||||
"frontend-design": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "70c1738e2ead9b1118bbf77ce6d72f3b9a6fef91b6ba42579066350fe7d1e745"
|
||||
},
|
||||
"harden": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "54072e299abb30b20ddca38dcbb8c585ccd3dcecc414586d6279db1fccae3578"
|
||||
},
|
||||
"normalize": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "82deb8f724b0188afee2bcc4f00a33b7446212ff831feda6d0b515e6d9ff0cea"
|
||||
},
|
||||
"onboard": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "1e90eb71e79b019c50c6e4ab01d45da4c093090e26f25ee4b2250fafe5274e8a"
|
||||
},
|
||||
"optimize": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "36de9c64e36c778a01502ca9c98a7a6d54d4fa5215c62c01a2e93dcc5912d869"
|
||||
},
|
||||
"polish": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "12a83281065df7cecc24c17fdf9a126a13f664140ed6939c8230eb3f447d1aa3"
|
||||
},
|
||||
"quieter": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "bdf6069485ed66c6da4ad6932319d56c06034198d7e8467bc7cdae8d3169759e"
|
||||
},
|
||||
"teach-impeccable": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "759bfe9a53d48b87d60352db3403b62a0663e5187b2a2bd61d43657ac48d1a11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* ChatCopilot — LangChain-compatible ChatModel adapter for the GitHub Copilot SDK.
|
||||
*
|
||||
* Wraps the CopilotClient's session API so it can be used as a drop-in
|
||||
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
|
||||
*
|
||||
* Accepts a client-getter function to avoid module duplication issues when
|
||||
* this file is code-split into a separate chunk by Vite.
|
||||
*/
|
||||
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessageChunk } from '@langchain/core/messages';
|
||||
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
|
||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
||||
|
||||
/** Minimal shape of a Copilot SDK Tool (avoids importing the full SDK type) */
|
||||
type CopilotNativeTool = {
|
||||
name: string;
|
||||
description?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parameters?: any;
|
||||
handler: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const COPILOT_TIMEOUT = 120_000;
|
||||
|
||||
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
||||
private getClient: () => CopilotClientType | null;
|
||||
/** Native Copilot SDK tools, populated by bindTools() */
|
||||
private _copilotTools: CopilotNativeTool[] = [];
|
||||
|
||||
constructor(getClient: () => CopilotClientType | null, tools: CopilotNativeTool[] = []) {
|
||||
super({});
|
||||
this.getClient = getClient;
|
||||
this._copilotTools = tools;
|
||||
}
|
||||
|
||||
_llmType(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
private requireClient(): CopilotClientType {
|
||||
const client = this.getClient();
|
||||
if (!client) {
|
||||
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LangChain StructuredTools to Copilot SDK native tools and return a
|
||||
* new ChatCopilot instance that will pass them to createSession().
|
||||
* The SDK handles the full tool-calling loop internally — no LangChain ToolMessages needed.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
override bindTools(tools: StructuredTool[]): any {
|
||||
const copilotTools: CopilotNativeTool[] = tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? undefined,
|
||||
parameters: t.schema,
|
||||
handler: async (args: unknown) => {
|
||||
console.log(`[ChatCopilot] tool handler called: ${t.name}`, JSON.stringify(args));
|
||||
const result = await t.invoke(args as Record<string, unknown>);
|
||||
const output = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
console.log(`[ChatCopilot] tool handler result: ${t.name} →`, output.slice(0, 200));
|
||||
return output;
|
||||
},
|
||||
}));
|
||||
console.log(`[ChatCopilot] bindTools() called with:`, copilotTools.map((t) => t.name));
|
||||
return new ChatCopilot(this.getClient, copilotTools);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
|
||||
const client = this.requireClient();
|
||||
|
||||
// Extract system message and user prompt from LangChain messages
|
||||
const systemContent = messages
|
||||
.filter((m) => m._getType() === 'system')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const userContent = messages
|
||||
.filter((m) => m._getType() === 'human')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const hasTools = this._copilotTools.length > 0;
|
||||
|
||||
const session = await client.createSession({
|
||||
// When tools are registered, use append mode so the SDK can inject its tool-calling
|
||||
// instructions before our content. mode:'replace' strips those SDK-managed sections,
|
||||
// causing the model to never see/call registered tools.
|
||||
systemMessage: systemContent
|
||||
? hasTools
|
||||
? { content: systemContent }
|
||||
: { mode: 'replace', content: systemContent }
|
||||
: undefined,
|
||||
// Pass native tools when available — SDK handles the agentic tool-calling loop
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
|
||||
return result?.data.content ?? '';
|
||||
} finally {
|
||||
await session.destroy().catch(() => { /* ignore cleanup errors */ });
|
||||
}
|
||||
}
|
||||
|
||||
async *_streamResponseChunks(
|
||||
messages: BaseMessage[],
|
||||
_options: this['ParsedCallOptions'],
|
||||
_runManager?: CallbackManagerForLLMRun,
|
||||
): AsyncGenerator<ChatGenerationChunk> {
|
||||
const client = this.requireClient();
|
||||
|
||||
const systemContent = messages
|
||||
.filter((m) => m._getType() === 'system')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const userContent = messages
|
||||
.filter((m) => m._getType() === 'human')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const hasTools = this._copilotTools.length > 0;
|
||||
|
||||
console.log(`[ChatCopilot] _streamResponseChunks: hasTools=${hasTools}, tools=[${this._copilotTools.map((t) => t.name).join(', ')}]`);
|
||||
console.log(`[ChatCopilot] systemMessage mode: ${hasTools ? 'append' : 'replace'}`);
|
||||
|
||||
const session = await client.createSession({
|
||||
// Same append-vs-replace logic as _call: tools require append mode so the SDK
|
||||
// can inject its tool-calling instructions before our project context.
|
||||
systemMessage: systemContent
|
||||
? hasTools
|
||||
? { content: systemContent }
|
||||
: { mode: 'replace', content: systemContent }
|
||||
: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
|
||||
streaming: true,
|
||||
});
|
||||
|
||||
console.log(`[ChatCopilot] session created: ${session.sessionId}`);
|
||||
|
||||
// Buffer chunks via event listener and yield them
|
||||
const chunks: string[] = [];
|
||||
let done = false;
|
||||
let sessionError: Error | null = null;
|
||||
let resolveNext: (() => void) | null = null;
|
||||
|
||||
const unsubDelta = session.on('assistant.message_delta', (event) => {
|
||||
const delta = event.data.deltaContent;
|
||||
if (delta) {
|
||||
chunks.push(delta);
|
||||
resolveNext?.();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubEnd = session.on('session.idle', () => {
|
||||
console.log('[ChatCopilot] session.idle received');
|
||||
done = true;
|
||||
resolveNext?.();
|
||||
});
|
||||
|
||||
const unsubError = session.on('session.error', (event) => {
|
||||
console.error('[ChatCopilot] session.error received:', event.data.message);
|
||||
sessionError = new Error(event.data.message);
|
||||
done = true;
|
||||
resolveNext?.();
|
||||
});
|
||||
|
||||
// Log all events to understand SDK behaviour with tools
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const unsubAll = session.on((event: any) => {
|
||||
if (!['assistant.message_delta'].includes(event.type)) {
|
||||
console.log(`[ChatCopilot] SDK event: ${event.type}`, JSON.stringify(event.data ?? {}).slice(0, 300));
|
||||
}
|
||||
});
|
||||
|
||||
// Fire the request (don't await — we'll drain via events).
|
||||
const sendPromise = session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
|
||||
|
||||
// If sendAndWait rejects before any session events fire (e.g. send() throws
|
||||
// internally due to a listModels/auth failure), wake up the while loop so it
|
||||
// doesn't hang waiting for session.idle that will never arrive.
|
||||
sendPromise.catch((err: unknown) => {
|
||||
if (!done) {
|
||||
sessionError = err instanceof Error ? err : new Error(String(err));
|
||||
done = true;
|
||||
resolveNext?.();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
while (!done || chunks.length > 0) {
|
||||
if (chunks.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const text = chunks.shift()!;
|
||||
const chunk = new ChatGenerationChunk({
|
||||
message: new AIMessageChunk({ content: text }),
|
||||
text,
|
||||
});
|
||||
await _runManager?.handleLLMNewToken(text);
|
||||
yield chunk;
|
||||
} else if (!done) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveNext = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate any error surfaced via session.error event or sendAndWait rejection
|
||||
if (sessionError) throw sessionError;
|
||||
} finally {
|
||||
unsubDelta();
|
||||
unsubEnd();
|
||||
unsubError();
|
||||
unsubAll();
|
||||
await session.destroy().catch(() => { /* ignore cleanup errors */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { app } from 'electron';
|
||||
import { registerProvider, type AIProvider } from './provider';
|
||||
|
||||
// Dynamic import type — @github/copilot-sdk is ESM-only
|
||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
||||
|
||||
let client: CopilotClientType | null = null;
|
||||
let isReady = false;
|
||||
|
||||
const copilotProvider: AIProvider = {
|
||||
name: 'copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
usesExternalAuth: true,
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
// Stop existing client if re-initializing
|
||||
if (client) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
await client.stop().catch(() => {});
|
||||
client = null;
|
||||
}
|
||||
|
||||
const { CopilotClient } = await import('@github/copilot-sdk');
|
||||
// No githubToken — uses stored OAuth credentials from Copilot CLI
|
||||
// (authenticate first with `copilot auth login`)
|
||||
client = new CopilotClient({
|
||||
autoStart: true,
|
||||
autoRestart: true,
|
||||
logLevel: 'warning',
|
||||
});
|
||||
await client.start();
|
||||
isReady = true;
|
||||
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[AI] Failed to start CopilotClient:', err);
|
||||
client = null;
|
||||
isReady = false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
isReady(): boolean {
|
||||
return isReady && client !== null;
|
||||
},
|
||||
};
|
||||
|
||||
/** Get the CopilotClient instance (null if not initialized). */
|
||||
export function getCopilotClient(): CopilotClientType | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
// Clean shutdown on app quit
|
||||
app.on('before-quit', () => {
|
||||
if (client) {
|
||||
client.stop().catch((err: unknown) => console.error('[AI] Error stopping CopilotClient:', err));
|
||||
}
|
||||
});
|
||||
|
||||
registerProvider(copilotProvider);
|
||||
@@ -1,73 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getToken } from './token';
|
||||
|
||||
interface CopilotConfig {
|
||||
copilot_tokens?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the GitHub Copilot OAuth token from the CLI config file.
|
||||
* Stored at ~/.copilot/config.json under copilot_tokens["{host}:{login}"].
|
||||
* Returns the first available token, or null if unavailable.
|
||||
*/
|
||||
function readCopilotToken(): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(
|
||||
path.join(os.homedir(), '.copilot', 'config.json'),
|
||||
'utf-8',
|
||||
);
|
||||
const cfg = JSON.parse(raw) as CopilotConfig;
|
||||
const vals = Object.values(cfg.copilot_tokens ?? {});
|
||||
return vals[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a single text string using the best available credentials.
|
||||
*
|
||||
* Priority:
|
||||
* 1. GitHub Copilot CLI token → OpenAI-compatible embeddings endpoint at
|
||||
* https://api.githubcopilot.com
|
||||
* 2. Stored OpenAI token → standard OpenAI embeddings API
|
||||
*
|
||||
* Throws if no credentials are available or the API call fails.
|
||||
* Callers must .catch() this and handle the error without rejecting
|
||||
* the surrounding tRPC mutation.
|
||||
*/
|
||||
export async function embedText(text: string): Promise<number[]> {
|
||||
const { OpenAIEmbeddings } = await import('@langchain/openai');
|
||||
|
||||
const copilotToken = readCopilotToken();
|
||||
|
||||
let embeddingsInstance;
|
||||
if (copilotToken) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
apiKey: copilotToken,
|
||||
model: 'text-embedding-3-small',
|
||||
configuration: { baseURL: 'https://api.githubcopilot.com' },
|
||||
});
|
||||
} else {
|
||||
const openaiToken = await getToken('openai');
|
||||
if (!openaiToken) {
|
||||
throw new Error(
|
||||
'[Embeddings] No credentials available. Authenticate with Copilot CLI or add an OpenAI token in Settings.',
|
||||
);
|
||||
}
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
apiKey: openaiToken,
|
||||
model: 'text-embedding-3-small',
|
||||
});
|
||||
}
|
||||
|
||||
// embedDocuments returns number[][] — cast explicitly to satisfy strict TS
|
||||
const results = (await embeddingsInstance.embedDocuments([text])) as number[][];
|
||||
const vector = results[0] as number[] | undefined;
|
||||
if (!vector || vector.length === 0) {
|
||||
throw new Error('[Embeddings] Empty vector returned from embedding API');
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* LLM connector factory — returns a LangChain BaseChatModel for the active provider.
|
||||
*
|
||||
* The agent orchestration (LangGraph) is provider-independent. This module is
|
||||
* the only place that knows how to create provider-specific LLM instances.
|
||||
*/
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { getActiveProviderName, getActiveProvider } from './provider';
|
||||
import { getToken } from './token';
|
||||
import { getCopilotClient } from './copilot';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-specific factory functions (lazy-loaded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createOpenAIModel(token: string): Promise<BaseChatModel> {
|
||||
const { ChatOpenAI } = await import('@langchain/openai');
|
||||
return new ChatOpenAI({
|
||||
apiKey: token,
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.3,
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function createAnthropicModel(token: string): Promise<BaseChatModel> {
|
||||
const { ChatAnthropic } = await import('@langchain/anthropic');
|
||||
return new ChatAnthropic({
|
||||
apiKey: token,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
temperature: 0.3,
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
|
||||
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
|
||||
// We wrap it in a LangChain-compatible adapter.
|
||||
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
|
||||
// module duplication when chat-copilot.ts is code-split by Vite.
|
||||
const { ChatCopilot } = await import('./chat-copilot');
|
||||
return new ChatCopilot(getCopilotClient);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODEL_FACTORIES: Record<string, (token: string) => Promise<BaseChatModel>> = {
|
||||
openai: createOpenAIModel,
|
||||
anthropic: createAnthropicModel,
|
||||
copilot: createCopilotModel,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a LangChain BaseChatModel for the currently active AI provider.
|
||||
* Returns null if no provider is configured or no token is available.
|
||||
*/
|
||||
export async function getLLM(): Promise<BaseChatModel | null> {
|
||||
const providerName = getActiveProviderName();
|
||||
const factory = MODEL_FACTORIES[providerName];
|
||||
if (!factory) {
|
||||
console.log(`[AI] No LLM factory for provider "${providerName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = getActiveProvider();
|
||||
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
|
||||
if (!provider?.usesExternalAuth && !token) {
|
||||
console.log(`[AI] No token available for provider "${providerName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await factory(token ?? '');
|
||||
} catch (err) {
|
||||
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { getStore } from '../store';
|
||||
import { getToken, setToken as storeToken } from './token';
|
||||
|
||||
export interface AIProvider {
|
||||
/** Internal key, e.g. 'copilot', 'openai', 'anthropic' */
|
||||
name: string;
|
||||
/** Human-readable label shown in Settings UI */
|
||||
displayName: string;
|
||||
/** Initialize with a token. Returns true if the provider is ready. */
|
||||
initialize(token: string): Promise<boolean>;
|
||||
/** Whether the provider is initialized and ready to handle requests. */
|
||||
isReady(): boolean;
|
||||
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
|
||||
usesExternalAuth?: boolean;
|
||||
}
|
||||
|
||||
const providers = new Map<string, AIProvider>();
|
||||
let activeProvider: AIProvider | null = null;
|
||||
|
||||
/** Register a provider implementation. Call at import time. */
|
||||
export function registerProvider(provider: AIProvider): void {
|
||||
providers.set(provider.name, provider);
|
||||
}
|
||||
|
||||
/** Get the currently active provider (may be null if none configured). */
|
||||
export function getActiveProvider(): AIProvider | null {
|
||||
return activeProvider;
|
||||
}
|
||||
|
||||
/** Get the active provider's name from electron-store. */
|
||||
export function getActiveProviderName(): string {
|
||||
return getStore().get('aiProvider');
|
||||
}
|
||||
|
||||
/** Switch to a different registered provider. */
|
||||
function setActiveProviderName(name: string): void {
|
||||
const provider = providers.get(name);
|
||||
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
||||
activeProvider = provider;
|
||||
getStore().set('aiProvider', name);
|
||||
}
|
||||
|
||||
/** Store token for the active provider and re-initialize it. */
|
||||
export async function saveTokenAndInit(token: string): Promise<void> {
|
||||
const name = getActiveProviderName();
|
||||
await storeToken(name, token);
|
||||
const provider = providers.get(name);
|
||||
if (provider) {
|
||||
await provider.initialize(token);
|
||||
activeProvider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the active provider has credentials (stored token or external auth). */
|
||||
export async function hasActiveToken(): Promise<boolean> {
|
||||
const name = getActiveProviderName();
|
||||
const provider = providers.get(name);
|
||||
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
|
||||
if (provider?.usesExternalAuth) return true;
|
||||
const token = await getToken(name);
|
||||
return token !== null && token.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the AI subsystem on app startup.
|
||||
* Reads the active provider from settings, loads its token from keychain,
|
||||
* and calls provider.initialize() if a token exists.
|
||||
*/
|
||||
export async function initAI(): Promise<void> {
|
||||
const name = getActiveProviderName();
|
||||
const provider = providers.get(name);
|
||||
if (!provider) {
|
||||
console.log(`[AI] No provider registered for "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
|
||||
if (provider.usesExternalAuth) {
|
||||
const ready = await provider.initialize('');
|
||||
activeProvider = provider;
|
||||
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken(name);
|
||||
if (token) {
|
||||
const ready = await provider.initialize(token);
|
||||
activeProvider = provider;
|
||||
console.log(`[AI] Provider "${provider.displayName}" initialized: ready=${ready}`);
|
||||
} else {
|
||||
console.log(`[AI] No token stored for provider "${provider.displayName}"`);
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@
|
||||
* @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 * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, inArray, sql, 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 { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -25,14 +26,24 @@ const TABLE_REGISTRY = {
|
||||
tasks,
|
||||
projects,
|
||||
clients,
|
||||
checkpoints,
|
||||
notes,
|
||||
taskComments,
|
||||
timelineEvents,
|
||||
// Alias: the backend sends "timelines" as the table name
|
||||
timelines: timelineEvents,
|
||||
projectFolderFiles,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
type AnyTable = (typeof TABLE_REGISTRY)[TableName];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum file content size returned by read_file_content (500 KB). */
|
||||
const MAX_READ_SIZE_BYTES = 500 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -48,8 +59,11 @@ export class ExecutorError extends Error {
|
||||
// Filter builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reserved filter keys that are not direct column matchers. */
|
||||
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'dueDateFrom', 'dueDateTo']);
|
||||
/** Keys that are handled explicitly and should not be treated as direct column matchers. */
|
||||
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'limit', 'offset']);
|
||||
|
||||
const RANGE_FROM_RE = /^(.+)From$/;
|
||||
const RANGE_TO_RE = /^(.+)To$/;
|
||||
|
||||
function buildConditions(
|
||||
table: AnyTable,
|
||||
@@ -61,11 +75,45 @@ function buildConditions(
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (RESERVED_KEYS.has(key)) continue;
|
||||
|
||||
// Generic *From / *To range filters — e.g. dueDateFrom, createdAtFrom, dateFrom, completedAtTo
|
||||
const fromMatch = RANGE_FROM_RE.exec(key);
|
||||
if (fromMatch) {
|
||||
const colName = fromMatch[1]!;
|
||||
const col = tbl[colName];
|
||||
if (col && value != null) {
|
||||
conditions.push(gte(col as Parameters<typeof gte>[0], Number(value)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const toMatch = RANGE_TO_RE.exec(key);
|
||||
if (toMatch) {
|
||||
const colName = toMatch[1]!;
|
||||
const col = tbl[colName];
|
||||
if (col && value != null) {
|
||||
conditions.push(lte(col as Parameters<typeof lte>[0], Number(value)));
|
||||
}
|
||||
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 if (typeof value === 'string' && value.includes(',')) {
|
||||
const parts = value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
|
||||
} else if (parts.length === 1) {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
const parts = value.map((v) => String(v)).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
|
||||
} else if (parts.length === 1) {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
|
||||
}
|
||||
} else {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
|
||||
}
|
||||
@@ -98,20 +146,6 @@ function buildConditions(
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -158,10 +192,24 @@ export class DrizzleExecutor {
|
||||
return this.handleUpdate(payload);
|
||||
case 'delete':
|
||||
return this.handleDelete(payload);
|
||||
case 'vector_upsert':
|
||||
return this.handleVectorUpsert(payload);
|
||||
case 'vector_search':
|
||||
return this.handleVectorSearch(payload);
|
||||
case 'count':
|
||||
return this.handleCount(payload);
|
||||
case 'propose_note_edit':
|
||||
return this.handleProposeNoteEdit(payload);
|
||||
case 'list_directory':
|
||||
return this.handleListDirectory(payload);
|
||||
case 'read_file_content':
|
||||
return this.handleReadFileContent(payload);
|
||||
case 'get_file_metadata':
|
||||
return this.handleGetFileMetadata(payload);
|
||||
case 'read_project_folder_manifest':
|
||||
return this.handleReadProjectFolderManifest(payload);
|
||||
case 'read_project_folder_file':
|
||||
return this.handleReadProjectFolderFile(payload);
|
||||
case 'list_projects_with_folder_manifests':
|
||||
return this.handleListProjectsWithFolderManifests();
|
||||
case 'get_page_details':
|
||||
return this.handleGetPageDetails(payload);
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
@@ -181,14 +229,30 @@ export class DrizzleExecutor {
|
||||
const withWhere = conditions.length > 0
|
||||
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
|
||||
: query;
|
||||
const withOrder = orderBy ? withWhere.orderBy(orderBy) : withWhere;
|
||||
|
||||
const rows = orderBy
|
||||
? withWhere.orderBy(orderBy).all()
|
||||
: withWhere.all();
|
||||
// Default limit of 50 prevents context flooding for AI tool calls
|
||||
const limit = filters['limit'] != null ? Number(filters['limit']) : 50;
|
||||
const offset = filters['offset'] != null ? Number(filters['offset']) : 0;
|
||||
const rows = withOrder.limit(limit).offset(offset).all();
|
||||
|
||||
return { rows };
|
||||
}
|
||||
|
||||
private handleCount(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 query = getDb().select({ count: sql<number>`count(*)` }).from(table);
|
||||
const withWhere = conditions.length > 0
|
||||
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
|
||||
: query;
|
||||
|
||||
const result = withWhere.get();
|
||||
return { count: Number((result as { count: number } | undefined)?.count ?? 0) };
|
||||
}
|
||||
|
||||
private handleGet(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
@@ -198,7 +262,6 @@ export class DrizzleExecutor {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -207,12 +270,19 @@ export class DrizzleExecutor {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const now = Date.now();
|
||||
|
||||
// Auto-set completedAt for tables that have the column
|
||||
const completedAtPatch =
|
||||
('completedAt' in table && !('completedAt' in data) &&
|
||||
(data['status'] === 'done' || data['isCompleted'] === 1))
|
||||
? { completedAt: now }
|
||||
: {};
|
||||
|
||||
const values = {
|
||||
id: crypto.randomUUID(),
|
||||
...data,
|
||||
createdAt: now,
|
||||
// Set updatedAt for tables that have it (notes)
|
||||
...(('updatedAt' in table) ? { updatedAt: now } : {}),
|
||||
...completedAtPatch,
|
||||
};
|
||||
|
||||
const row = getDb().insert(table).values(values).returning().get() ?? null;
|
||||
@@ -228,11 +298,22 @@ export class DrizzleExecutor {
|
||||
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)
|
||||
const baseTimestamp = ('updatedAt' in table)
|
||||
? { ...updates, updatedAt: now }
|
||||
: updates;
|
||||
|
||||
// Auto-set completedAt when status/isCompleted changes, unless caller provided it explicitly
|
||||
const completedAtPatch: Record<string, unknown> = {};
|
||||
if ('completedAt' in table && !('completedAt' in updates)) {
|
||||
if (updates['status'] === 'done' || updates['isCompleted'] === 1) {
|
||||
completedAtPatch['completedAt'] = now;
|
||||
} else if (updates['status'] !== undefined || updates['isCompleted'] !== undefined) {
|
||||
completedAtPatch['completedAt'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const withTimestamp = { ...baseTimestamp, ...completedAtPatch };
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
const row = getDb()
|
||||
@@ -258,43 +339,329 @@ export class DrizzleExecutor {
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
private async handleVectorUpsert(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
private handleProposeNoteEdit(payload: WsToolCall): 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;
|
||||
const noteId = data['noteId'] as string | undefined;
|
||||
const type = data['type'] as string | undefined;
|
||||
const proposedContent = data['proposedContent'] as string | undefined;
|
||||
|
||||
if (!noteId || !content || !vector) {
|
||||
throw new ExecutorError('vector_upsert requires data.noteId, data.content, and vector');
|
||||
}
|
||||
if (!noteId) throw new ExecutorError('"data.noteId" is required for propose_note_edit');
|
||||
if (!type) throw new ExecutorError('"data.type" is required for propose_note_edit');
|
||||
if (!proposedContent) throw new ExecutorError('"data.proposedContent" is required for propose_note_edit');
|
||||
|
||||
await upsertWithVector(noteId, projectId, content, vector);
|
||||
return { ok: true };
|
||||
const values = {
|
||||
id: crypto.randomUUID(),
|
||||
noteId,
|
||||
type,
|
||||
proposedContent,
|
||||
anchorBefore: (data['anchorBefore'] as string | null) ?? null,
|
||||
anchorText: (data['anchorText'] as string | null) ?? null,
|
||||
reasoning: (data['reasoning'] as string | null) ?? null,
|
||||
agentId: (data['agentId'] as string | null) ?? null,
|
||||
runId: (data['runId'] as string | null) ?? null,
|
||||
status: 'pending' as const,
|
||||
createdAt: Date.now(),
|
||||
resolvedAt: null,
|
||||
};
|
||||
|
||||
const row = getDb().insert(noteEdits).values(values).returning().get() ?? null;
|
||||
return { id: values.id, row };
|
||||
}
|
||||
|
||||
private async handleVectorSearch(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const vec = payload.vector;
|
||||
const limit = payload.limit ?? 5;
|
||||
// -------------------------------------------------------------------------
|
||||
// Filesystem handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (!vec) {
|
||||
throw new ExecutorError('vector_search requires a vector');
|
||||
private async handleListDirectory(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const dirPath = data['path'] as string | undefined;
|
||||
if (!dirPath) throw new ExecutorError('"data.path" is required for list_directory');
|
||||
|
||||
const resolved = await fs.promises.realpath(path.resolve(dirPath));
|
||||
|
||||
let dirents: fs.Dirent[];
|
||||
try {
|
||||
dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw new ExecutorError(
|
||||
`Cannot read directory ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
const entries = dirents.map((d) => ({
|
||||
name: d.name,
|
||||
type: d.isDirectory() ? 'directory' : 'file',
|
||||
path: path.join(resolved, d.name),
|
||||
}));
|
||||
|
||||
return { results };
|
||||
return { entries };
|
||||
}
|
||||
|
||||
private async handleReadFileContent(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const filePath = data['path'] as string | undefined;
|
||||
if (!filePath) throw new ExecutorError('"data.path" is required for read_file_content');
|
||||
|
||||
const resolved = await fs.promises.realpath(path.resolve(filePath));
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = await fs.promises.stat(resolved);
|
||||
} catch (err) {
|
||||
throw new ExecutorError(
|
||||
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new ExecutorError(`Not a file: ${filePath}`);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
if (stat.size > MAX_READ_SIZE_BYTES) {
|
||||
// Read only the first MAX_READ_SIZE_BYTES to prevent context saturation
|
||||
const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
|
||||
const fd = await fs.promises.open(resolved, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
content = buf.toString('utf8') + '\n[…truncated]';
|
||||
} else {
|
||||
content = await fs.promises.readFile(resolved, 'utf8');
|
||||
}
|
||||
|
||||
return { content };
|
||||
}
|
||||
|
||||
private async handleGetFileMetadata(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const filePath = data['path'] as string | undefined;
|
||||
if (!filePath) throw new ExecutorError('"data.path" is required for get_file_metadata');
|
||||
|
||||
const resolved = await fs.promises.realpath(path.resolve(filePath));
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = await fs.promises.stat(resolved);
|
||||
} catch (err) {
|
||||
throw new ExecutorError(
|
||||
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: path.basename(resolved),
|
||||
extension: path.extname(resolved).toLowerCase(),
|
||||
size: stat.size,
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Project folder handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
|
||||
const { projectId } = (payload.data ?? {}) as { projectId: string };
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
|
||||
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
|
||||
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
|
||||
// for the next call.
|
||||
if (proj.folderLastScanStatus !== 'scanning') {
|
||||
void import('../files/scanner')
|
||||
.then(async ({ scanFolder }) => {
|
||||
const delta = await scanFolder(projectId, proj.folderPath!);
|
||||
if (
|
||||
delta.newFiles.length > 0 ||
|
||||
delta.changedFiles.length > 0 ||
|
||||
delta.deletedRelPaths.length > 0
|
||||
) {
|
||||
const { startIndexSession } = await import('../files/indexer');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(projectId, () => {});
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
folderPath: proj.folderPath,
|
||||
lastScannedAt: proj.folderLastScannedAt,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
|
||||
projectId: string;
|
||||
relativePath: string;
|
||||
offset?: number;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };
|
||||
|
||||
const abs = path.join(proj.folderPath, relativePath);
|
||||
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
const ext = path.extname(relativePath).toLowerCase();
|
||||
|
||||
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
|
||||
}
|
||||
|
||||
// PDF + DOCX: return full base64; backend extracts text + slices.
|
||||
if (ext === '.pdf' || ext === '.docx') {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return {
|
||||
content: buf.toString('base64'),
|
||||
kind: ext === '.pdf' ? 'pdf' : 'docx',
|
||||
totalSize: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Text: slice at offset/length on Electron side to keep WS payload small.
|
||||
const start = Math.max(0, offset ?? 0);
|
||||
const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
|
||||
const end = Math.min(start + want, stat.size);
|
||||
const len = Math.max(0, end - start);
|
||||
if (len === 0) {
|
||||
return { content: '', kind: 'text', totalSize: stat.size };
|
||||
}
|
||||
const buf = Buffer.alloc(len);
|
||||
const fd = await fs.promises.open(abs, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, len, start);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
|
||||
} catch {
|
||||
return { content: '', kind: 'error', totalSize: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contextual agent: composite read op
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleGetPageDetails(payload: WsToolCall): Record<string, unknown> {
|
||||
const db = getDb();
|
||||
// entity_type is sent as the `table` field by the backend execute_on_client call.
|
||||
const entityType = payload.table ?? '';
|
||||
const entityId = (payload.data?.['entityId'] as string | null | undefined) ?? undefined;
|
||||
|
||||
switch (entityType) {
|
||||
case 'project': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for project');
|
||||
const project = db.select().from(projects).where(eq(projects.id, entityId)).get() ?? null;
|
||||
if (!project) return { project: null, tasks: [], notes: [], milestones: [], events: [] };
|
||||
const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, entityId)).all();
|
||||
const projectNotes = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
title: notes.title,
|
||||
aiSummary: notes.aiSummary,
|
||||
updatedAt: notes.updatedAt,
|
||||
})
|
||||
.from(notes)
|
||||
.where(eq(notes.projectId, entityId))
|
||||
.all();
|
||||
const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, entityId)).all();
|
||||
return {
|
||||
project,
|
||||
tasks: projectTasks,
|
||||
notes: projectNotes,
|
||||
milestones: events.filter((e) => e.type === 'milestone'),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
case 'task': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for task');
|
||||
const task = db.select().from(tasks).where(eq(tasks.id, entityId)).get() ?? null;
|
||||
const project = task?.projectId
|
||||
? (db.select().from(projects).where(eq(projects.id, task.projectId)).get() ?? null)
|
||||
: null;
|
||||
const comments = db.select().from(taskComments).where(eq(taskComments.taskId, entityId)).all();
|
||||
return { task, project, comments };
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for note');
|
||||
const note = db.select().from(notes).where(eq(notes.id, entityId)).get() ?? null;
|
||||
return { note };
|
||||
}
|
||||
|
||||
case 'tasks_all':
|
||||
return { tasks: db.select().from(tasks).all() };
|
||||
|
||||
case 'projects_all':
|
||||
return { projects: db.select().from(projects).all() };
|
||||
|
||||
case 'timeline_all':
|
||||
return { events: db.select().from(timelineEvents).all() };
|
||||
|
||||
default:
|
||||
throw new ExecutorError(`get_page_details: unknown entityType "${entityType}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
|
||||
const projs = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`${projects.folderPath} IS NOT NULL`)
|
||||
.all();
|
||||
|
||||
const out: Array<unknown> = [];
|
||||
for (const p of projs) {
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, p.id))
|
||||
.all();
|
||||
out.push({
|
||||
projectId: p.id,
|
||||
projectName: p.name,
|
||||
folderPath: p.folderPath,
|
||||
lastScannedAt: p.folderLastScannedAt,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
return { projects: out };
|
||||
}
|
||||
}
|
||||
|
||||
49
src/main/attachments/storage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { app } from 'electron';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const FILENAME_MAX = 200;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
const stripped = name
|
||||
.replace(/[\\/]/g, '_')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f]/g, '')
|
||||
.replace(/^\.+/, '');
|
||||
return stripped.length > FILENAME_MAX ? stripped.slice(0, FILENAME_MAX) : stripped;
|
||||
}
|
||||
|
||||
export function attachmentsRoot(): string {
|
||||
return path.join(app.getPath('userData'), 'attachments');
|
||||
}
|
||||
|
||||
export function absolutePath(storedPath: string): string {
|
||||
return path.join(attachmentsRoot(), storedPath);
|
||||
}
|
||||
|
||||
export async function copyIntoTask(
|
||||
taskId: string,
|
||||
sourcePath: string,
|
||||
filename: string,
|
||||
): Promise<{ storedPath: string }> {
|
||||
const safeName = sanitizeFilename(filename);
|
||||
const dir = path.join(attachmentsRoot(), taskId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const finalName = `${randomUUID()}-${safeName}`;
|
||||
const dest = path.join(dir, finalName);
|
||||
await fs.copyFile(sourcePath, dest);
|
||||
return { storedPath: path.join(taskId, finalName) };
|
||||
}
|
||||
|
||||
export async function deleteStored(storedPath: string): Promise<void> {
|
||||
const abs = absolutePath(storedPath);
|
||||
await fs.unlink(abs).catch((err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTaskDir(taskId: string): Promise<void> {
|
||||
const dir = path.join(attachmentsRoot(), taskId);
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Auth manager — handles registration, login, token refresh, and profile
|
||||
* retrieval against the Adiuva backend API.
|
||||
* retrieval against the AdiuvAI backend API.
|
||||
*
|
||||
* Singleton. Tokens are persisted via the two-tier storage in `token.ts`
|
||||
* (safeStorage + electron-store fallback).
|
||||
@@ -22,7 +22,7 @@ import type { AuthTokens, UserProfile } from '../../shared/api-types';
|
||||
const TOKEN_KEYS = {
|
||||
access: 'auth_access',
|
||||
refresh: 'auth_refresh',
|
||||
/** Stored as string representation of Unix-epoch seconds. */
|
||||
/** Stored as string representation of Unix-epoch milliseconds. */
|
||||
expiresAt: 'auth_expires_at',
|
||||
} as const;
|
||||
|
||||
@@ -32,6 +32,28 @@ const REFRESH_WINDOW_SEC = 5 * 60; // 5 minutes
|
||||
/** Maximum request timeout (ms). */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RelationOut {
|
||||
id: string;
|
||||
subjectLabel: string;
|
||||
subjectType: string;
|
||||
predicate: string;
|
||||
objectLabel: string;
|
||||
objectType: string;
|
||||
confidence: number;
|
||||
lastConfirmedAt: number | null;
|
||||
}
|
||||
|
||||
export interface RelationPatch {
|
||||
subjectLabel?: string;
|
||||
objectLabel?: string;
|
||||
predicate?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,10 +72,27 @@ export class AuthError extends Error {
|
||||
// AuthManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tracks a pending OAuth login promise until the deep-link callback arrives. */
|
||||
interface PendingOAuth {
|
||||
provider: string;
|
||||
resolve: (tokens: AuthTokens) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/** How long (ms) to wait for the user to complete the browser OAuth flow. */
|
||||
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class AuthManager {
|
||||
private static instance: AuthManager | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Pending OAuth login promises keyed by state param.
|
||||
* One entry per in-flight OAuth flow (practically always ≤ 1).
|
||||
*/
|
||||
private _pendingOAuth = new Map<string, PendingOAuth>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -69,8 +108,11 @@ export class AuthManager {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 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 });
|
||||
async register(email: string, password: string, name?: string, surname?: string): Promise<AuthTokens> {
|
||||
const body: Record<string, unknown> = { email, password };
|
||||
if (name) body.name = name;
|
||||
if (surname) body.surname = surname;
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/register', body);
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
return tokens;
|
||||
@@ -104,9 +146,11 @@ export class AuthManager {
|
||||
// Check expiry — refresh if within the window
|
||||
const expiresAtStr = await getToken(TOKEN_KEYS.expiresAt);
|
||||
if (expiresAtStr) {
|
||||
const expiresAt = Number(expiresAtStr);
|
||||
// Backend returns expires_at in milliseconds; convert to seconds.
|
||||
const expiresAtSec = Math.floor(Number(expiresAtStr) / 1000);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (expiresAt - nowSec < REFRESH_WINDOW_SEC) {
|
||||
if (expiresAtSec - nowSec < REFRESH_WINDOW_SEC) {
|
||||
const isExpired = nowSec >= expiresAtSec;
|
||||
// Coalesce concurrent refresh calls
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = this.refreshTokens().finally(() => {
|
||||
@@ -117,7 +161,14 @@ export class AuthManager {
|
||||
await this.refreshPromise;
|
||||
return (await getToken(TOKEN_KEYS.access)) ?? token;
|
||||
} catch {
|
||||
// Refresh failed — return existing token (may still work)
|
||||
// Refresh failed — if the token is already expired, don't
|
||||
// return a stale token that will certainly be rejected.
|
||||
if (isExpired) {
|
||||
console.warn('[Auth] Token expired and refresh failed — logging out.');
|
||||
await this.logout();
|
||||
return null;
|
||||
}
|
||||
// Token not yet expired — return it; it may still work.
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -138,6 +189,237 @@ export class AuthManager {
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Update the user's profile (name, surname) on the backend. */
|
||||
async updateProfile(fields: { name?: string; surname?: string }): Promise<UserProfile> {
|
||||
const data = await this.put<UserProfile>('/api/v1/auth/me', fields);
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Update core memory key/value pairs and optionally mark onboarding complete. */
|
||||
async updateMemory(
|
||||
memory: Record<string, string>,
|
||||
markOnboarded = false,
|
||||
): Promise<UserProfile> {
|
||||
const data = await this.put<UserProfile>('/api/v1/auth/me/memory', {
|
||||
memory,
|
||||
markOnboarded,
|
||||
});
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** One-shot LLM normalization for free-text onboarding answers. */
|
||||
async normalizeOnboarding(
|
||||
inputs: Record<string, string>,
|
||||
): Promise<Record<string, string>> {
|
||||
const res = await this.post<{ normalized: Record<string, string> }>('/api/v1/auth/onboarding/normalize', { inputs });
|
||||
return res.normalized;
|
||||
}
|
||||
|
||||
/** Reset onboarding so the wizard runs again. */
|
||||
async resetOnboarding(): Promise<void> {
|
||||
await this.post('/api/v1/auth/me/onboarding/reset', {});
|
||||
}
|
||||
|
||||
/** Change password (email/password users only). */
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await this.put('/api/v1/auth/me/password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/** List linked OAuth providers for the current user. */
|
||||
async listOAuthAccounts(): Promise<{ provider: string; providerEmail: string | null; createdAt: number }[]> {
|
||||
return this.get('/api/v1/auth/me/oauth-accounts');
|
||||
}
|
||||
|
||||
/** Unlink an OAuth provider from the current user. */
|
||||
async unlinkOAuthAccount(provider: string): Promise<void> {
|
||||
await this.httpDelete(`/api/v1/auth/me/oauth-accounts/${encodeURIComponent(provider)}`);
|
||||
}
|
||||
|
||||
/** Update the user's avatar URL. */
|
||||
async updateAvatar(avatarUrl: string): Promise<UserProfile> {
|
||||
const data = await this.put<UserProfile>('/api/v1/auth/me/avatar', { avatarUrl });
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Permanently delete the user's account. */
|
||||
async deleteAccount(): Promise<void> {
|
||||
await this.httpDelete('/api/v1/auth/me');
|
||||
}
|
||||
|
||||
// ── Billing ────────────────────────────────────────────────────────
|
||||
|
||||
/** Get current subscription info. */
|
||||
async getSubscription(): Promise<Record<string, unknown>> {
|
||||
return this.get('/api/v1/billing/subscription');
|
||||
}
|
||||
|
||||
/** Create a Stripe checkout session for a tier upgrade. */
|
||||
async createCheckout(tier: string): Promise<{ checkoutUrl: string }> {
|
||||
return this.post('/api/v1/billing/checkout', { tier });
|
||||
}
|
||||
|
||||
/** Cancel the active subscription. */
|
||||
async cancelSubscription(): Promise<void> {
|
||||
await this.httpDelete('/api/v1/billing/subscription');
|
||||
}
|
||||
|
||||
/** List billing invoices from Stripe. */
|
||||
async listInvoices(): Promise<Record<string, unknown>[]> {
|
||||
return this.get('/api/v1/billing/invoices');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Google (or other provider) OAuth login flow.
|
||||
*
|
||||
* 1. Calls GET /api/v1/auth/oauth/{provider}/authorize to obtain the
|
||||
* consent-screen URL and a PKCE state token from the backend.
|
||||
* 2. Opens the URL in the system browser via shell.openExternal().
|
||||
* 3. Returns a Promise that resolves with AuthTokens when the Electron app
|
||||
* receives the deep-link callback (adiuvai://oauth/callback?...) and
|
||||
* handleOAuthCallback() is called.
|
||||
*
|
||||
* Rejects after OAUTH_TIMEOUT_MS (5 min) if no callback arrives.
|
||||
*/
|
||||
async loginWithOAuth(provider: string): Promise<AuthTokens> {
|
||||
// Fetch the authorization URL from the backend (public endpoint — no auth header needed).
|
||||
const url = `${this.baseUrl}/api/v1/auth/oauth/${encodeURIComponent(provider)}/authorize`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(`Failed to get OAuth authorize URL: ${res.status}${text ? ` ${text}` : ''}`);
|
||||
}
|
||||
const json = (await res.json()) as { url: string; state: string };
|
||||
|
||||
// Open the consent screen in the system browser.
|
||||
const { shell } = await import('electron');
|
||||
await shell.openExternal(json.url);
|
||||
|
||||
// Wait for the deep-link callback to arrive.
|
||||
return new Promise<AuthTokens>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this._pendingOAuth.delete(json.state);
|
||||
reject(new AuthError('OAuth login timed out — no callback received within 5 minutes'));
|
||||
}, OAUTH_TIMEOUT_MS);
|
||||
|
||||
this._pendingOAuth.set(json.state, { provider, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the main process when the OS delivers an adiuvai:// deep link.
|
||||
*
|
||||
* Parses code + state from the URL, exchanges the code with the backend,
|
||||
* stores the resulting JWT tokens, and resolves the pending loginWithOAuth()
|
||||
* promise.
|
||||
*
|
||||
* If no pending flow matches the state (e.g. duplicate or stale callback),
|
||||
* the call is silently ignored.
|
||||
*/
|
||||
async handleOAuthCallback(deepLinkUrl: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(deepLinkUrl);
|
||||
} catch {
|
||||
console.warn('[Auth] Received malformed deep-link URL:', deepLinkUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = parsed.searchParams.get('code');
|
||||
const state = parsed.searchParams.get('state');
|
||||
const provider = parsed.searchParams.get('provider');
|
||||
|
||||
if (!code || !state || !provider) {
|
||||
console.warn('[Auth] Deep-link missing required params:', deepLinkUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this._pendingOAuth.get(state);
|
||||
if (!pending) {
|
||||
console.warn('[Auth] No pending OAuth flow for state:', state);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
this._pendingOAuth.delete(state);
|
||||
|
||||
try {
|
||||
const data = await this.post<AuthTokens>(
|
||||
`/api/v1/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
||||
{ code, state },
|
||||
);
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
pending.resolve(tokens);
|
||||
} catch (err) {
|
||||
pending.reject(err instanceof Error ? err : new AuthError('OAuth callback exchange failed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return all core memory k/v pairs (plaintext). */
|
||||
async getCoreMemory(): Promise<Record<string, string>> {
|
||||
return this.get<Record<string, string>>('/api/v1/memory/core');
|
||||
}
|
||||
|
||||
/** Add or overwrite a core memory key/value pair. */
|
||||
async addCoreKey(key: string, value: string): Promise<Record<string, string>> {
|
||||
return this.post<Record<string, string>>('/api/v1/memory/core', { key, value });
|
||||
}
|
||||
|
||||
/** Delete a core memory key (GDPR). */
|
||||
async deleteCoreKey(key: string): Promise<void> {
|
||||
await this.httpDelete(`/api/v1/memory/core/${encodeURIComponent(key)}`);
|
||||
}
|
||||
|
||||
/** Return relational memory rows. */
|
||||
async getRelationalMemory(): Promise<RelationOut[]> {
|
||||
return this.get<RelationOut[]>('/api/v1/memory/relational');
|
||||
}
|
||||
|
||||
/** Edit a relation row's labels, predicate, or confidence. */
|
||||
async patchRelation(id: string, patch: RelationPatch): Promise<RelationOut> {
|
||||
const url = `${this.baseUrl}/api/v1/memory/relational/${encodeURIComponent(id)}`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (!accessToken) throw new AuthError('Not authenticated');
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(patch),
|
||||
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<RelationOut>(json);
|
||||
}
|
||||
|
||||
/** Hard-delete a relation row (GDPR). */
|
||||
async deleteRelation(id: string): Promise<void> {
|
||||
await this.httpDelete(`/api/v1/memory/relational/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Wipe all memory tiers for the current user (GDPR Art. 17). */
|
||||
async forgetAll(): Promise<void> {
|
||||
const url = `${this.baseUrl}/api/v1/memory/forget-all`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (!accessToken) throw new AuthError('Not authenticated');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'X-Confirm': 'true' },
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicitly refresh the token pair. */
|
||||
async refreshTokens(): Promise<void> {
|
||||
const refreshToken = await getToken(TOKEN_KEYS.refresh);
|
||||
@@ -145,10 +427,27 @@ export class AuthManager {
|
||||
throw new AuthError('No refresh token available');
|
||||
}
|
||||
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/refresh', {
|
||||
refreshToken,
|
||||
// Use a direct fetch instead of this.post() to avoid sending the
|
||||
// (possibly expired) access token in the Authorization header.
|
||||
// The refresh endpoint only needs the refresh token in the body.
|
||||
const url = `${this.baseUrl}/api/v1/auth/refresh`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(toSnakeCase({ refreshToken })),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
|
||||
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();
|
||||
const tokens = AuthTokensSchema.parse(toCamelCase<AuthTokens>(json));
|
||||
await this.storeTokens(tokens);
|
||||
}
|
||||
|
||||
@@ -202,6 +501,39 @@ export class AuthManager {
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic PUT request to the backend (authenticated).
|
||||
*/
|
||||
private async put<T>(path: string, body: Record<string, unknown>): 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: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
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).
|
||||
*/
|
||||
@@ -232,6 +564,35 @@ export class AuthManager {
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
|
||||
private async httpDelete<T = void>(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: 'DELETE',
|
||||
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 text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return toCamelCase<T>(JSON.parse(text));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
45
src/main/auth/backup-key.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Device-specific backup encryption key.
|
||||
*
|
||||
* Generated randomly (256-bit) on first call and persisted via the same
|
||||
* safeStorage + electron-store mechanism used for auth tokens (see token.ts).
|
||||
* This key is device-bound — it never leaves the machine and is not derived
|
||||
* from the user's password, so social-login users can use backups without issue.
|
||||
*
|
||||
* Usage:
|
||||
* const key = await getBackupKey(); // Buffer of 32 bytes
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getToken, setToken } from '../ai/token';
|
||||
|
||||
const BACKUP_KEY_STORE_NAME = 'backup_key';
|
||||
|
||||
/**
|
||||
* Return the device-specific backup encryption key (32 bytes).
|
||||
*
|
||||
* Generates a fresh key on first call and stores it via safeStorage so it
|
||||
* survives app restarts. Subsequent calls return the same key.
|
||||
*/
|
||||
export async function getBackupKey(): Promise<Buffer> {
|
||||
const stored = await getToken(BACKUP_KEY_STORE_NAME);
|
||||
|
||||
if (stored) {
|
||||
return Buffer.from(stored, 'base64');
|
||||
}
|
||||
|
||||
// First launch: generate a random 256-bit key and persist it.
|
||||
const key = randomBytes(32);
|
||||
await setToken(BACKUP_KEY_STORE_NAME, key.toString('base64'));
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored backup key (e.g. on device-wipe / factory reset).
|
||||
* After this call, the next `getBackupKey()` will generate a new key —
|
||||
* any backups encrypted with the old key will be unrecoverable.
|
||||
*/
|
||||
export async function deleteBackupKey(): Promise<void> {
|
||||
const { deleteToken } = await import('../ai/token');
|
||||
await deleteToken(BACKUP_KEY_STORE_NAME);
|
||||
}
|
||||
32
src/main/auth/locale-defaults.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { app } from 'electron';
|
||||
import type { FormatPrefs } from '../store';
|
||||
|
||||
export function detectFormatPrefs(): FormatPrefs {
|
||||
const locale = app.getLocale();
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
|
||||
const timeFormat = hour12 ? '12h' : '24h';
|
||||
const dateFormat = inferDateFormat(locale);
|
||||
return { timezone, timeFormat, dateFormat };
|
||||
}
|
||||
|
||||
export function detectLanguage(): string {
|
||||
const locale = app.getLocale(); // e.g. 'it-IT', 'en-US'
|
||||
try {
|
||||
const display = new Intl.DisplayNames([locale], { type: 'language' });
|
||||
return display.of(locale) ?? locale;
|
||||
} catch {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
|
||||
function inferDateFormat(locale: string): string {
|
||||
// MDY locales
|
||||
const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
|
||||
if (mdyLocales.some((l) => locale.startsWith(l))) return 'MM/dd/yyyy';
|
||||
// YMD locales (CJK, ISO-oriented)
|
||||
const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
|
||||
if (ymdPrefixes.some((p) => locale.startsWith(p))) return 'yyyy-MM-dd';
|
||||
// Default: DMY (most of the world)
|
||||
return 'dd/MM/yyyy';
|
||||
}
|
||||
@@ -1,91 +1,109 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { app } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as schema from './schema';
|
||||
|
||||
// SQL to create all tables if they don't exist (non-destructive push strategy)
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
industry TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
/** Resolved path to the SQLite database file. Set once in initDb(). */
|
||||
let _dbPath: string | null = null;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
ai_summary TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
assignee TEXT,
|
||||
due_date INTEGER,
|
||||
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||
is_approved INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||
is_approved INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
/** Raw better-sqlite3 instance (needed for .backup() API). */
|
||||
let _rawSqlite: Database.Database | null = null;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
let dbInstance: DbInstance | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the migrations folder location.
|
||||
*
|
||||
* - Packaged: shipped via electron-forge `extraResource` → `<resourcesPath>/migrations`.
|
||||
* - Dev: lives in the source tree at `<appPath>/src/main/db/migrations`. We do NOT
|
||||
* resolve from `__dirname` because Vite bundles `src/main/**` into a single
|
||||
* `.vite/build/main.js` and the migrations folder is not copied next to it.
|
||||
*/
|
||||
function resolveMigrationsFolder(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'migrations');
|
||||
}
|
||||
return path.join(app.getAppPath(), 'src', 'main', 'db', 'migrations');
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time bootstrap for DBs created by the legacy hand-rolled MIGRATION_SQL.
|
||||
*
|
||||
* Pre-Drizzle-migrator era, schema was managed by ad-hoc CREATE TABLE IF NOT EXISTS
|
||||
* + try/catch ALTER TABLE. Those DBs have all the tables from migrations 0000-0003
|
||||
* but no `__drizzle_migrations` ledger. If we just call migrate(), it will try to
|
||||
* re-run 0000 and crash on duplicate table.
|
||||
*
|
||||
* Strategy: if the DB looks pre-existing (has a `tasks` table) but no migrations
|
||||
* ledger, create the ledger and mark all migrations EXCEPT the latest as applied.
|
||||
* The migrator will then only run the latest one (0004 — adds `estimate` column +
|
||||
* `task_attachments` table — both genuinely missing from legacy DBs).
|
||||
*/
|
||||
function bootstrapMigrationsLedger(sqlite: Database.Database, migrationsFolder: string): void {
|
||||
const hasLedger = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
if (hasLedger) return;
|
||||
|
||||
const hasTasks = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
||||
.get();
|
||||
if (!hasTasks) return; // fresh DB — let the migrator create everything from scratch
|
||||
|
||||
// Legacy DB detected. Build the ledger Drizzle expects.
|
||||
// Schema must match drizzle-orm/sqlite-core/dialect.js migrate():
|
||||
// id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric
|
||||
sqlite.exec(`
|
||||
CREATE TABLE __drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
);
|
||||
`);
|
||||
|
||||
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as {
|
||||
entries: { idx: number; tag: string; when: number }[];
|
||||
};
|
||||
|
||||
// Mark everything except the latest entry as applied.
|
||||
// Drizzle's migrator filters by `lastDbMigration.created_at < migration.folderMillis`,
|
||||
// so seeding the second-to-last entry's `when` is sufficient.
|
||||
const toMark = journal.entries.slice(0, -1);
|
||||
if (toMark.length === 0) return;
|
||||
|
||||
const insert = sqlite.prepare(
|
||||
'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
|
||||
);
|
||||
for (const entry of toMark) {
|
||||
// Hash value is opaque to the migrator — only created_at matters for the cutoff.
|
||||
// Use the tag for traceability.
|
||||
insert.run(entry.tag, entry.when);
|
||||
}
|
||||
}
|
||||
|
||||
export function initDb(): DbInstance {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbPath = path.join(userDataPath, 'adiuva.db');
|
||||
_dbPath = path.join(userDataPath, 'adiuvai.db');
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
const sqlite = new Database(_dbPath);
|
||||
_rawSqlite = sqlite;
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
|
||||
// Run non-destructive migrations on every start
|
||||
sqlite.exec(MIGRATION_SQL);
|
||||
|
||||
// Additive column migrations (SQLite has no ADD COLUMN IF NOT EXISTS)
|
||||
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_ai_suggested INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1'); } catch { /* already exists */ }
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
bootstrapMigrationsLedger(sqlite, migrationsFolder);
|
||||
|
||||
dbInstance = drizzle(sqlite, { schema });
|
||||
migrate(dbInstance, { migrationsFolder });
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
@@ -95,3 +113,31 @@ export function getDb(): DbInstance {
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/** Returns the absolute path to the active SQLite database file. */
|
||||
export function getDbPath(): string {
|
||||
if (!_dbPath) throw new Error('Database not initialized.');
|
||||
return _dbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw better-sqlite3 Database instance.
|
||||
* Used by BackupManager for the `.backup()` API.
|
||||
*/
|
||||
export function getRawSqlite(): Database.Database {
|
||||
if (!_rawSqlite) throw new Error('Database not initialized.');
|
||||
return _rawSqlite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection and clears all module-level references.
|
||||
* Called by BackupManager before atomically replacing the DB file.
|
||||
* After calling this, you must call `initDb()` again to re-open.
|
||||
*/
|
||||
export function closeDb(): void {
|
||||
if (_rawSqlite) {
|
||||
try { _rawSqlite.close(); } catch { /* ignore */ }
|
||||
_rawSqlite = null;
|
||||
}
|
||||
dbInstance = null;
|
||||
}
|
||||
|
||||
86
src/main/db/migrations/0000_broad_dust.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
CREATE TABLE `agent_run_actions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `agent_runs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent_id` text NOT NULL,
|
||||
`status` text DEFAULT 'running' NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`parent_id` text,
|
||||
`name` text NOT NULL,
|
||||
`industry` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `projects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text,
|
||||
`name` text NOT NULL,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`ai_summary` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_comments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`author` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`description` text,
|
||||
`status` text DEFAULT 'todo' NOT NULL,
|
||||
`priority` text DEFAULT 'medium' NOT NULL,
|
||||
`assignee` text,
|
||||
`due_date` integer,
|
||||
`is_ai_suggested` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `timeline_event_dependencies` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`from_event_id` text NOT NULL,
|
||||
`to_event_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `timeline_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`date` integer NOT NULL,
|
||||
`end_date` integer,
|
||||
`type` text DEFAULT 'milestone' NOT NULL,
|
||||
`is_completed` integer DEFAULT 0 NOT NULL,
|
||||
`is_ai_suggested` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
17
src/main/db/migrations/0001_boring_the_leader.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `note_edits` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`anchor_before` text,
|
||||
`anchor_text` text,
|
||||
`proposed_content` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`agent_id` text,
|
||||
`run_id` text,
|
||||
`reasoning` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`resolved_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `notes` ADD `ai_summary` text;--> statement-breakpoint
|
||||
ALTER TABLE `notes` ADD `ai_summary_updated_at` integer;
|
||||
10
src/main/db/migrations/0002_giant_karnak.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `task_briefings` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`briefing_markdown` text NOT NULL,
|
||||
`canvas_draft` text,
|
||||
`canvas_kind` text,
|
||||
`citations` text,
|
||||
`source_task_hash` text NOT NULL,
|
||||
`generated_at` integer NOT NULL,
|
||||
`model_version` text
|
||||
);
|
||||
8
src/main/db/migrations/0003_shiny_karma.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `task_brief_chats` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`is_error` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
11
src/main/db/migrations/0004_right_alex_power.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `task_attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`stored_path` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `tasks` ADD `estimate` integer;
|
||||
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `project_folder_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`relative_path` text NOT NULL,
|
||||
`ext` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`summary` text,
|
||||
`summary_updated_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;
|
||||
23
src/main/db/migrations/0006_misty_cammi.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `ai_chat_messages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`tool_calls` text,
|
||||
`tool_results` text,
|
||||
`scope` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ai_chat_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`channel` text NOT NULL,
|
||||
`title` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`last_scope` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `ai_chat_messages_session_created_idx` ON `ai_chat_messages` (`session_id`, `created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `ai_chat_sessions_channel_updated_idx` ON `ai_chat_sessions` (`channel`, `updated_at`);
|
||||
44
src/main/db/migrations/0007_scouts_rename.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Rename agent_runs → scout_runs and agent_run_actions → scout_run_actions
|
||||
-- SQLite supports ALTER TABLE RENAME TO; column rename (agent_id → scout_id) requires recreate.
|
||||
|
||||
-- Step 1: rename agent_runs table
|
||||
ALTER TABLE `agent_runs` RENAME TO `scout_runs`;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Step 2: rename agent_run_actions table
|
||||
ALTER TABLE `agent_run_actions` RENAME TO `scout_run_actions`;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Step 3: rename agent_id column in scout_runs (SQLite requires full table recreate for column rename)
|
||||
CREATE TABLE `__new_scout_runs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`scout_id` text NOT NULL,
|
||||
`status` text DEFAULT 'running' NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_scout_runs` SELECT `id`, `agent_id`, `status`, `started_at`, `completed_at` FROM `scout_runs`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `scout_runs`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__new_scout_runs` RENAME TO `scout_runs`;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Step 4: rename agent_id column in scout_run_actions
|
||||
CREATE TABLE `__new_scout_run_actions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`run_id` text NOT NULL,
|
||||
`scout_id` text NOT NULL,
|
||||
`verb` text NOT NULL,
|
||||
`entity_type` text NOT NULL,
|
||||
`entity_id` text,
|
||||
`entity_title` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_scout_run_actions` SELECT `id`, `run_id`, `agent_id`, `verb`, `entity_type`, `entity_id`, `entity_title`, `created_at` FROM `scout_run_actions`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `scout_run_actions`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__new_scout_run_actions` RENAME TO `scout_run_actions`;
|
||||
16
src/main/db/migrations/0008_scout_suggestions.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Create scout_suggestions table
|
||||
CREATE TABLE `scout_suggestions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`scout_id` text NOT NULL,
|
||||
`source_type` text NOT NULL,
|
||||
`source_msg_ref` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`payload` text,
|
||||
`raw_subject` text,
|
||||
`raw_snippet` text,
|
||||
`status` text NOT NULL,
|
||||
`proposed_at` integer NOT NULL,
|
||||
`resolved_at` integer,
|
||||
`resolved_entity_type` text,
|
||||
`resolved_entity_id` text
|
||||
);
|
||||
537
src/main/db/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,537 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "163d917f-37b9-44a6-8edc-222ebf3f7f74",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
646
src/main/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,646 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a52096e8-17fe-493a-a24a-4305c2953b3d",
|
||||
"prevId": "163d917f-37b9-44a6-8edc-222ebf3f7f74",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
712
src/main/db/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,712 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "c2e44835-b24a-4410-babf-887a82a4568e",
|
||||
"prevId": "a52096e8-17fe-493a-a24a-4305c2953b3d",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
765
src/main/db/migrations/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,765 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
|
||||
"prevId": "c2e44835-b24a-4410-babf-887a82a4568e",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
831
src/main/db/migrations/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,831 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"prevId": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
|
||||
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"project_folder_files": {
|
||||
"name": "project_folder_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relative_path": {
|
||||
"name": "relative_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ext": {
|
||||
"name": "ext",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary_updated_at": {
|
||||
"name": "summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_path": {
|
||||
"name": "folder_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scanned_at": {
|
||||
"name": "folder_last_scanned_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scan_status": {
|
||||
"name": "folder_last_scan_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"folder_total_files": {
|
||||
"name": "folder_total_files",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
1052
src/main/db/migrations/meta/0006_snapshot.json
Normal file
1058
src/main/db/migrations/meta/0007_snapshot.json
Normal file
1153
src/main/db/migrations/meta/0008_snapshot.json
Normal file
69
src/main/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1777233385010,
|
||||
"tag": "0000_broad_dust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1777499571580,
|
||||
"tag": "0001_boring_the_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1777882122765,
|
||||
"tag": "0002_giant_karnak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1777889091889,
|
||||
"tag": "0003_shiny_karma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1778238659431,
|
||||
"tag": "0004_right_alex_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1778579196669,
|
||||
"tag": "0005_slim_baron_strucker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1778777130582,
|
||||
"tag": "0006_misty_cammi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1747353600000,
|
||||
"tag": "0007_scouts_rename",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1747440000000,
|
||||
"tag": "0008_scout_suggestions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
69
src/main/db/notes-backfill.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Notes AI summary backfill.
|
||||
*
|
||||
* On startup, scans notes with a null ai_summary and generates summaries
|
||||
* via the backend `POST /api/v1/scouts/notes/summarize` endpoint.
|
||||
*
|
||||
* - Throttled to 1 request/second to avoid rate-limiting.
|
||||
* - Idempotent: notes that already have an aiSummary are skipped.
|
||||
* - Offline-safe: if the backend is unreachable the run is skipped entirely;
|
||||
* the next startup will retry.
|
||||
*/
|
||||
|
||||
import { eq, isNull } from 'drizzle-orm';
|
||||
import { getDb } from './index';
|
||||
import { notes } from './schema';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
const THROTTLE_MS = 1_000;
|
||||
|
||||
export async function backfillNoteSummaries(): Promise<void> {
|
||||
const client = getBackendClient();
|
||||
|
||||
const isOnline = await client.isOnline().catch(() => false);
|
||||
if (!isOnline) {
|
||||
console.log('[NotesBackfill] Backend offline — skipping aiSummary backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = getDb()
|
||||
.select({ id: notes.id, title: notes.title, content: notes.content })
|
||||
.from(notes)
|
||||
.where(isNull(notes.aiSummary))
|
||||
.all();
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('[NotesBackfill] All notes have aiSummary — nothing to backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[NotesBackfill] Generating aiSummary for ${pending.length} note(s)…`);
|
||||
let success = 0;
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const note = pending[i]!;
|
||||
try {
|
||||
const result = await client.proxyPost<{ summary: string }>(
|
||||
'/api/v1/scouts/notes/summarize',
|
||||
{ title: note.title, content: note.content },
|
||||
);
|
||||
const summary = result.summary?.trim() ?? '';
|
||||
if (summary) {
|
||||
getDb()
|
||||
.update(notes)
|
||||
.set({ aiSummary: summary, aiSummaryUpdatedAt: Date.now() })
|
||||
.where(eq(notes.id, note.id))
|
||||
.run();
|
||||
success++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[NotesBackfill] Failed for note ${note.id}:`, err);
|
||||
}
|
||||
|
||||
if (i < pending.length - 1) {
|
||||
await new Promise<void>((r) => setTimeout(r, THROTTLE_MS));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[NotesBackfill] Done: ${success}/${pending.length} summaries generated.`);
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
|
||||
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
|
||||
aiSummary: text('ai_summary'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
folderPath: text('folder_path'),
|
||||
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
|
||||
folderLastScanStatus: text('folder_last_scan_status', {
|
||||
enum: ['idle', 'scanning', 'error'],
|
||||
}).default('idle'),
|
||||
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
|
||||
});
|
||||
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
@@ -27,18 +33,29 @@ export const tasks = sqliteTable('tasks', {
|
||||
priority: text('priority').notNull().default('medium'),
|
||||
assignee: text('assignee'),
|
||||
dueDate: integer('due_date', { mode: 'number' }),
|
||||
estimate: integer('estimate', { mode: 'number' }),
|
||||
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
|
||||
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(1),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const checkpoints = sqliteTable('checkpoints', {
|
||||
export const timelineEvents = sqliteTable('timeline_events', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
projectId: text('project_id'),
|
||||
title: text('title').notNull(),
|
||||
date: integer('date', { mode: 'number' }).notNull(),
|
||||
endDate: integer('end_date', { mode: 'number' }),
|
||||
type: text('type', { enum: ['milestone', 'checkpoint', 'activity'] }).notNull().default('milestone'),
|
||||
isCompleted: integer('is_completed', { mode: 'number' }).notNull().default(0),
|
||||
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
|
||||
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const timelineEventDependencies = sqliteTable('timeline_event_dependencies', {
|
||||
id: text('id').primaryKey(),
|
||||
fromEventId: text('from_event_id').notNull(),
|
||||
toEventId: text('to_event_id').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
@@ -47,10 +64,42 @@ export const notes = sqliteTable('notes', {
|
||||
projectId: text('project_id'),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull().default(''),
|
||||
aiSummary: text('ai_summary'),
|
||||
aiSummaryUpdatedAt: integer('ai_summary_updated_at', { mode: 'number' }),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const projectFolderFiles = sqliteTable('project_folder_files', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
relativePath: text('relative_path').notNull(),
|
||||
ext: text('ext').notNull(),
|
||||
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
|
||||
summary: text('summary'),
|
||||
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
|
||||
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
|
||||
|
||||
export const noteEdits = sqliteTable('note_edits', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id').notNull(),
|
||||
type: text('type', { enum: ['append', 'insert', 'replace'] }).notNull(),
|
||||
anchorBefore: text('anchor_before'),
|
||||
anchorText: text('anchor_text'),
|
||||
proposedContent: text('proposed_content').notNull(),
|
||||
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).notNull().default('pending'),
|
||||
agentId: text('agent_id'),
|
||||
runId: text('run_id'),
|
||||
reasoning: text('reasoning'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
resolvedAt: integer('resolved_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const taskComments = sqliteTable('task_comments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
@@ -59,6 +108,16 @@ export const taskComments = sqliteTable('task_comments', {
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const taskAttachments = sqliteTable('task_attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type'),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
storedPath: text('stored_path').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
// Inferred TypeScript types — no manual duplication
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type NewClient = InferInsertModel<typeof clients>;
|
||||
@@ -69,11 +128,116 @@ export type NewProject = InferInsertModel<typeof projects>;
|
||||
export type Task = InferSelectModel<typeof tasks>;
|
||||
export type NewTask = InferInsertModel<typeof tasks>;
|
||||
|
||||
export type Checkpoint = InferSelectModel<typeof checkpoints>;
|
||||
export type NewCheckpoint = InferInsertModel<typeof checkpoints>;
|
||||
|
||||
export type Note = InferSelectModel<typeof notes>;
|
||||
export type NewNote = InferInsertModel<typeof notes>;
|
||||
|
||||
export type TaskComment = InferSelectModel<typeof taskComments>;
|
||||
export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||
|
||||
export type TaskAttachment = InferSelectModel<typeof taskAttachments>;
|
||||
export type NewTaskAttachment = InferInsertModel<typeof taskAttachments>;
|
||||
|
||||
export type TimelineEvent = InferSelectModel<typeof timelineEvents>;
|
||||
export type NewTimelineEvent = InferInsertModel<typeof timelineEvents>;
|
||||
|
||||
export type TimelineEventDependency = InferSelectModel<typeof timelineEventDependencies>;
|
||||
export type NewTimelineEventDependency = InferInsertModel<typeof timelineEventDependencies>;
|
||||
|
||||
export const taskBriefings = sqliteTable('task_briefings', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
briefingMarkdown: text('briefing_markdown').notNull(),
|
||||
canvasDraft: text('canvas_draft'),
|
||||
canvasKind: text('canvas_kind'),
|
||||
citations: text('citations'),
|
||||
sourceTaskHash: text('source_task_hash').notNull(),
|
||||
generatedAt: integer('generated_at', { mode: 'number' }).notNull(),
|
||||
modelVersion: text('model_version'),
|
||||
});
|
||||
|
||||
export type TaskBriefing = InferSelectModel<typeof taskBriefings>;
|
||||
export type NewTaskBriefing = InferInsertModel<typeof taskBriefings>;
|
||||
|
||||
export const taskBriefChats = sqliteTable('task_brief_chats', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
role: text('role', { enum: ['user', 'assistant'] }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
isError: integer('is_error', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type TaskBriefChat = InferSelectModel<typeof taskBriefChats>;
|
||||
export type NewTaskBriefChat = InferInsertModel<typeof taskBriefChats>;
|
||||
|
||||
export const scoutRuns = sqliteTable('scout_runs', {
|
||||
id: text('id').primaryKey(),
|
||||
scoutId: text('scout_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 scoutRunActions = sqliteTable('scout_run_actions', {
|
||||
id: text('id').primaryKey(),
|
||||
runId: text('run_id').notNull(),
|
||||
scoutId: text('scout_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 ScoutRun = InferSelectModel<typeof scoutRuns>;
|
||||
export type NewScoutRun = InferInsertModel<typeof scoutRuns>;
|
||||
export type ScoutRunAction = InferSelectModel<typeof scoutRunActions>;
|
||||
export type NewScoutRunAction = InferInsertModel<typeof scoutRunActions>;
|
||||
|
||||
export type NoteEdit = InferSelectModel<typeof noteEdits>;
|
||||
export type NewNoteEdit = InferInsertModel<typeof noteEdits>;
|
||||
|
||||
export const aiChatSessions = sqliteTable('ai_chat_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
channel: text('channel', { enum: ['home', 'contextual'] }).notNull(),
|
||||
title: text('title'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
lastScope: text('last_scope'),
|
||||
});
|
||||
|
||||
export const aiChatMessages = sqliteTable('ai_chat_messages', {
|
||||
id: text('id').primaryKey(),
|
||||
sessionId: text('session_id').notNull(),
|
||||
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
toolCalls: text('tool_calls'),
|
||||
toolResults: text('tool_results'),
|
||||
scope: text('scope'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type AiChatSession = InferSelectModel<typeof aiChatSessions>;
|
||||
export type NewAiChatSession = InferInsertModel<typeof aiChatSessions>;
|
||||
export type AiChatMessage = InferSelectModel<typeof aiChatMessages>;
|
||||
export type NewAiChatMessage = InferInsertModel<typeof aiChatMessages>;
|
||||
|
||||
export const scoutSuggestions = sqliteTable('scout_suggestions', {
|
||||
id: text().primaryKey(),
|
||||
scoutId: text('scout_id').notNull(),
|
||||
sourceType: text('source_type').notNull(),
|
||||
sourceMsgRef: text('source_msg_ref').notNull(),
|
||||
category: text().notNull(), // "unprocessed" until Phase 4
|
||||
payload: text(), // JSON, populated by Phase 4
|
||||
rawSubject: text('raw_subject'),
|
||||
rawSnippet: text('raw_snippet'),
|
||||
status: text().notNull(), // pending | approved | rejected | expired
|
||||
proposedAt: integer('proposed_at').notNull(),
|
||||
resolvedAt: integer('resolved_at'),
|
||||
resolvedEntityType: text('resolved_entity_type'),
|
||||
resolvedEntityId: text('resolved_entity_id'),
|
||||
});
|
||||
|
||||
export type ScoutSuggestion = InferSelectModel<typeof scoutSuggestions>;
|
||||
export type NewScoutSuggestion = InferInsertModel<typeof scoutSuggestions>;
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import * as lancedb from 'vectordb';
|
||||
import { app } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { getDb } from './index';
|
||||
import { notes } from './schema';
|
||||
import { embedText } from '../ai/embeddings';
|
||||
|
||||
interface NoteRecord {
|
||||
id: string;
|
||||
/** Empty string when the note has no project (Arrow string fields don't cleanly handle null) */
|
||||
projectId: string;
|
||||
content: string;
|
||||
vector: number[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
projectId: string;
|
||||
content: string;
|
||||
_distance: number;
|
||||
}
|
||||
|
||||
let conn: lancedb.Connection | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the LanceDB connection. Must be called before any other
|
||||
* function in this module. Vector data is stored at userData/vectors/.
|
||||
*/
|
||||
export async function initVectorDb(): Promise<void> {
|
||||
const vectorPath = path.join(app.getPath('userData'), 'vectors');
|
||||
conn = await lancedb.connect(vectorPath);
|
||||
console.log('[VectorDB] Connected at:', vectorPath);
|
||||
}
|
||||
|
||||
function getConn(): lancedb.Connection {
|
||||
if (!conn) throw new Error('[VectorDB] Not initialized. Call initVectorDb() first.');
|
||||
return conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed note content and upsert the record into the LanceDB 'notes' table.
|
||||
*
|
||||
* Upsert strategy: delete-then-add.
|
||||
* table.delete(where) is a no-op when no rows match, so this is safe for
|
||||
* both first-time inserts and subsequent updates.
|
||||
*
|
||||
* On the very first call when the table does not yet exist, createTable
|
||||
* infers the Arrow schema from the initial record.
|
||||
*
|
||||
* Throws on error — callers fire-and-forget via .catch().
|
||||
*/
|
||||
export async function upsertNoteEmbedding(
|
||||
noteId: string,
|
||||
projectId: string | null,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const c = getConn();
|
||||
const vector = await embedText(content);
|
||||
|
||||
const record: NoteRecord = {
|
||||
id: noteId,
|
||||
projectId: projectId ?? '',
|
||||
content,
|
||||
vector,
|
||||
};
|
||||
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (!tableNames.includes('notes')) {
|
||||
// First embedding: createTable infers the Arrow schema from this record.
|
||||
// The vector dimension (1536 for text-embedding-3-small) is baked in here.
|
||||
await c.createTable('notes', [record]);
|
||||
console.log('[VectorDB] Created notes table');
|
||||
return;
|
||||
}
|
||||
|
||||
const table = await c.openTable<NoteRecord>('notes');
|
||||
// Note IDs are UUID v4 — only [0-9a-f-] chars, no SQL injection risk.
|
||||
await table.delete(`id = '${noteId}'`);
|
||||
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.
|
||||
*
|
||||
* Per-note errors are caught and logged; a single failure does not
|
||||
* abort the remaining notes.
|
||||
*/
|
||||
export async function migrateNotesIfNeeded(): Promise<void> {
|
||||
const c = getConn();
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (tableNames.includes('notes')) {
|
||||
console.log('[VectorDB] Notes table exists, skipping migration');
|
||||
return;
|
||||
}
|
||||
|
||||
const allNotes = getDb().select().from(notes).all();
|
||||
|
||||
if (allNotes.length === 0) {
|
||||
console.log('[VectorDB] No existing notes to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[VectorDB] Migrating ${allNotes.length} notes...`);
|
||||
let successCount = 0;
|
||||
|
||||
for (const note of allNotes) {
|
||||
try {
|
||||
const embeddingText = `${note.title}\n\n${note.content}`;
|
||||
await upsertNoteEmbedding(note.id, note.projectId ?? null, embeddingText);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`[VectorDB] Failed to embed note ${note.id} during migration:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[VectorDB] Migration complete: ${successCount}/${allNotes.length} notes embedded`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed the query string and perform a similarity search across all notes
|
||||
* in the LanceDB 'notes' table. Returns up to `limit` results sorted by
|
||||
* distance (closest first).
|
||||
*
|
||||
* Returns an empty array if the notes table does not exist yet.
|
||||
*/
|
||||
export async function searchNotes(query: string, limit = 5): Promise<SearchResult[]> {
|
||||
const c = getConn();
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (!tableNames.includes('notes')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryVector = await embedText(query);
|
||||
const table = await c.openTable('notes');
|
||||
const results = await table.search(queryVector).limit(limit).execute();
|
||||
|
||||
return results.map((r) => ({
|
||||
id: r.id as string,
|
||||
projectId: r.projectId as string,
|
||||
content: r.content as string,
|
||||
_distance: r._distance as number,
|
||||
}));
|
||||
}
|
||||
21
src/main/files/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** File-type whitelists & size caps for project folder indexing. */
|
||||
|
||||
export const TEXT_EXTS = new Set([
|
||||
'.md', '.txt', '.rst', '.adoc',
|
||||
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
|
||||
'.html', '.htm', '.xml',
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
||||
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
||||
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
|
||||
'.css', '.scss', '.sass',
|
||||
'.sql',
|
||||
]);
|
||||
|
||||
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
|
||||
export const PDF_EXTS = new Set(['.pdf']);
|
||||
export const DOCX_EXTS = new Set(['.docx']);
|
||||
|
||||
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const INDEX_BATCH_SIZE = 5;
|
||||
27
src/main/files/daily-rescan.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// adiuvAI/src/main/files/daily-rescan.ts
|
||||
import { getDb } from '../db';
|
||||
import { projects } from '../db/schema';
|
||||
import { sql, and, isNotNull } from 'drizzle-orm';
|
||||
import { startIndexSession } from './indexer';
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function runDailyRescan(): Promise<void> {
|
||||
const cutoff = Date.now() - ONE_DAY_MS;
|
||||
const stale = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(projects.folderPath),
|
||||
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const p of stale) {
|
||||
if (p.folderLastScanStatus === 'scanning') continue;
|
||||
// Fire-and-forget; no UI listener.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(p.id, () => {});
|
||||
}
|
||||
}
|
||||
222
src/main/files/indexer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Folder index session orchestrator.
|
||||
*
|
||||
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
|
||||
* returned summaries to projectFolderFiles, drives progress callbacks.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { scanFolder, type ScannedFile } from './scanner';
|
||||
import { INDEX_BATCH_SIZE } from './constants';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
export interface IndexProgress {
|
||||
sessionId: string;
|
||||
processed: number;
|
||||
total: number;
|
||||
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ProgressListener = (p: IndexProgress) => void;
|
||||
|
||||
async function readForIndex(
|
||||
folderPath: string,
|
||||
f: ScannedFile,
|
||||
): Promise<{ content: string; mime?: string }> {
|
||||
const abs = path.join(folderPath, f.relativePath);
|
||||
if (f.kind === 'image') {
|
||||
const buf = await readFile(abs);
|
||||
const ext = f.ext.toLowerCase();
|
||||
const mime =
|
||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
return { content: buf.toString('base64'), mime };
|
||||
}
|
||||
if (f.kind === 'text') {
|
||||
return { content: await readFile(abs, 'utf-8') };
|
||||
}
|
||||
// pdf / docx: read as binary, base64. Server is responsible for extraction.
|
||||
const buf = await readFile(abs);
|
||||
return { content: buf.toString('base64') };
|
||||
}
|
||||
|
||||
export async function startIndexSession(
|
||||
projectId: string,
|
||||
onProgress: ProgressListener,
|
||||
): Promise<{ sessionId: string; cancel: () => void }> {
|
||||
const sessionId = randomUUID();
|
||||
const db = getDb();
|
||||
|
||||
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj || !proj.folderPath) {
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
db.update(projects)
|
||||
.set({ folderLastScanStatus: 'scanning' })
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
|
||||
|
||||
const delta = await scanFolder(projectId, proj.folderPath);
|
||||
|
||||
// Filter out 'skipped' files — they are too large to index and must not be sent
|
||||
const toIndex = [
|
||||
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
|
||||
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
|
||||
];
|
||||
const total = toIndex.length;
|
||||
|
||||
for (const rel of delta.deletedRelPaths) {
|
||||
db.delete(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, rel),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus: 'idle',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
const backend = getBackendClient();
|
||||
|
||||
let processed = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const finalize = (status: IndexProgress['status'], error?: string): void => {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus:
|
||||
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount + processed,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed, total, status, error });
|
||||
};
|
||||
|
||||
backend.registerIndexSession(sessionId, {
|
||||
onFileResult: ({ relPath, summary, error }) => {
|
||||
if (error) return;
|
||||
const f = toIndex.find((x) => x.relativePath === relPath);
|
||||
if (!f) return;
|
||||
const now = Date.now();
|
||||
|
||||
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, f.relativePath),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
db.update(projectFolderFiles)
|
||||
.set({
|
||||
mtimeMs: f.mtimeMs,
|
||||
sizeBytes: f.sizeBytes,
|
||||
kind: f.kind,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.where(eq(projectFolderFiles.id, existing.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(projectFolderFiles)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
relativePath: f.relativePath,
|
||||
ext: f.ext,
|
||||
kind: f.kind,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
onProgress: ({ processed: p, total: t }) => {
|
||||
processed = p;
|
||||
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
|
||||
},
|
||||
onDone: (status) => {
|
||||
finalize(
|
||||
status === 'completed'
|
||||
? 'completed'
|
||||
: status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: status === 'quota_exceeded'
|
||||
? 'quota_exceeded'
|
||||
: 'error',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
backend.sendIndexSessionStart(sessionId, projectId, total);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
// Send batches (skipped files already excluded from toIndex)
|
||||
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
|
||||
const payload = await Promise.all(
|
||||
batch.map(async (f) => {
|
||||
const { content, mime } = await readForIndex(proj.folderPath!, f);
|
||||
return {
|
||||
relPath: f.relativePath,
|
||||
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
|
||||
content,
|
||||
ext: f.ext,
|
||||
mime,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
try {
|
||||
backend.sendIndexFileBatch(sessionId, payload);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
cancelled = true;
|
||||
backend.sendIndexSessionCancel(sessionId);
|
||||
};
|
||||
return { sessionId, cancel };
|
||||
}
|
||||
95
src/main/files/scanner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getDb } from '../db';
|
||||
import { projectFolderFiles } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
|
||||
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
|
||||
} from './constants';
|
||||
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
|
||||
|
||||
export interface ScannedFile {
|
||||
relativePath: string;
|
||||
ext: string;
|
||||
kind: FileKind;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ScanDelta {
|
||||
newFiles: ScannedFile[];
|
||||
changedFiles: ScannedFile[];
|
||||
unchangedCount: number;
|
||||
deletedRelPaths: string[];
|
||||
}
|
||||
|
||||
function classify(ext: string, sizeBytes: number): FileKind | null {
|
||||
const e = ext.toLowerCase();
|
||||
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
|
||||
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
|
||||
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
|
||||
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
|
||||
return null; // not indexable
|
||||
}
|
||||
|
||||
async function walk(root: string): Promise<ScannedFile[]> {
|
||||
const out: ScannedFile[] = [];
|
||||
async function recurse(dir: string) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied — skip silently
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue; // skip dot dirs / files
|
||||
if (e.name === 'node_modules') continue; // common noise
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await recurse(full);
|
||||
} else if (e.isFile()) {
|
||||
let s;
|
||||
try { s = await stat(full); } catch { continue; }
|
||||
const ext = path.extname(e.name);
|
||||
const kind = classify(ext, s.size);
|
||||
if (kind === null) continue;
|
||||
out.push({
|
||||
relativePath: path.relative(root, full),
|
||||
ext,
|
||||
kind,
|
||||
sizeBytes: s.size,
|
||||
mtimeMs: Math.floor(s.mtimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await recurse(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
|
||||
const scanned = await walk(folderPath);
|
||||
const existing = getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
|
||||
const newFiles: ScannedFile[] = [];
|
||||
const changedFiles: ScannedFile[] = [];
|
||||
let unchanged = 0;
|
||||
for (const f of scanned) {
|
||||
const prev = existingMap.get(f.relativePath);
|
||||
if (!prev) newFiles.push(f);
|
||||
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
|
||||
else unchanged++;
|
||||
existingMap.delete(f.relativePath);
|
||||
}
|
||||
const deletedRelPaths = Array.from(existingMap.keys());
|
||||
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
|
||||
}
|
||||
@@ -1,27 +1,97 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import path from 'node:path';
|
||||
import started from 'electron-squirrel-startup';
|
||||
import { initDb } from './db';
|
||||
import { appRouter } from './router';
|
||||
import { createIPCHandler } from './ipc';
|
||||
import { initAI } from './ai/provider';
|
||||
import { initVectorDb, migrateNotesIfNeeded } from './db/vectordb';
|
||||
// Import to trigger provider registration before initAI() runs
|
||||
import './ai/copilot';
|
||||
import { getAuthManager } from './auth/auth-manager';
|
||||
import { getBackendClient } from './api/backend-client';
|
||||
import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
import { startScoutScheduler, stopScoutScheduler } from './scouts/scout-scheduler';
|
||||
import { backfillNoteSummaries } from './db/notes-backfill';
|
||||
import { runDailyRescan } from './files/daily-rescan';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-instance lock + deep link (OAuth callback via adiuvai://)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// In dev, Electron is launched as: `electron . ` (or via electron-forge).
|
||||
// setAsDefaultProtocolClient on Windows/Linux requires the path to the exe.
|
||||
if (process.defaultApp) {
|
||||
// Dev: electron.exe is the "app" — pass the script path as the second arg
|
||||
// so that OS-registered links include it and second-instance receives the URL.
|
||||
app.setAsDefaultProtocolClient('adiuvai', process.execPath, [path.resolve(process.argv[1] ?? '.')]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('adiuvai');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and dispatch an adiuvai:// deep link URL.
|
||||
* Delegates to AuthManager so the pending OAuth promise is resolved.
|
||||
* Also handles scout-specific OAuth callbacks (e.g. Gmail connector setup).
|
||||
*/
|
||||
function handleDeepLink(url: string): void {
|
||||
if (url.startsWith('adiuvai://oauth/callback')) {
|
||||
void getAuthManager().handleOAuthCallback(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scout Gmail OAuth callback: adiuvai://scout/oauth/gmail/callback?code=...&state=...
|
||||
if (url.startsWith('adiuvai://scout/oauth/gmail/callback')) {
|
||||
const parsed = new URL(url);
|
||||
const code = parsed.searchParams.get('code');
|
||||
const state = parsed.searchParams.get('state');
|
||||
if (code && state) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows[0]?.webContents.send('scout:gmailOAuthCallback', { code, state });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Windows / Linux: a second instance is launched with the deep link as an argv.
|
||||
// We prevent the second instance and redirect the URL to the first instance.
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
// Another instance already running — hand off and exit.
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
// On Windows the URL is the last argument (e.g. adiuvai://oauth/callback?...)
|
||||
const url = argv.find((arg) => arg.startsWith('adiuvai://'));
|
||||
if (url) handleDeepLink(url);
|
||||
|
||||
// Bring the existing window to focus.
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
const win = windows[0]!;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// macOS: the OS delivers the URL via this event (no second instance spawned).
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
const createWindow = (): BrowserWindow => {
|
||||
// Create the browser window.
|
||||
const iconPath = path.join(__dirname, '../../assets/logo/logo-icon.png');
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -46,6 +116,20 @@ const createWindow = (): BrowserWindow => {
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog IPC — file/folder picker
|
||||
// ---------------------------------------------------------------------------
|
||||
ipcMain.handle('dialog:showOpenDialog', (_event, options: Electron.OpenDialogOptions) =>
|
||||
dialog.showOpenDialog(options),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual sidebar — scope update IPC handler (M4.7)
|
||||
// ---------------------------------------------------------------------------
|
||||
ipcMain.handle('ai:contextual-scope-update', (_event, args: { sessionId: string; scope: unknown }) => {
|
||||
getBackendClient().sendContextualScopeUpdate(args);
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -53,12 +137,33 @@ app.on('ready', () => {
|
||||
initDb();
|
||||
const win = createWindow();
|
||||
createIPCHandler({ router: appRouter, windows: [win] });
|
||||
// AI init is best-effort — never block window creation
|
||||
initAI().catch((err) => console.error('[AI] Init failed:', err));
|
||||
// Vector DB init + migration is best-effort — runs after window is shown
|
||||
initVectorDb()
|
||||
.then(() => migrateNotesIfNeeded())
|
||||
.catch((err) => console.error('[VectorDB] Init or migration failed:', err));
|
||||
// Persistent device WebSocket for agent triggers — best-effort on startup
|
||||
getAuthManager()
|
||||
.isAuthenticated()
|
||||
.then((authenticated) => {
|
||||
if (authenticated) {
|
||||
void getBackendClient().connectPersistent();
|
||||
// Best-effort notes backfill — runs after WS is likely connected
|
||||
setTimeout(() => {
|
||||
backfillNoteSummaries().catch((err) =>
|
||||
console.error('[NotesBackfill] Startup backfill failed:', err),
|
||||
);
|
||||
}, 5_000);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
|
||||
|
||||
startBriefScheduler();
|
||||
startScoutScheduler();
|
||||
// Delay so WS connection is likely up before triggering rescans
|
||||
setTimeout(() => { void runDailyRescan(); }, 10_000);
|
||||
});
|
||||
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
app.on('will-quit', () => {
|
||||
stopBriefScheduler();
|
||||
stopScoutScheduler();
|
||||
getBackendClient().disconnectPersistent();
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
|
||||
105
src/main/router/ai-chat.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// adiuvAI/src/main/router/ai-chat.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { eq, desc, asc } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { aiChatSessions, aiChatMessages } from '../db/schema';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
const ChannelSchema = z.enum(['home', 'contextual']);
|
||||
const RoleSchema = z.enum(['user', 'assistant', 'system']);
|
||||
|
||||
export const aiChatRouter = router({
|
||||
listSessions: publicProcedure
|
||||
.input(z.object({ channel: ChannelSchema }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(aiChatSessions)
|
||||
.where(eq(aiChatSessions.channel, input.channel))
|
||||
.orderBy(desc(aiChatSessions.updatedAt))
|
||||
.all();
|
||||
}),
|
||||
|
||||
getSession: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const db = getDb();
|
||||
const session = db
|
||||
.select()
|
||||
.from(aiChatSessions)
|
||||
.where(eq(aiChatSessions.id, input.id))
|
||||
.get();
|
||||
if (!session) return null;
|
||||
const messages = db
|
||||
.select()
|
||||
.from(aiChatMessages)
|
||||
.where(eq(aiChatMessages.sessionId, input.id))
|
||||
.orderBy(asc(aiChatMessages.createdAt))
|
||||
.all();
|
||||
return { session, messages };
|
||||
}),
|
||||
|
||||
createSession: publicProcedure
|
||||
.input(z.object({
|
||||
channel: ChannelSchema,
|
||||
initialScope: z.string().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
db.insert(aiChatSessions).values({
|
||||
id,
|
||||
channel: input.channel,
|
||||
title: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastScope: input.initialScope ?? null,
|
||||
}).run();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
appendMessage: publicProcedure
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
role: RoleSchema,
|
||||
content: z.string(),
|
||||
toolCalls: z.string().optional(),
|
||||
toolResults: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
db.insert(aiChatMessages).values({
|
||||
id,
|
||||
sessionId: input.sessionId,
|
||||
role: input.role,
|
||||
content: input.content,
|
||||
toolCalls: input.toolCalls ?? null,
|
||||
toolResults: input.toolResults ?? null,
|
||||
scope: input.scope ?? null,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
db.update(aiChatSessions)
|
||||
.set({ updatedAt: now, lastScope: input.scope ?? null })
|
||||
.where(eq(aiChatSessions.id, input.sessionId))
|
||||
.run();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
deleteSession: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.delete(aiChatMessages).where(eq(aiChatMessages.sessionId, input.id)).run();
|
||||
db.delete(aiChatSessions).where(eq(aiChatSessions.id, input.id)).run();
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
128
src/main/router/projectFolders.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// adiuvAI/src/main/router/projectFolders.ts
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { dialog } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { startIndexSession, type IndexProgress } from '../files/indexer';
|
||||
import { scanFolder } from '../files/scanner';
|
||||
import { getBackendClient, QuotaError } from '../api/backend-client';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
// In-memory map of active sessions per projectId so we can cancel
|
||||
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
|
||||
|
||||
export const projectFoldersRouter = router({
|
||||
chooseFolder: publicProcedure.mutation(async () => {
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
}),
|
||||
|
||||
link: publicProcedure
|
||||
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.update(projects)
|
||||
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
unlink: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderPath: null,
|
||||
folderLastScannedAt: null,
|
||||
folderLastScanStatus: 'idle',
|
||||
folderTotalFiles: 0,
|
||||
})
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
startScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
|
||||
if (!proj?.folderPath) throw new Error('No folder linked');
|
||||
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
|
||||
|
||||
// Pre-flight: walk folder to estimate indexable file count, then ask the
|
||||
// backend whether the user's tier allows proceeding.
|
||||
const delta = await scanFolder(input.projectId, proj.folderPath);
|
||||
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
|
||||
|
||||
try {
|
||||
await getBackendClient().checkFolderQuota(estimated);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaError) {
|
||||
// Encode reason + backend message so the renderer can produce a
|
||||
// localised toast without an extra RPC call.
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: `QUOTA:${err.reason}:${err.message}`,
|
||||
});
|
||||
}
|
||||
// Network / auth errors: propagate as-is so the renderer shows a
|
||||
// generic error toast rather than silently swallowing the problem.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = await startIndexSession(input.projectId, (p) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.lastProgress = p;
|
||||
if (
|
||||
p.status === 'completed' ||
|
||||
p.status === 'cancelled' ||
|
||||
p.status === 'quota_exceeded' ||
|
||||
p.status === 'error'
|
||||
) {
|
||||
_active.delete(input.projectId);
|
||||
}
|
||||
});
|
||||
_active.set(input.projectId, {
|
||||
cancel: session.cancel,
|
||||
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
|
||||
});
|
||||
return { sessionId: session.sessionId };
|
||||
}),
|
||||
|
||||
cancelScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.cancel();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getStatus: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
return entry?.lastProgress ?? null;
|
||||
}),
|
||||
|
||||
listFiles: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, input.projectId))
|
||||
.orderBy(projectFolderFiles.relativePath)
|
||||
.all();
|
||||
}),
|
||||
});
|
||||
119
src/main/scouts/scout-scheduler.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Scout scheduler — checks locally-stored scout configs on a periodic
|
||||
* interval and triggers BE-orchestrated runs when they are due.
|
||||
*
|
||||
* Follows the same pattern as the daily brief scheduler in orchestrator.ts:
|
||||
* a single `setInterval` tick that checks all enabled scouts.
|
||||
*/
|
||||
|
||||
import { getLocalScouts, saveLocalScout, getDeviceId } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import { getDb } from '../db';
|
||||
import { scoutRuns } from '../db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How often the scheduler checks for due scouts (ms). */
|
||||
const TICK_INTERVAL_MS = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Cron expression → minimum interval in ms.
|
||||
* We use a simple mapping for the supported presets; unknown cron values
|
||||
* are treated as manual-only.
|
||||
*/
|
||||
const CRON_INTERVAL_MS: Record<string, number> = {
|
||||
'*/15 * * * *': 15 * 60 * 1000,
|
||||
'0 * * * *': 60 * 60 * 1000,
|
||||
'0 */6 * * *': 6 * 60 * 60 * 1000,
|
||||
'0 0 * * *': 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function startScoutScheduler(): void {
|
||||
if (schedulerTimer) return;
|
||||
|
||||
schedulerTimer = setInterval(() => {
|
||||
void tickScoutScheduler();
|
||||
}, TICK_INTERVAL_MS);
|
||||
|
||||
// Run once immediately on start
|
||||
void tickScoutScheduler();
|
||||
}
|
||||
|
||||
export function stopScoutScheduler(): void {
|
||||
if (schedulerTimer) {
|
||||
clearInterval(schedulerTimer);
|
||||
schedulerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function tickScoutScheduler(): Promise<void> {
|
||||
const scouts = getLocalScouts();
|
||||
const now = Date.now();
|
||||
|
||||
for (const scout of scouts) {
|
||||
if (!scout.enabled) continue;
|
||||
|
||||
// Manual-only scouts don't auto-trigger
|
||||
const intervalMs = CRON_INTERVAL_MS[scout.scheduleCron];
|
||||
if (!intervalMs) continue;
|
||||
|
||||
// Check if enough time has passed since lastRunAt
|
||||
if (scout.lastRunAt && now - scout.lastRunAt < intervalMs) continue;
|
||||
|
||||
try {
|
||||
const activeScouts = scouts.length;
|
||||
console.log(
|
||||
`[ScoutScheduler] Triggering scout "${scout.name}" (id=${scout.id}) with lastRunAt=${scout.lastRunAt} (${scout.lastRunAt ? new Date(scout.lastRunAt).toISOString() : 'null'})`,
|
||||
);
|
||||
const response = await getBackendClient().proxyPost<{ id: string }>(
|
||||
'/api/v1/scouts/trigger',
|
||||
{
|
||||
directory: scout.directory,
|
||||
deviceId: getDeviceId(),
|
||||
agentId: scout.id,
|
||||
whatToExtract: scout.dataTypes,
|
||||
batchInterval: scout.scheduleCron,
|
||||
agentConfig: scout.agentConfig ?? undefined,
|
||||
activeAgents: activeScouts,
|
||||
lastRunAt: scout.lastRunAt ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// Create the run row immediately so it appears in history even if
|
||||
// the scout finds nothing to create/update.
|
||||
if (response?.id) {
|
||||
try {
|
||||
await getDb().insert(scoutRuns).values({
|
||||
id: response.id,
|
||||
scoutId: scout.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
|
||||
saveLocalScout({ ...scout, lastRunAt: now });
|
||||
console.log(`[ScoutScheduler] Triggered scout "${scout.name}" (id=${scout.id}).`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[ScoutScheduler] Failed to trigger scout "${scout.name}": ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/main/scouts/scout-suggestion-handler.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getDb } from '../db';
|
||||
import { scoutSuggestions } from '../db/schema';
|
||||
|
||||
/**
|
||||
* Shape of the `proposal` object inside a `scout_proposal` WS frame,
|
||||
* after toCamelCase has been applied to the incoming JSON.
|
||||
*/
|
||||
export interface IncomingScoutProposal {
|
||||
id: string;
|
||||
scoutId: string;
|
||||
sourceType: string;
|
||||
sourceMsgRef: string;
|
||||
rawSubject?: string | null;
|
||||
rawSnippet?: string | null;
|
||||
category: 'unprocessed';
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a scout_proposal into the local scout_suggestions table.
|
||||
* Idempotent: a duplicate `id` is silently ignored via onConflictDoNothing.
|
||||
*/
|
||||
export async function handleScoutProposal(p: IncomingScoutProposal): Promise<void> {
|
||||
await getDb()
|
||||
.insert(scoutSuggestions)
|
||||
.values({
|
||||
id: p.id,
|
||||
scoutId: p.scoutId,
|
||||
sourceType: p.sourceType,
|
||||
sourceMsgRef: p.sourceMsgRef,
|
||||
category: p.category,
|
||||
payload: p.payload ? JSON.stringify(p.payload) : null,
|
||||
rawSubject: p.rawSubject ?? null,
|
||||
rawSnippet: p.rawSnippet ?? null,
|
||||
status: 'pending',
|
||||
proposedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
@@ -1,14 +1,60 @@
|
||||
import Store from 'electron-store';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local scout config — stored entirely on the FE, never on the backend.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LocalScoutConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
directory: string;
|
||||
dataTypes: string[];
|
||||
/** Structured extraction config produced by the Journey setup flow. */
|
||||
agentConfig: Record<string, unknown> | null;
|
||||
scheduleCron: string;
|
||||
enabled: boolean;
|
||||
lastRunAt: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format preferences — stored locally, never sent to LLM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FormatPrefs {
|
||||
timezone: string;
|
||||
dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
|
||||
timeFormat: '12h' | '24h';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App settings (electron-store shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppSettings {
|
||||
sidebarCollapsed: boolean;
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
userName: string;
|
||||
/** Base URL of the Adiuva backend API (e.g. 'http://localhost:8000'). */
|
||||
/** Base URL of the AdiuvAI backend API (e.g. 'http://localhost:8000'). */
|
||||
backendUrl: string;
|
||||
/**
|
||||
* Stable device identifier — UUID v4 generated once on first launch and
|
||||
* persisted forever. Used to bind local agents to the machine they were
|
||||
* configured on.
|
||||
*/
|
||||
deviceId: string;
|
||||
/** Cached daily brief — regenerated once per day or when relevant data changes. */
|
||||
dailyBriefCache: { content: string; date: string } | null;
|
||||
/** Locally-managed scout configurations. */
|
||||
localScouts: LocalScoutConfig[];
|
||||
/** OS-detected display format preferences. */
|
||||
formatPrefs: FormatPrefs | null;
|
||||
/** UI language code (e.g. 'en', 'it', 'es', 'fr', 'de'). */
|
||||
uiLanguage: string;
|
||||
/** Timeline zoom level. */
|
||||
timelineZoom: ZoomLevel;
|
||||
}
|
||||
|
||||
export type ZoomLevel = 'day' | 'week' | 'month';
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
|
||||
export function getStore(): Store<AppSettings> {
|
||||
@@ -16,12 +62,91 @@ export function getStore(): Store<AppSettings> {
|
||||
_store = new Store<AppSettings>({
|
||||
defaults: {
|
||||
sidebarCollapsed: false,
|
||||
aiProvider: 'copilot',
|
||||
encryptedTokens: {},
|
||||
userName: 'there',
|
||||
backendUrl: 'http://localhost:8000',
|
||||
deviceId: '',
|
||||
dailyBriefCache: null,
|
||||
localScouts: [],
|
||||
formatPrefs: null,
|
||||
uiLanguage: 'en',
|
||||
timelineZoom: 'day',
|
||||
},
|
||||
});
|
||||
}
|
||||
return _store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable device ID, generating and persisting a new UUID v4 on
|
||||
* first call. Subsequent calls always return the same value.
|
||||
*/
|
||||
export function getDeviceId(): string {
|
||||
const store = getStore();
|
||||
let id = store.get('deviceId');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
store.set('deviceId', id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local scout helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getLocalScouts(): LocalScoutConfig[] {
|
||||
return getStore().get('localScouts');
|
||||
}
|
||||
|
||||
export function getLocalScout(id: string): LocalScoutConfig | undefined {
|
||||
return getLocalScouts().find((s) => s.id === id);
|
||||
}
|
||||
|
||||
export function saveLocalScout(scout: LocalScoutConfig): void {
|
||||
const scouts = getLocalScouts();
|
||||
const idx = scouts.findIndex((s) => s.id === scout.id);
|
||||
if (idx >= 0) {
|
||||
scouts[idx] = scout;
|
||||
} else {
|
||||
scouts.push(scout);
|
||||
}
|
||||
getStore().set('localScouts', scouts);
|
||||
}
|
||||
|
||||
export function deleteLocalScout(id: string): void {
|
||||
const scouts = getLocalScouts().filter((s) => s.id !== id);
|
||||
getStore().set('localScouts', scouts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format preference helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getFormatPrefs(): FormatPrefs | null {
|
||||
return getStore().get('formatPrefs', null);
|
||||
}
|
||||
|
||||
export function setFormatPrefs(prefs: FormatPrefs): void {
|
||||
getStore().set('formatPrefs', prefs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI language helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getUiLanguage(): string {
|
||||
return getStore().get('uiLanguage', 'en');
|
||||
}
|
||||
|
||||
export function setUiLanguage(lang: string): void {
|
||||
getStore().set('uiLanguage', lang);
|
||||
}
|
||||
|
||||
export function getTimelineZoom(): ZoomLevel {
|
||||
const v = getStore().get('timelineZoom', 'day');
|
||||
return v === 'day' || v === 'week' || v === 'month' ? v : 'day';
|
||||
}
|
||||
|
||||
export function setTimelineZoom(level: ZoomLevel): void {
|
||||
getStore().set('timelineZoom', level);
|
||||
}
|
||||
|
||||
@@ -20,24 +20,47 @@ contextBridge.exposeInMainWorld('electronTRPC', {
|
||||
});
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
// V3 stream event — discriminated union of all frame types the renderer can receive.
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAI', {
|
||||
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
|
||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; done: boolean }) => cb(data);
|
||||
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: V3StreamEvent) => cb(data);
|
||||
ipcRenderer.on(AI_STREAM_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
|
||||
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
|
||||
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
|
||||
/** Subscribe to background brief-updated push events. Returns an unsubscribe function. */
|
||||
onBriefUpdated: (cb: (content: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, content: string) => cb(content);
|
||||
ipcRenderer.on('ai:brief-updated', handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
|
||||
ipcRenderer.removeListener('ai:brief-updated', handler);
|
||||
};
|
||||
},
|
||||
/** Fire-and-forget scope update for the contextual sidebar. Added in M4.7. */
|
||||
sendContextualScopeUpdate: (args: { sessionId: string; scope: unknown }): Promise<void> =>
|
||||
ipcRenderer.invoke('ai:contextual-scope-update', args),
|
||||
/** Subscribe to Gmail OAuth callback from the deep link handler. Returns an unsubscribe function. */
|
||||
onScoutGmailOAuthCallback: (cb: (data: { code: string; state: string }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { code: string; state: string }) => cb(data);
|
||||
ipcRenderer.on('scout:gmailOAuthCallback', handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('scout:gmailOAuthCallback', handler);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog — native file/folder picker
|
||||
// ---------------------------------------------------------------------------
|
||||
contextBridge.exposeInMainWorld('electronDialog', {
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:showOpenDialog', options),
|
||||
});
|
||||
|
||||
131
src/renderer/components/agents/ScoutRunLog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFormatPrefs, formatTs, formatDuration } from '@/lib/date';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types inferred from router return
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ScoutRunSummary = {
|
||||
id: string;
|
||||
scoutId: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'partial';
|
||||
startedAt: number;
|
||||
completedAt: number | null | undefined;
|
||||
actionCounts: { created: number; updated: number; deleted: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<CheckCircle2 className="size-3" /> Done
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1 shrink-0">
|
||||
<XCircle className="size-3" /> Failed
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 shrink-0">
|
||||
<Loader2 className="size-3 animate-spin" /> Running
|
||||
</Badge>
|
||||
);
|
||||
case 'partial':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-amber-600 shrink-0">
|
||||
<AlertCircle className="size-3" /> Partial
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline" className="shrink-0">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-run row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RunRow({ run }: { run: ScoutRunSummary }) {
|
||||
const prefs = useFormatPrefs();
|
||||
const duration = formatDuration(run.startedAt, run.completedAt);
|
||||
const totalActions = run.actionCounts.created + run.actionCounts.updated + run.actionCounts.deleted;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
{statusBadge(run.status)}
|
||||
|
||||
<span className="text-muted-foreground shrink-0">{formatTs(run.startedAt, prefs)}</span>
|
||||
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
|
||||
<Clock className="size-3" />
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{totalActions} action{totalActions !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScoutRunLog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ScoutRunLog({ agentId, expanded }: { agentId: string; expanded: boolean }) {
|
||||
const runsQuery = trpc.scout.runs.useQuery(
|
||||
{ agentId, limit: 10 },
|
||||
{ enabled: expanded },
|
||||
);
|
||||
|
||||
if (!expanded) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Run History
|
||||
</p>
|
||||
|
||||
{runsQuery.isPending && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0, 1, 2].map(i => (
|
||||
<Skeleton key={i} className="h-9 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && (runsQuery.data ?? []).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && (runsQuery.data ?? []).length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{(runsQuery.data as ScoutRunSummary[]).map(run => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,35 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { LogIn, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X, Sparkles } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
|
||||
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useTaskBriefing } from '@/context/TaskBriefingContext';
|
||||
import { TaskBriefingOverlay } from '@/components/brief/TaskBriefingOverlay';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||
import { ChatSurface, MessageContent, ChatMarkdown } from './ChatSurface';
|
||||
|
||||
/** Fluid font size for chat messages — scales with viewport width */
|
||||
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
|
||||
// const CHAT_FONT = '1rem';
|
||||
|
||||
|
||||
const SUGGESTION_CHIPS = [
|
||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||
{ icon: TrendingUp, label: 'Summarize this week' },
|
||||
{ icon: AlertCircle, label: 'Any overdue tasks?' },
|
||||
{ icon: Lightbulb, label: 'Suggest next actions' },
|
||||
{ icon: ListTodo, labelKey: 'home.chipWhatsOnMyPlate' },
|
||||
{ icon: TrendingUp, labelKey: 'home.chipSummarizeWeek' },
|
||||
{ icon: AlertCircle, labelKey: 'home.chipOverdueTasks' },
|
||||
{ icon: Lightbulb, labelKey: 'home.chipSuggestActions' },
|
||||
] as const;
|
||||
|
||||
function getTimeGreeting(): string {
|
||||
function getTimeGreeting(t: (key: string) => string): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning,';
|
||||
if (hour < 17) return 'Good afternoon,';
|
||||
return 'Good evening,';
|
||||
if (hour < 12) return t('home.goodMorning');
|
||||
if (hour < 17) return t('home.goodAfternoon');
|
||||
return t('home.goodEvening');
|
||||
}
|
||||
|
||||
/* Entrance animation: staggered fade-up */
|
||||
@@ -42,66 +47,205 @@ const fadeUp = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Module-level brief state — survives component remounts (navigation away and back).
|
||||
* `content` is the text to display immediately on remount without waiting for the query.
|
||||
* `streamFired` prevents re-streaming across remounts.
|
||||
*/
|
||||
const briefModule = {
|
||||
content: null as string | null,
|
||||
streamFired: false,
|
||||
};
|
||||
|
||||
/** Cached AI response minHeight — survives component remounts (navigation). */
|
||||
let aiMinHeightCache: number | null = null;
|
||||
|
||||
interface AIChatPanelProps {
|
||||
onOpenSettings?: () => void;
|
||||
isHomePage?: boolean;
|
||||
actionsRef?: React.MutableRefObject<{ clear: () => void } | null>;
|
||||
onHasMessagesChange?: (has: boolean) => void;
|
||||
}
|
||||
|
||||
export function AIChatPanel({
|
||||
onOpenSettings,
|
||||
isHomePage,
|
||||
actionsRef,
|
||||
onHasMessagesChange,
|
||||
}: AIChatPanelProps) {
|
||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||
const { t } = useTranslation();
|
||||
const utils = trpc.useUtils();
|
||||
const taskBriefing = useTaskBriefing();
|
||||
const authStatusQuery = trpc.auth.status.useQuery();
|
||||
|
||||
// Home-specific queries
|
||||
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
|
||||
const profile = authStatusQuery.data?.profile;
|
||||
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Home chat SQLite persistence (M2.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
const HOME_SESSION_KEY = 'chat.home.lastSessionId';
|
||||
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem(HOME_SESSION_KEY) : null,
|
||||
);
|
||||
const createSession = trpc.aiChat.createSession.useMutation();
|
||||
const appendMessage = trpc.aiChat.appendMessage.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!homeSessionId) {
|
||||
const { id } = await createSession.mutateAsync({ channel: 'home' });
|
||||
if (cancelled) return;
|
||||
window.localStorage.setItem(HOME_SESSION_KEY, id);
|
||||
setHomeSessionId(id);
|
||||
} else {
|
||||
// Verify the session still exists. If row is missing (e.g. user
|
||||
// deleted the db file), recreate.
|
||||
const res = await utils.aiChat.getSession.fetch({ id: homeSessionId });
|
||||
if (cancelled) return;
|
||||
if (!res) {
|
||||
const { id } = await createSession.mutateAsync({ channel: 'home' });
|
||||
if (cancelled) return;
|
||||
window.localStorage.setItem(HOME_SESSION_KEY, id);
|
||||
setHomeSessionId(id);
|
||||
}
|
||||
// Note: hydrating past messages into useAIChat's in-memory cache
|
||||
// is deferred to a follow-up task. Current behavior matches
|
||||
// the previous in-memory cache lifetime.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [homeSessionId]);
|
||||
|
||||
const chatContext = useMemo<UIChatContext>(
|
||||
() => ({ type: 'global' as const }),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
handleSend: chatHandleSend,
|
||||
clearMessages,
|
||||
cacheKey,
|
||||
} = useAIChat(chatContext);
|
||||
|
||||
// Daily brief state (home page only)
|
||||
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
|
||||
const [briefLoading, setBriefLoading] = useState(false);
|
||||
// Persist each new user/assistant message to aiChatMessages in SQLite.
|
||||
const persistedCountRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!homeSessionId) return;
|
||||
// Reset cursor when session changes or messages are cleared (new chat).
|
||||
if (persistedCountRef.current > messages.length) {
|
||||
persistedCountRef.current = 0;
|
||||
}
|
||||
const fresh = messages.slice(persistedCountRef.current);
|
||||
for (const m of fresh) {
|
||||
appendMessage.mutate({
|
||||
sessionId: homeSessionId,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
});
|
||||
}
|
||||
persistedCountRef.current = messages.length;
|
||||
// appendMessage is stable (useMutation ref), intentionally omitted from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages, homeSessionId]);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
// Notify parent when conversation active state changes
|
||||
useEffect(() => {
|
||||
onHasMessagesChange?.(hasMessages);
|
||||
}, [hasMessages, onHasMessagesChange]);
|
||||
|
||||
// Daily brief state — initialized from module-level cache so remounts are instant.
|
||||
const [dailyBrief, setDailyBrief] = useState<string | null>(() => briefModule.content);
|
||||
const briefContentRef = useRef('');
|
||||
const hasFiredBrief = useRef(false);
|
||||
|
||||
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// --- Scroll-to-user-message + shrinking placeholder ---
|
||||
// --- Scroll-to-user-message + AI response minHeight ---
|
||||
const chatInputWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<ChatInputBoxHandle>(null);
|
||||
const briefWrapper = useRef<HTMLDivElement | null>(null);
|
||||
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
|
||||
const [streamingEl, setStreamingEl] = useState<HTMLDivElement | null>(null);
|
||||
const [placeholderHeight, setPlaceholderHeight] = useState<number | null>(null);
|
||||
const initialPlaceholderRef = useRef(0);
|
||||
const lastAiRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [aiMinHeight, setAiMinHeight] = useState<number | null>(
|
||||
() => aiMinHeightCache,
|
||||
);
|
||||
|
||||
// Expose clear action to parent (AppShell) via ref — placed after setAiMinHeight is in scope
|
||||
if (actionsRef) {
|
||||
actionsRef.current = {
|
||||
clear: () => {
|
||||
inputRef.current?.clear();
|
||||
clearMessages();
|
||||
aiMinHeightCache = null;
|
||||
setAiMinHeight(null);
|
||||
// Create a new SQLite session for the next conversation.
|
||||
createSession.mutateAsync({ channel: 'home' }).then(({ id }) => {
|
||||
window.localStorage.setItem(HOME_SESSION_KEY, id);
|
||||
setHomeSessionId(id);
|
||||
}).catch(() => {
|
||||
// Non-fatal: next message will still attempt session verification.
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pendingScrollRef = useRef(false);
|
||||
|
||||
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||
// Stable layout values — recomputed only on mount and window resize.
|
||||
const stableLayout = useRef({ messageGap: 0 });
|
||||
useEffect(() => {
|
||||
function measureStable() {
|
||||
const rootStyles = window.getComputedStyle(document.documentElement);
|
||||
const spacingNumber = parseFloat(rootStyles.getPropertyValue('--spacing').trim()) || 0;
|
||||
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
|
||||
stableLayout.current = {
|
||||
messageGap: spacingNumber * 8 * rootFontSize,
|
||||
};
|
||||
}
|
||||
measureStable();
|
||||
window.addEventListener('resize', measureStable);
|
||||
return () => window.removeEventListener('resize', measureStable);
|
||||
}, []);
|
||||
|
||||
// When the user message appears in the list, set the placeholder and scroll it to the top
|
||||
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||
const briefLoading = briefMutation.isPending;
|
||||
|
||||
// Fetch cached brief from main process — null means stale/missing (needs regen).
|
||||
const cachedBriefQuery = trpc.ai.getBrief.useQuery(undefined, {
|
||||
enabled: !!isHomePage && !!authStatusQuery.data?.authenticated,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// When user sends a message: compute how much vertical space remains below the
|
||||
// user bubble → that becomes the minHeight of the AI response div. Then scroll.
|
||||
useEffect(() => {
|
||||
if (!pendingScrollRef.current) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (!lastMsg || lastMsg.role !== 'user') return;
|
||||
|
||||
pendingScrollRef.current = false;
|
||||
const ph = Math.round(window.innerHeight * 0.71);
|
||||
initialPlaceholderRef.current = ph;
|
||||
setPlaceholderHeight(ph);
|
||||
|
||||
// Double-rAF: wait for the placeholder div to actually paint before scrolling
|
||||
const { messageGap } = stableLayout.current;
|
||||
const vhw = window.innerHeight;
|
||||
const briefHeight = briefWrapper.current?.getBoundingClientRect().height || 0;
|
||||
const userMsgHeight = lastUserMsgRef.current?.getBoundingClientRect().height || 0;
|
||||
|
||||
const minH = Math.max(0, Math.round(vhw - (briefHeight + userMsgHeight + messageGap)));
|
||||
|
||||
aiMinHeightCache = minH;
|
||||
setAiMinHeight(minH);
|
||||
|
||||
// Auto-scroll — the only auto-scroll point in the entire flow
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
lastUserMsgRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
@@ -109,140 +253,167 @@ export function AIChatPanel({
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// Shrink placeholder in real-time as AI streaming content grows
|
||||
// Sync query result into module cache so remounts get it instantly.
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !streamingEl) return;
|
||||
const MIN_PADDING = 80;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const contentHeight = streamingEl.getBoundingClientRect().height;
|
||||
setPlaceholderHeight(Math.max(MIN_PADDING, initialPlaceholderRef.current - contentHeight));
|
||||
const cached = cachedBriefQuery.data;
|
||||
if (!cached) return;
|
||||
briefModule.content = cached;
|
||||
setDailyBrief(cached);
|
||||
}, [cachedBriefQuery.data]);
|
||||
|
||||
// Listen for background brief refreshes pushed by the main process.
|
||||
useEffect(() => {
|
||||
if (!isHomePage) return;
|
||||
|
||||
const unsubscribe = window.electronAI.onBriefUpdated((content) => {
|
||||
briefModule.content = content;
|
||||
briefModule.streamFired = true;
|
||||
setDailyBrief(content);
|
||||
void utils.ai.getBrief.invalidate();
|
||||
});
|
||||
observer.observe(streamingEl);
|
||||
return () => observer.disconnect();
|
||||
}, [isStreaming, streamingEl]);
|
||||
|
||||
// Auto-fire daily brief on home page
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [isHomePage, utils]);
|
||||
|
||||
// Stream a fresh brief when cache is empty (once per app session).
|
||||
useEffect(() => {
|
||||
if (!isHomePage || hasFiredBrief.current || hasTokenQuery.data !== true) return;
|
||||
hasFiredBrief.current = true;
|
||||
setBriefLoading(true);
|
||||
if (!isHomePage || !authStatusQuery.data?.authenticated) return;
|
||||
// Already have content (from module cache or query) — nothing to do.
|
||||
if (briefModule.content) return;
|
||||
// Wait for query to finish before deciding whether to stream.
|
||||
if (cachedBriefQuery.isLoading) return;
|
||||
// Query returned content — handled by the sync effect above.
|
||||
if (cachedBriefQuery.data) return;
|
||||
// Guard against firing twice.
|
||||
if (briefModule.streamFired) return;
|
||||
briefModule.streamFired = true;
|
||||
briefContentRef.current = '';
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||
if (done) {
|
||||
let unsubscribe: (() => void) | null = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
|
||||
if (event.type === 'stream_text') {
|
||||
briefContentRef.current += event.chunk;
|
||||
briefModule.content = briefContentRef.current;
|
||||
setDailyBrief(briefContentRef.current);
|
||||
setBriefLoading(false);
|
||||
unsubscribe();
|
||||
return;
|
||||
} else if (event.type === 'stream_end') {
|
||||
const final = briefContentRef.current || null;
|
||||
briefModule.content = final;
|
||||
setDailyBrief(final);
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
// Invalidate so the persisted cache is loaded on next app launch / reload.
|
||||
void utils.ai.getBrief.invalidate();
|
||||
}
|
||||
briefContentRef.current += token;
|
||||
setDailyBrief(briefContentRef.current);
|
||||
});
|
||||
|
||||
briefMutation.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
unsubscribe();
|
||||
setDailyBrief(null);
|
||||
setBriefLoading(false);
|
||||
}
|
||||
},
|
||||
briefMutation.mutate({ requestId }, {
|
||||
onError: () => {
|
||||
unsubscribe();
|
||||
setDailyBrief(null);
|
||||
setBriefLoading(false);
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
briefModule.streamFired = false; // allow retry on error
|
||||
},
|
||||
});
|
||||
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (briefLoading) return;
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isHomePage, authStatusQuery.data?.authenticated, cachedBriefQuery.isLoading, cachedBriefQuery.data]);
|
||||
|
||||
const handleSend = useCallback((message: string) => {
|
||||
pendingScrollRef.current = true;
|
||||
chatHandleSend();
|
||||
}, [briefLoading, chatHandleSend]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
chatHandleSend(message);
|
||||
}, [chatHandleSend]);
|
||||
|
||||
// Derived values for home page
|
||||
const dueCount = dueTodayQuery.data?.length ?? 0;
|
||||
const userName = userNameQuery.data ?? 'there';
|
||||
|
||||
// When briefing is open on home page, show inline briefing section
|
||||
if (isHomePage && taskBriefing.isOpen) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<TaskBriefingOverlay key="briefing" />
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||
{/* Sticky brief toast — anchored at top when chatting */}
|
||||
<AnimatePresence>
|
||||
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
|
||||
<motion.div
|
||||
initial={{ y: -80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -80, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
|
||||
{/* Toast header — always visible */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<Sparkles size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setBriefExpanded((v) => !v)}
|
||||
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBriefDismissed(true)}
|
||||
aria-label="Dismiss brief"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Collapsed: one-line preview */}
|
||||
{!briefExpanded && (
|
||||
<div className="px-4 pb-3 -mt-1">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Expanded: full brief content */}
|
||||
<AnimatePresence>
|
||||
{briefExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
|
||||
<ChatMarkdown content={dailyBrief} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute inset-0 z-0 flex flex-col">
|
||||
{/* Scrollable messages area */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{/* Brief toast — floats over the scroll area so messages scroll underneath */}
|
||||
<AnimatePresence>
|
||||
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
|
||||
<motion.div
|
||||
ref={briefWrapper}
|
||||
initial={{ y: -80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -80, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
className="absolute top-0 left-0 right-0 z-30 flex justify-center px-4 pt-3 pb-1"
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/60 backdrop-blur-md shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
|
||||
{/* Toast header — always visible */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<Sparkles size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold tracking-wide text-foreground">{t('home.dailyBrief')}</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setBriefExpanded((v) => !v)}
|
||||
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBriefDismissed(true)}
|
||||
aria-label="Dismiss brief"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Collapsed: one-line preview */}
|
||||
{!briefExpanded && (
|
||||
<div className="px-4 pb-3 -mt-1">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Expanded: full brief content */}
|
||||
<AnimatePresence>
|
||||
{briefExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
|
||||
<MessageContent content={dailyBrief} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Gradual blur at the bottom of messages */}
|
||||
{hasMessages && (
|
||||
<GradualBlur
|
||||
position="bottom"
|
||||
strength={0.6}
|
||||
height="4rem"
|
||||
height="4.5rem"
|
||||
divCount={10}
|
||||
curve="ease-out"
|
||||
opacity={0.8}
|
||||
@@ -251,18 +422,17 @@ export function AIChatPanel({
|
||||
)}
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
viewportRef={messagesContainerRef}
|
||||
scrollbarClassName={hasMessages ? 'z-30' : undefined}
|
||||
viewportClassName={
|
||||
isHomePage && !hasMessages
|
||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-start'
|
||||
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
||||
}
|
||||
>
|
||||
{/* Home page initial state: greeting + brief */}
|
||||
{isHomePage && !hasMessages && (
|
||||
<motion.div
|
||||
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
|
||||
className="mx-auto w-full max-w-4xl px-8 pt-35 pb-8"
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
@@ -274,13 +444,13 @@ export function AIChatPanel({
|
||||
className="font-light tracking-wide text-muted-foreground"
|
||||
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
|
||||
>
|
||||
{getTimeGreeting()}
|
||||
{getTimeGreeting(t)}
|
||||
</span>
|
||||
<h1
|
||||
className="font-bold leading-[1.05]"
|
||||
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
|
||||
>
|
||||
{userName}
|
||||
{profile?.name}
|
||||
<span className="text-primary ml-3 inline-block">✦</span>
|
||||
</h1>
|
||||
{dueCount > 0 && (
|
||||
@@ -288,22 +458,21 @@ export function AIChatPanel({
|
||||
className="text-muted-foreground mt-2"
|
||||
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
|
||||
>
|
||||
<span className="text-foreground font-medium">{dueCount}</span>
|
||||
{' '}task{dueCount !== 1 ? 's' : ''} due today
|
||||
{t('home.tasksDueToday', { count: dueCount })}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Daily brief */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
{hasTokenQuery.data === false ? (
|
||||
{authStatusQuery.data?.authenticated === false ? (
|
||||
<div className="flex flex-col items-start gap-3 py-2">
|
||||
<KeyRound size={20} className="text-muted-foreground" />
|
||||
<LogIn size={20} className="text-muted-foreground" />
|
||||
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||
Configure your AI provider in Settings to enable the daily brief.
|
||||
{t('home.loginPrompt')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
Open Settings
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">{t('home.openSettings')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : briefLoading && !dailyBrief ? (
|
||||
@@ -313,123 +482,69 @@ export function AIChatPanel({
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
) : dailyBrief ? (
|
||||
<ChatMarkdown content={dailyBrief} size="lg" />
|
||||
) : (
|
||||
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||
Your daily brief will appear here.
|
||||
</p>
|
||||
<MessageContent content={dailyBrief} />
|
||||
) : null}
|
||||
{authStatusQuery.data?.authenticated !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => taskBriefing.open()}
|
||||
className="mt-3 text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
{t('home.openTaskBriefing')}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Input + suggestion links */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
ref={inputRef}
|
||||
cacheKey={cacheKey}
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
placeholder={t('home.askAnything')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 mt-5">
|
||||
{SUGGESTION_CHIPS.map((chip) => (
|
||||
{SUGGESTION_CHIPS.map((chip) => {
|
||||
const label = t(chip.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={chip.label}
|
||||
key={chip.labelKey}
|
||||
type="button"
|
||||
className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
|
||||
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
|
||||
onClick={() => setInput(chip.label)}
|
||||
onClick={() => { inputRef.current?.setValue(label); inputRef.current?.focus(); }}
|
||||
>
|
||||
<chip.icon
|
||||
size={16}
|
||||
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
<span>{chip.label}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Home page with messages: brief stays, then messages */}
|
||||
{/* Home page with messages: brief stays, then messages via ChatSurface */}
|
||||
{isHomePage && hasMessages && (
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Chat messages */}
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastMsg = idx === messages.length - 1;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
ref={isLastMsg ? lastUserMsgRef : undefined}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
<div className="pl-[22px]">
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming AI response */}
|
||||
{isStreaming && (
|
||||
<div ref={setStreamingEl} className="mr-auto max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
{streamingContent ? (
|
||||
<div className="pl-[22px]">
|
||||
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pl-[22px]">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder: fills viewport after user message, shrinks as AI responds */}
|
||||
{placeholderHeight !== null && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
height: placeholderHeight,
|
||||
transition: 'height 180ms ease-out',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChatSurface
|
||||
variant="home"
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
cacheKey={cacheKey}
|
||||
aiMinHeight={aiMinHeight}
|
||||
lastUserMsgRef={lastUserMsgRef}
|
||||
lastAiRef={lastAiRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Non-home messages */}
|
||||
@@ -438,15 +553,17 @@ export function AIChatPanel({
|
||||
|
||||
{/* Fixed input — pinned to the bottom, above the blur */}
|
||||
{hasMessages && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
|
||||
<div ref={chatInputWrapperRef} className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
|
||||
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
ref={inputRef}
|
||||
cacheKey={cacheKey}
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
placeholder={t('home.askAnything')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -454,79 +571,6 @@ export function AIChatPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ChatInput: Floating glass card ---------- */
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
}
|
||||
|
||||
function ChatInput({
|
||||
input,
|
||||
isStreaming,
|
||||
onInputChange,
|
||||
onKeyDown,
|
||||
onSend,
|
||||
}: ChatInputProps) {
|
||||
return (
|
||||
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask me anything..."
|
||||
aria-label="Chat message"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
aria-label="Send message"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||
|
||||
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
|
||||
style={fontSize ? { fontSize } : undefined}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
if (!className) {
|
||||
return (
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Re-export shared rendering utilities for consumers that previously imported
|
||||
// them directly from AIChatPanel.
|
||||
export { ChatMarkdown } from './ChatSurface';
|
||||
|
||||
17
src/renderer/components/ai/AdiuvaIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface AdiuvaIconProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function AdiuvaIcon({ size = 24 }: AdiuvaIconProps) {
|
||||
return (
|
||||
<img
|
||||
src="/logo/logo-mark.svg"
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="adiuva-mark-img select-none pointer-events-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/renderer/components/ai/AdiuvaTriggerButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContextualChat } from '@/context/ContextualChatContext';
|
||||
import { AdiuvaIcon } from './AdiuvaIcon';
|
||||
|
||||
export function AdiuvaTriggerButton() {
|
||||
const { toggle, open } = useContextualChat();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title="Ask adiuvAI"
|
||||
aria-pressed={open}
|
||||
className="adiuva-btn sm"
|
||||
>
|
||||
<AdiuvaIcon size={24} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
137
src/renderer/components/ai/ChatInputBox.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { readInputDraft, writeInputDraft } from '@/hooks/useAIChat';
|
||||
|
||||
export interface ChatInputBoxHandle {
|
||||
getValue: () => string;
|
||||
setValue: (v: string) => void;
|
||||
clear: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
type ChatInputBoxVariant = 'panel' | 'comment';
|
||||
|
||||
interface ChatInputBoxProps {
|
||||
cacheKey: string;
|
||||
isStreaming: boolean;
|
||||
onSend: (message: string) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
variant?: ChatInputBoxVariant;
|
||||
}
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
panel: {
|
||||
container: 'flex items-center gap-2 px-4 py-2.5',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto',
|
||||
button: 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||
iconSize: 16,
|
||||
},
|
||||
comment: {
|
||||
container: 'flex items-center gap-2 px-3 py-2',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
|
||||
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
|
||||
iconSize: 14,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
|
||||
({ cacheKey, isStreaming, onSend, placeholder, autoFocus, variant = 'panel' }, ref) => {
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const [value, setValue] = useState(() => readInputDraft(cacheKey));
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Re-init when the cache key changes (context switches).
|
||||
const prevKeyRef = useRef(cacheKey);
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== cacheKey) {
|
||||
prevKeyRef.current = cacheKey;
|
||||
setValue(readInputDraft(cacheKey));
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
// Debounced draft persistence — fires 250 ms after the last keystroke.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => writeInputDraft(cacheKey, value), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [cacheKey, value]);
|
||||
|
||||
// Flush on unmount so a fast close/reopen preserves the current draft.
|
||||
useEffect(() => {
|
||||
return () => writeInputDraft(cacheKey, valueRef.current);
|
||||
}, [cacheKey]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue: () => valueRef.current,
|
||||
setValue: (v: string) => {
|
||||
setValue(v);
|
||||
// Move caret to end + focus after React commits the new value.
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(v.length, v.length);
|
||||
}
|
||||
});
|
||||
},
|
||||
clear: () => setValue(''),
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
}));
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Guard IME composition — prevents spurious submit during Italian dead-key
|
||||
// input (e.g. ` + e → è) and CJK composition sequences.
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isStreaming) return;
|
||||
const v = valueRef.current.trim();
|
||||
if (!v) return;
|
||||
setValue('');
|
||||
writeInputDraft(cacheKey, '');
|
||||
onSend(v);
|
||||
}
|
||||
},
|
||||
[isStreaming, onSend, cacheKey],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isStreaming) return;
|
||||
const v = valueRef.current.trim();
|
||||
if (!v) return;
|
||||
setValue('');
|
||||
writeInputDraft(cacheKey, '');
|
||||
onSend(v);
|
||||
}, [isStreaming, onSend, cacheKey]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Chat message"
|
||||
rows={1}
|
||||
autoFocus={autoFocus}
|
||||
className={styles.textarea}
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={!value.trim() || isStreaming}
|
||||
aria-label="Send message"
|
||||
className={styles.button}
|
||||
>
|
||||
<ArrowUp size={styles.iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChatInputBox.displayName = 'ChatInputBox';
|
||||
502
src/renderer/components/ai/ChatSurface.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* ChatSurface — pure presentational chat surface.
|
||||
*
|
||||
* Contains:
|
||||
* - Message list rendering (user bubbles, AI messages with Sparkles header,
|
||||
* inline entity/chart block parsing, error styling)
|
||||
* - Streaming content placeholder
|
||||
* - Scroll management
|
||||
* - ChatInputBox wrapper
|
||||
*
|
||||
* Also exports shared rendering utilities (ChatMarkdown, MessageContent,
|
||||
* AIMessage) so AIChatPanel can import them here and avoid circular deps.
|
||||
*/
|
||||
|
||||
import {
|
||||
memo,
|
||||
forwardRef,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
|
||||
import { ChatChartBlock } from './blocks/ChatChartBlock';
|
||||
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
|
||||
import type { ChatMessage } from '@/hooks/useAIChat';
|
||||
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline tag parsing (mirrors AIChatPanel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENTITY_TAG_RE =
|
||||
/<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
|
||||
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
|
||||
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
|
||||
|
||||
type ContentSegment =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
|
||||
| { type: 'chart'; data: ChartBlockData };
|
||||
|
||||
function parseInlineTags(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
let remaining = content;
|
||||
|
||||
while (remaining) {
|
||||
const match = INLINE_TAG_RE.exec(remaining);
|
||||
if (!match) {
|
||||
segments.push({ type: 'text', content: remaining });
|
||||
break;
|
||||
}
|
||||
|
||||
const before = remaining.slice(0, match.index);
|
||||
if (before) segments.push({ type: 'text', content: before });
|
||||
|
||||
const groups = match.groups ?? {};
|
||||
|
||||
if (groups.entity) {
|
||||
const entity = groups.entity as EntityRefBlockData['entity'];
|
||||
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
|
||||
const ids = rawIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
segments.push({ type: 'entity', entity, ids });
|
||||
} else if (groups.chartJson) {
|
||||
try {
|
||||
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
|
||||
segments.push({ type: 'chart', data: chartData });
|
||||
} catch {
|
||||
segments.push({ type: 'text', content: match[0] });
|
||||
}
|
||||
}
|
||||
|
||||
const matchIndex = typeof match.index === 'number' ? match.index : 0;
|
||||
remaining = remaining.slice(matchIndex + match[0].length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function hasInlineTags(content: string): boolean {
|
||||
return INLINE_TAG_RE.test(content);
|
||||
}
|
||||
|
||||
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
|
||||
const allTimelineIds: string[] = [];
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
allTimelineIds.push(...seg.ids);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTimelineIds = [...new Set(allTimelineIds)];
|
||||
if (!uniqueTimelineIds.length) return segments;
|
||||
|
||||
const merged: ContentSegment[] = [];
|
||||
let lastTimelineInsertIndex = 0;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
lastTimelineInsertIndex = merged.length;
|
||||
continue;
|
||||
}
|
||||
merged.push(seg);
|
||||
}
|
||||
|
||||
merged.splice(lastTimelineInsertIndex, 0, {
|
||||
type: 'entity',
|
||||
entity: 'timeline',
|
||||
ids: uniqueTimelineIds,
|
||||
});
|
||||
|
||||
return merged.filter((seg) => !(seg.type === 'text' && !seg.content.trim()));
|
||||
}
|
||||
|
||||
function mergeConsecutiveTaskSegments(segments: ContentSegment[]): ContentSegment[] {
|
||||
const merged: ContentSegment[] = [];
|
||||
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const current = segments[i];
|
||||
if (!current) continue;
|
||||
|
||||
if (!(current?.type === 'entity' && current.entity === 'task')) {
|
||||
merged.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupedIds: string[] = [...current.ids];
|
||||
let j = i + 1;
|
||||
|
||||
while (j < segments.length) {
|
||||
const next = segments[j];
|
||||
if (next?.type === 'text' && !next.content.trim()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (next?.type === 'entity' && next.entity === 'task') {
|
||||
groupedIds.push(...next.ids);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
merged.push({
|
||||
type: 'entity',
|
||||
entity: 'task',
|
||||
ids: [...new Set(groupedIds)],
|
||||
});
|
||||
|
||||
i = j - 1;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatMarkdown — lightweight markdown renderer with GFM + styled code blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REMARK_PLUGINS: Parameters<typeof ReactMarkdown>[0]['remarkPlugins'] = [remarkGfm];
|
||||
const MARKDOWN_COMPONENTS: Parameters<typeof ReactMarkdown>[0]['components'] = {
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">{children}</pre>
|
||||
),
|
||||
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
|
||||
if (!className) {
|
||||
return (
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">{children}</code>
|
||||
);
|
||||
}
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
};
|
||||
|
||||
export function ChatMarkdown({
|
||||
content,
|
||||
size = 'sm',
|
||||
fontSize,
|
||||
}: {
|
||||
content: string;
|
||||
size?: 'sm' | 'lg';
|
||||
fontSize?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
|
||||
style={fontSize ? { fontSize } : undefined}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageContent — text with inline entity + chart blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const blockAnimation = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
|
||||
};
|
||||
|
||||
export const MessageContent = memo(function MessageContent({
|
||||
content,
|
||||
fontSize,
|
||||
}: {
|
||||
content: string;
|
||||
fontSize?: string;
|
||||
}) {
|
||||
const segments = useMemo(
|
||||
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
|
||||
[content],
|
||||
);
|
||||
|
||||
if (segments.length === 1 && segments[0]?.type === 'text') {
|
||||
return <ChatMarkdown content={content} fontSize={fontSize} />;
|
||||
}
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
|
||||
}
|
||||
if (seg.type === 'chart') {
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatChartBlock data={seg.data} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AIMessage — shared layout for completed + streaming AI turns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AIMessageProps {
|
||||
content: string;
|
||||
bottomPad?: boolean;
|
||||
minHeight?: number | null;
|
||||
skeleton?: boolean;
|
||||
}
|
||||
|
||||
export const AIMessage = memo(
|
||||
forwardRef<HTMLDivElement, AIMessageProps>(({ content, bottomPad, minHeight, skeleton }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`mr-auto ${hasInlineTags(content) ? 'w-full' : 'max-w-[75%]'}`}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
{skeleton ? (
|
||||
<div className="space-y-2 pl-[32px] pb-40">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`pl-[32px] flex flex-col gap-3${bottomPad ? ' pb-40' : ''}`}>
|
||||
<MessageContent content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
);
|
||||
AIMessage.displayName = 'AIMessage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatSurface props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChatSurfaceProps {
|
||||
messages: ChatMessage[];
|
||||
streamingContent: string;
|
||||
isStreaming: boolean;
|
||||
onSend: (text: string) => void;
|
||||
cacheKey: string;
|
||||
variant: 'home' | 'contextual';
|
||||
/** Slot rendered just above the input area (e.g. suggestion chips). */
|
||||
aboveInputSlot?: React.ReactNode;
|
||||
/** Extra bottom padding for the message list (default 120px). */
|
||||
bottomPadPx?: number;
|
||||
/** Ref forwarded to the ChatInputBox for imperative control. */
|
||||
inputRef?: React.Ref<ChatInputBoxHandle>;
|
||||
/** minHeight applied to the last AI message (home page scroll behaviour). */
|
||||
aiMinHeight?: number | null;
|
||||
/** Ref set on the last user message div for scroll targeting. */
|
||||
lastUserMsgRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Ref set on the last AI message div. */
|
||||
lastAiRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Additional class names for the scroll area viewport. */
|
||||
viewportClassName?: string;
|
||||
/** Whether the scroll area has messages (controls scrollbar z-index). */
|
||||
hasMessages?: boolean;
|
||||
/** i18n placeholder for the input field. */
|
||||
placeholder?: string;
|
||||
/** Hint shown when messages are empty and not streaming. Used by the contextual variant. */
|
||||
emptyStateCopy?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatSurface component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ChatSurface = memo(function ChatSurface({
|
||||
messages,
|
||||
streamingContent,
|
||||
isStreaming,
|
||||
onSend,
|
||||
cacheKey,
|
||||
variant,
|
||||
aboveInputSlot,
|
||||
inputRef,
|
||||
aiMinHeight,
|
||||
lastUserMsgRef,
|
||||
lastAiRef,
|
||||
viewportClassName,
|
||||
hasMessages,
|
||||
placeholder,
|
||||
emptyStateCopy,
|
||||
}: ChatSurfaceProps) {
|
||||
// Internal scroll ref — used only in contextual variant where we don't have
|
||||
// the parent-managed scroll refs from the home path.
|
||||
const internalScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'contextual') return;
|
||||
internalScrollRef.current?.scrollTo({
|
||||
top: internalScrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [messages.length, streamingContent, variant]);
|
||||
|
||||
if (variant === 'home') {
|
||||
// Home variant: delegates scroll management entirely to the parent
|
||||
// (AIChatPanel owns ScrollArea + scroll-to-user-message logic).
|
||||
// Renders only the message list rows + fixed input footer.
|
||||
return (
|
||||
<>
|
||||
{/* Message list — rendered inside parent's ScrollArea */}
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pt-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div aria-hidden style={{ height: '4vw', flexShrink: 0 }} />
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const isLast = idx === messages.length - 1;
|
||||
const isLastUser = isLast && msg.role === 'user';
|
||||
const isLastAssistant = isLast && msg.role === 'assistant' && !isStreaming;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
ref={isLastUser ? lastUserMsgRef : undefined}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIMessage
|
||||
key={msg.id}
|
||||
ref={isLastAssistant ? lastAiRef : undefined}
|
||||
content={msg.content}
|
||||
bottomPad={isLastAssistant}
|
||||
minHeight={isLastAssistant ? aiMinHeight : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreaming && (
|
||||
<AIMessage
|
||||
ref={lastAiRef}
|
||||
content={streamingContent}
|
||||
bottomPad
|
||||
minHeight={aiMinHeight}
|
||||
skeleton={!streamingContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Above-input slot (suggestion chips, etc.) rendered by parent */}
|
||||
{aboveInputSlot}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual variant — self-contained scroll + absolute-positioned input
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
scrollbarClassName={hasMessages ? 'z-30' : undefined}
|
||||
viewportClassName={viewportClassName}
|
||||
>
|
||||
<div
|
||||
ref={internalScrollRef}
|
||||
className="flex flex-col gap-4 px-4"
|
||||
style={{ paddingBottom: 120, paddingTop: 64 }}
|
||||
>
|
||||
{messages.length === 0 && !isStreaming && variant === 'contextual' && emptyStateCopy && (
|
||||
<div className="text-center text-xs text-muted-foreground py-12 px-6 leading-relaxed">
|
||||
{emptyStateCopy}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIMessage key={msg.id} content={msg.content} />;
|
||||
})}
|
||||
|
||||
{isStreaming && (
|
||||
<AIMessage
|
||||
content={streamingContent}
|
||||
skeleton={!streamingContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{aboveInputSlot}
|
||||
|
||||
{/* Absolute-positioned input with gradient fade */}
|
||||
<div className="absolute inset-x-0 bottom-0 px-4 pb-3 pointer-events-none">
|
||||
<div
|
||||
className="h-16 -mx-4 -mt-16 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent 0%, color-mix(in srgb, var(--background) 90%, transparent) 60%, var(--background) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-auto relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
ref={inputRef}
|
||||
onSend={onSend}
|
||||
isStreaming={isStreaming}
|
||||
cacheKey={cacheKey}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
85
src/renderer/components/ai/ContextualSidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SquarePen } from 'lucide-react';
|
||||
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
|
||||
import { ChatSurface } from './ChatSurface';
|
||||
|
||||
function scopeLabel(scope: ContextualScope | null): string | null {
|
||||
if (!scope) return null;
|
||||
switch (scope.page) {
|
||||
case 'timeline':
|
||||
return 'Timeline';
|
||||
case 'tasks':
|
||||
return 'Tasks';
|
||||
case 'projects-list':
|
||||
return 'Projects';
|
||||
case 'project':
|
||||
return scope.entityName ? `Project · ${scope.entityName}` : 'Project';
|
||||
case 'note':
|
||||
return scope.entityName ? `Note · ${scope.entityName}` : 'Note';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextualSidebar() {
|
||||
const { messages, isStreaming, streamingContent, send, newChat, sessionId, scope } =
|
||||
useContextualChat();
|
||||
const label = scopeLabel(scope);
|
||||
|
||||
const emptyStateCopy = useMemo(() => {
|
||||
if (!scope) return null;
|
||||
switch (scope.page) {
|
||||
case 'tasks':
|
||||
return 'Ask anything about your tasks — or "create a task for…"';
|
||||
case 'projects-list':
|
||||
return 'Ask about your projects, or kick off a new one.';
|
||||
case 'timeline':
|
||||
return "Ask about milestones, what's coming up, or what's overdue.";
|
||||
case 'project':
|
||||
return scope.entityName
|
||||
? `Ask anything about ${scope.entityName} — recap, tasks, status.`
|
||||
: null;
|
||||
case 'note':
|
||||
return scope.entityName
|
||||
? `Ask about ${scope.entityName}. (Note editing comes in a later release.)`
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [scope]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-transparent">
|
||||
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void newChat();
|
||||
}}
|
||||
aria-label="New conversation"
|
||||
title="New chat"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-sm bg-background/60 text-muted-foreground backdrop-blur-md transition-colors hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<SquarePen size={14} />
|
||||
</button>
|
||||
{label && (
|
||||
<div
|
||||
className="inline-flex h-6 items-center rounded-sm bg-background/60 px-2 text-[11px] font-medium text-muted-foreground backdrop-blur-md"
|
||||
title={`Current context: ${label}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatSurface
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
onSend={send}
|
||||
cacheKey={`contextual:${sessionId ?? 'none'}`}
|
||||
variant="contextual"
|
||||
emptyStateCopy={emptyStateCopy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { X, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
useFloatingChat,
|
||||
computeDualAnchor,
|
||||
getChatWidth,
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
/** Map section IDs to their routes for cross-page navigation */
|
||||
const SECTION_ROUTES: Record<string, string> = {
|
||||
'project-summary': 'project',
|
||||
'project-timeline': 'project',
|
||||
'project-tasks': 'project',
|
||||
'project-notes': 'project',
|
||||
'tasks-overview': '/tasks',
|
||||
'tasks-list': '/tasks',
|
||||
'timeline-chart': '/timeline',
|
||||
'note-editor': 'note',
|
||||
};
|
||||
|
||||
function FloatingChatInner() {
|
||||
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
|
||||
const utils = trpc.useUtils();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const prevPathRef = useRef(routerState.location.pathname);
|
||||
|
||||
// Active section lookup
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
|
||||
// Chat context derived from active section
|
||||
const chatContext = useMemo<UIChatContext>(
|
||||
() => ({
|
||||
type: activeSection?.projectId ? 'project' : 'global',
|
||||
projectId: activeSection?.projectId,
|
||||
uiContext: activeSection?.label,
|
||||
}),
|
||||
[activeSection?.projectId, activeSection?.label],
|
||||
);
|
||||
|
||||
// Handle [SECTION:xxx] tags from AI responses
|
||||
const handleSectionTag = useCallback((sectionId: string) => {
|
||||
// Same-page: section is already registered
|
||||
const targetSection = sections.get(sectionId);
|
||||
if (targetSection) {
|
||||
moveToSection(sectionId);
|
||||
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-page: section not registered, navigate to its route
|
||||
const route = SECTION_ROUTES[sectionId];
|
||||
if (!route) return;
|
||||
|
||||
setPendingSection({ sectionId });
|
||||
|
||||
if (route === 'project' && state.projectId) {
|
||||
// Navigate to the project page (stay on same project)
|
||||
// Project sections re-register on mount and pendingSection will auto-open
|
||||
void navigate({ to: '/projects', search: { projectId: state.projectId } });
|
||||
} else if (route.startsWith('/')) {
|
||||
void navigate({ to: route });
|
||||
}
|
||||
// 'note' type requires noteId — skip cross-page for now
|
||||
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---- Close on Escape ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [state.isOpen, close]);
|
||||
|
||||
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||
close();
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
|
||||
// ---- Clear messages on close ----
|
||||
|
||||
const prevOpenRef = useRef(state.isOpen);
|
||||
useEffect(() => {
|
||||
if (prevOpenRef.current && !state.isOpen) {
|
||||
clearMessages();
|
||||
}
|
||||
prevOpenRef.current = state.isOpen;
|
||||
}, [state.isOpen, clearMessages]);
|
||||
|
||||
// ---- AI action: morph into newly-created task ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
|
||||
const unsubscribe = window.electronAI.onAction((action) => {
|
||||
if (action.type === 'task_created' && action.taskId) {
|
||||
// Invalidate task queries so the new TaskRow renders
|
||||
void utils.tasks.list.invalidate();
|
||||
|
||||
// Set the morph target layoutId
|
||||
setMorphTarget(`task-morph-${action.taskId}`);
|
||||
|
||||
// Wait for the TaskRow to render, then close (triggering FLIP)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [state.isOpen, utils, setMorphTarget, close]);
|
||||
|
||||
// ---- Window resize: keep within bounds ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handler = () => {
|
||||
// Re-anchor if the container would go offscreen
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
|
||||
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
|
||||
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, [state.isOpen, state.position.x, state.position.y]);
|
||||
|
||||
// ---- Scroll tracking: dual-anchor repositioning ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen || !state.activeSectionId) return;
|
||||
const section = sections.get(state.activeSectionId);
|
||||
if (!section || section.anchorMode === 'right-margin') return;
|
||||
|
||||
const el = section.ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Find scrollable ancestor
|
||||
let scrollParent: HTMLElement | null = el.parentElement;
|
||||
while (scrollParent) {
|
||||
const style = getComputedStyle(scrollParent);
|
||||
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
|
||||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
break;
|
||||
}
|
||||
// Also check for Radix ScrollArea viewport
|
||||
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
|
||||
if (!scrollParent) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const newPos = computeDualAnchor(section);
|
||||
if (newPos) {
|
||||
updatePosition(newPos);
|
||||
}
|
||||
// null = fully off-screen → freeze (do nothing)
|
||||
});
|
||||
};
|
||||
|
||||
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleScroll);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
|
||||
|
||||
// ---- Auto-scroll messages ----
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
|
||||
// ---- Auto-focus input on open ----
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
// ---- Input handling ----
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
// Expand the messages panel upward if there's enough space above the input bar,
|
||||
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
|
||||
const expandUp = state.position.y >= 320;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{state.isOpen && (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
key="floating-chat"
|
||||
layout
|
||||
layoutId={state.morphTargetId ?? undefined}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.position.x,
|
||||
top: state.position.y,
|
||||
width: state.position.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* ---- Messages panel — floats above or below the input bar ---- */}
|
||||
<AnimatePresence>
|
||||
{hasMessages && (
|
||||
<motion.div
|
||||
key="messages-panel"
|
||||
initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
...(expandUp
|
||||
? { bottom: 'calc(100% + 8px)' }
|
||||
: { top: 'calc(100% + 8px)' }),
|
||||
}}
|
||||
className="rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-3">
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
|
||||
<p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
|
||||
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming */}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
{streamingContent ? (
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={streamingContent} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 py-0.5">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ---- Floating input bar ---- */}
|
||||
<div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingChatPortal() {
|
||||
return createPortal(<FloatingChatInner />, document.body);
|
||||
}
|
||||
184
src/renderer/components/ai/blocks/ChatChartBlock.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
XAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import type { ChartBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatChartBlock({ data: blockData }: { data: ChartBlockData }) {
|
||||
const { chartType, title, data } = blockData;
|
||||
// config is optional — the AI sometimes omits it and embeds color in data items instead
|
||||
const config = blockData.config ?? {};
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
const entries = Object.entries(config);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [key, val] = entries[i];
|
||||
// Normalize: guard against missing colors and the legacy hsl(var(--chart-N)) pattern
|
||||
// (chart vars are oklch values, so the hsl wrapper produces invalid CSS → black fills).
|
||||
const raw = val.color ?? '';
|
||||
const color =
|
||||
raw && !/^hsl\(var\(/.test(raw) ? raw : `var(--chart-${(i % 5) + 1})`;
|
||||
cfg[key] = { label: val.label, color };
|
||||
}
|
||||
return cfg;
|
||||
}, [config]);
|
||||
|
||||
const dataKeys = useMemo(() => {
|
||||
const keys = Object.keys(config);
|
||||
if (keys.length > 0) return keys;
|
||||
// Infer series keys from first data row when config is absent
|
||||
const first = data[0];
|
||||
if (!first) return ['value'];
|
||||
return Object.entries(first)
|
||||
.filter(([k, v]) => k !== 'name' && k !== 'color' && typeof v === 'number')
|
||||
.map(([k]) => k);
|
||||
}, [config, data]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
{title && (
|
||||
<p className="mb-3 text-sm font-medium">{title}</p>
|
||||
)}
|
||||
<ChartContainer config={chartConfig} className="max-h-[240px] w-full">
|
||||
{renderChart(chartType, data, dataKeys)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderChart(
|
||||
chartType: ChartBlockData['chartType'],
|
||||
data: Record<string, unknown>[],
|
||||
dataKeys: string[],
|
||||
) {
|
||||
switch (chartType) {
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
fill={`var(--color-${key})`}
|
||||
stroke={`var(--color-${key})`}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Bar key={key} dataKey={key} fill={`var(--color-${key})`} radius={4} />
|
||||
))}
|
||||
</BarChart>
|
||||
);
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
case 'pie':
|
||||
return (
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={dataKeys[0] ?? 'value'}
|
||||
nameKey="name"
|
||||
innerRadius="40%"
|
||||
outerRadius="70%"
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={`var(--chart-${(i % 5) + 1})`} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
);
|
||||
case 'radar':
|
||||
return (
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="name" />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Radar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={`var(--color-${key})`}
|
||||
fillOpacity={0.3}
|
||||
stroke={`var(--color-${key})`}
|
||||
/>
|
||||
))}
|
||||
</RadarChart>
|
||||
);
|
||||
case 'radial':
|
||||
return (
|
||||
<RadialBarChart data={data} innerRadius="30%" outerRadius="90%">
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<RadialBar key={key} dataKey={key} fill={`var(--color-${key})`} />
|
||||
))}
|
||||
</RadialBarChart>
|
||||
);
|
||||
}
|
||||
}
|
||||
267
src/renderer/components/ai/blocks/ChatEntityBlock.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { FileText, FolderOpen, Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { TaskRow } from '@/components/tasks/TaskRow';
|
||||
import type { TaskItem } from '@/components/tasks/task-types';
|
||||
import { TaskDetailSheet } from '@/components/tasks/TaskDetailSheet';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import type { EntityRefBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
|
||||
const { entity, ids } = data;
|
||||
|
||||
switch (entity) {
|
||||
case 'task':
|
||||
return <TaskEntityBlock ids={ids} />;
|
||||
case 'project':
|
||||
return <ProjectEntityBlock ids={ids} />;
|
||||
case 'note':
|
||||
return <NoteEntityBlock ids={ids} />;
|
||||
case 'timeline':
|
||||
return <TimelineEntityBlock ids={ids} />;
|
||||
case 'timelineEvent':
|
||||
return <TimelineEventEntityBlock ids={ids} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TaskEntityBlock({ ids }: { ids: string[] }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: tasksList } = trpc.tasks.byIds.useQuery({ ids }, { enabled: ids.length > 0 });
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.byIds.invalidate({ ids });
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.task.deleted');
|
||||
void utils.tasks.byIds.invalidate({ ids });
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.deleteError', err),
|
||||
});
|
||||
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(taskId: string, currentStatus: string | null) => {
|
||||
const nextStatus =
|
||||
currentStatus === 'todo' ? 'in_progress' :
|
||||
currentStatus === 'in_progress' ? 'done' : 'todo';
|
||||
updateTask.mutate({ id: taskId, status: nextStatus });
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
if (!tasksList?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntityWrapper label="Tasks">
|
||||
{tasksList.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={handleToggle}
|
||||
onClick={setViewTask}
|
||||
/>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
|
||||
<TaskDetailSheet
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
<EditTaskDialog
|
||||
task={editTask}
|
||||
open={!!editTask}
|
||||
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProjectEntityBlock({ ids }: { ids: string[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: allProjects } = trpc.projects.list.useQuery();
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allProjects?.filter((p) => ids.includes(p.id)) ?? [],
|
||||
[allProjects, ids],
|
||||
);
|
||||
|
||||
if (!filtered.length) return null;
|
||||
|
||||
return (
|
||||
<EntityWrapper label="Projects">
|
||||
{filtered.map((p) => (
|
||||
<Item
|
||||
key={p.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
onClick={() => void navigate({ to: '/projects', search: { projectId: p.id } })}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{p.name}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoteEntityBlock({ ids }: { ids: string[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: allNotes } = trpc.notes.list.useQuery();
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allNotes?.filter((n) => ids.includes(n.id)) ?? [],
|
||||
[allNotes, ids],
|
||||
);
|
||||
|
||||
if (!filtered.length) return null;
|
||||
|
||||
return (
|
||||
<EntityWrapper label="Notes">
|
||||
{filtered.map((n) => (
|
||||
<Item
|
||||
key={n.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
onClick={() => void navigate({ to: '/notes/$noteId', params: { noteId: n.id } })}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{n.title}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TimelineEntityBlock({ ids }: { ids: string[] }) {
|
||||
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
|
||||
|
||||
const timelineData = useMemo(() => {
|
||||
const filtered = allEvents?.filter((e) => ids.includes(e.id)) ?? [];
|
||||
|
||||
return {
|
||||
events: filtered
|
||||
.map((e) => {
|
||||
const date = new Date(e.date).getTime();
|
||||
const endDate = e.endDate ? new Date(e.endDate).getTime() : undefined;
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
date,
|
||||
endDate,
|
||||
projectId: e.projectId,
|
||||
isCompleted: e.isCompleted,
|
||||
isAiSuggested: e.isAiSuggested,
|
||||
};
|
||||
})
|
||||
.filter((e) => Number.isFinite(e.date)),
|
||||
};
|
||||
}, [allEvents, ids]);
|
||||
|
||||
if (!timelineData.events.length) return null;
|
||||
|
||||
return <ChatTimelineBlock data={timelineData} />;
|
||||
}
|
||||
|
||||
function TimelineEventEntityBlock({ ids }: { ids: string[] }) {
|
||||
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
|
||||
const prefs = useFormatPrefs();
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allEvents?.filter((e) => ids.includes(e.id)) ?? [],
|
||||
[allEvents, ids],
|
||||
);
|
||||
|
||||
if (!filtered.length) return null;
|
||||
|
||||
return (
|
||||
<EntityWrapper label="Timeline Events">
|
||||
{filtered.map((e) => (
|
||||
<Item
|
||||
key={e.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
{e.isAiSuggested ? (
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{e.title}</ItemTitle>
|
||||
{e.date && (
|
||||
<ItemDescription>
|
||||
{formatDate(e.date, prefs)}
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EntityWrapper({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-card p-3 w-full">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/renderer/components/ai/blocks/ChatTableBlock.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import type { TableBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTableBlock({ data }: { data: TableBlockData }) {
|
||||
const { headers, rows } = data;
|
||||
|
||||
if (!headers.length && !rows.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<Table>
|
||||
{headers.length > 0 && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map((h, i) => (
|
||||
<TableHead key={i}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{rows.map((row, ri) => (
|
||||
<TableRow key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<TableCell key={ci}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/renderer/components/ai/blocks/ChatTimelineBlock.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { ProjectTimelineBox, type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
|
||||
import type { TimelineEvent } from '@/components/timeline/ProjectTimeline';
|
||||
import type { TimelineBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
|
||||
const { events: rawEvents } = data;
|
||||
|
||||
const { data: allProjects } = trpc.projects.list.useQuery({ includeArchived: true });
|
||||
|
||||
const events = useMemo<TimelineEvent[]>(() => {
|
||||
return rawEvents
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
date: event.date,
|
||||
endDate: event.endDate ?? null,
|
||||
type: ((event as { type?: string }).type ?? 'milestone') as TimelineEvent['type'],
|
||||
projectId: event.projectId ?? null,
|
||||
isCompleted: event.isCompleted ?? 0,
|
||||
isAiSuggested: event.isAiSuggested ?? 0,
|
||||
}))
|
||||
.filter((event) => Number.isFinite(event.date));
|
||||
}, [rawEvents]);
|
||||
|
||||
const groups = useMemo<ProjectGroup[]>(() => {
|
||||
const PAD_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
const byProject = new Map<string, TimelineEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = event.projectId ?? '__unassigned__';
|
||||
const current = byProject.get(key);
|
||||
if (current) {
|
||||
current.push(event);
|
||||
} else {
|
||||
byProject.set(key, [event]);
|
||||
}
|
||||
}
|
||||
|
||||
const builtGroups: ProjectGroup[] = [];
|
||||
for (const [key, projectEvents] of byProject.entries()) {
|
||||
const projectId = key === '__unassigned__' ? null : key;
|
||||
const project = projectId
|
||||
? allProjects?.find((p) => p.id === projectId)
|
||||
: undefined;
|
||||
|
||||
const dates = projectEvents.flatMap((event) => (event.endDate ? [event.date, event.endDate] : [event.date]));
|
||||
const minDate = Math.min(...dates, now);
|
||||
const maxDate = Math.max(...dates, now);
|
||||
|
||||
builtGroups.push({
|
||||
projectId,
|
||||
projectName: project?.name ?? 'Timeline',
|
||||
projectStatus: project?.status ?? 'active',
|
||||
breadcrumb: [],
|
||||
events: projectEvents,
|
||||
startDate: new Date(minDate - PAD_MS),
|
||||
endDate: new Date(maxDate + PAD_MS),
|
||||
});
|
||||
}
|
||||
|
||||
return builtGroups.sort((a, b) => a.projectName.localeCompare(b.projectName));
|
||||
}, [events, allProjects]);
|
||||
|
||||
if (!events.length) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{groups.map((group) => (
|
||||
<ProjectTimelineBox
|
||||
key={group.projectId ?? 'unassigned'}
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/renderer/components/ai/blocks/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Block components are now rendered inline by the MessageContent parser
|
||||
* in AIChatPanel.tsx. Import individual block components directly:
|
||||
* - ChatEntityBlock
|
||||
* - ChatChartBlock
|
||||
* - ChatTableBlock
|
||||
* - ChatTimelineBlock
|
||||
*/
|
||||
export { ChatEntityBlock } from './ChatEntityBlock';
|
||||
export { ChatChartBlock } from './ChatChartBlock';
|
||||
export { ChatTableBlock } from './ChatTableBlock';
|
||||
export { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
328
src/renderer/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
|
||||
// Google 'G' logo — inline SVG to avoid importing a second icon library.
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign-in form (login-03 layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SignInForm({
|
||||
className,
|
||||
onSwitchMode,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useTranslation();
|
||||
const loginMutation = trpc.auth.login.useMutation();
|
||||
const oauthMutation = trpc.auth.loginWithOAuth.useMutation();
|
||||
const { notifyError } = useNotify();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const isBusy = loginMutation.isPending || oauthMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
setError('');
|
||||
loginMutation.mutate({ email, password }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Authentication failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
notifyError('toast.auth.loginError', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGoogleLogin() {
|
||||
setError('');
|
||||
oauthMutation.mutate({ provider: 'google' }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Google sign-in failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
notifyError('toast.auth.oauthError', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t('auth.welcomeBack')}</CardTitle>
|
||||
<CardDescription>{t('auth.signInDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
{/* Email + password form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{t('auth.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
disabled={isBusy}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
disabled={isBusy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive -mt-1">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isBusy || !email || !password}>
|
||||
{loginMutation.isPending ? (
|
||||
<><Loader2 className="mr-2 size-4 animate-spin" /> {t('auth.signingIn')}</>
|
||||
) : t('auth.signIn')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
|
||||
<span className="relative z-10 bg-card px-2 text-muted-foreground">
|
||||
{t('auth.or')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Google OAuth button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{oauthMutation.isPending ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> {t('auth.waitingForBrowser')}</>
|
||||
) : (
|
||||
<><GoogleIcon className="size-4" /> {t('auth.signInWithGoogle')}</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode} disabled={isBusy}>
|
||||
{t('auth.signUp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign-up form (signup-03 layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SignUpForm({
|
||||
className,
|
||||
onSwitchMode,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useTranslation();
|
||||
const registerMutation = trpc.auth.register.useMutation();
|
||||
const { notifyError } = useNotify();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [surname, setSurname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
setError('');
|
||||
registerMutation.mutate({
|
||||
email,
|
||||
password,
|
||||
...(name && { name }),
|
||||
...(surname && { surname }),
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Registration failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
notifyError('toast.auth.registerError', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t('auth.createAccount')}</CardTitle>
|
||||
<CardDescription>{t('auth.createAccountDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-name">{t('auth.name')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-surname">{t('auth.surname')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-surname"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={surname}
|
||||
onChange={(e) => { setSurname(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-email">{t('auth.email')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-password">{t('auth.password')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<FieldDescription>Must be at least 8 characters long.</FieldDescription>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={registerMutation.isPending || !email || !password}>
|
||||
{t('auth.createAccountButton')}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
{t('auth.haveAccount')}{' '}
|
||||
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
|
||||
{t('auth.signInLink')}
|
||||
</button>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By creating an account, you agree to our terms of service.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell — logo + mode switcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LoginForm() {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="flex items-center self-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none" width="120" height="47">
|
||||
<style>{`
|
||||
.compass-needle-login {
|
||||
animation: compass-settle-login 5s ease-in-out infinite;
|
||||
transform-origin: 32px 32px;
|
||||
}
|
||||
@keyframes compass-settle-login {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
`}</style>
|
||||
<g transform="translate(2,2)">
|
||||
<g className="compass-needle-login">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="currentColor"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="currentColor" strokeWidth="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="currentColor" opacity="0.18"/>
|
||||
</g>
|
||||
</g>
|
||||
<text x="65" y="42" fontFamily="Geist, system-ui, -apple-system, sans-serif" fontSize="30" letterSpacing="-0.5">
|
||||
<tspan fontWeight="400" fill="currentColor">adiuv</tspan><tspan fontWeight="700" fill="#fbc881">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
{mode === 'login' ? (
|
||||
<SignInForm onSwitchMode={() => setMode('register')} />
|
||||
) : (
|
||||
<SignUpForm onSwitchMode={() => setMode('login')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/renderer/components/brief/BriefChatHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AlertCircle, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface BriefChatHeaderProps {
|
||||
title: string;
|
||||
projectName?: string | null;
|
||||
priority: string;
|
||||
dueDate?: number | null;
|
||||
}
|
||||
|
||||
function relativeDate(ts: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||
const diff = ts - Date.now();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return t('brief.overdue', { days: Math.abs(days) });
|
||||
if (days === 0) return t('brief.dueToday');
|
||||
if (days === 1) return t('brief.dueTomorrow');
|
||||
return t('brief.dueInDays', { days });
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
high: 'bg-destructive/15 text-destructive',
|
||||
medium: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400',
|
||||
low: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
export function BriefChatHeader({ title, projectName, priority, dueDate }: BriefChatHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="px-5 pt-5 pb-4 space-y-2">
|
||||
<h2 className="font-semibold text-base text-foreground leading-snug line-clamp-2">{title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{projectName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-md capitalize',
|
||||
PRIORITY_STYLES[priority] ?? PRIORITY_STYLES.medium,
|
||||
)}
|
||||
>
|
||||
{priority}
|
||||
</span>
|
||||
{dueDate != null && (
|
||||
<span className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
dueDate < Date.now() ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}>
|
||||
{dueDate < Date.now() ? <AlertCircle size={11} /> : <Clock size={11} />}
|
||||
{relativeDate(dueDate, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/renderer/components/brief/CanvasPlaceholder.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Mail, FileText, MessageSquare, ScrollText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface CanvasPlaceholderProps {
|
||||
content: string | null;
|
||||
kind: string | null;
|
||||
onContentChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const KIND_META: Record<string, { label: string; Icon: React.ElementType }> = {
|
||||
email: { label: 'Email Draft', Icon: Mail },
|
||||
document: { label: 'Document Draft', Icon: FileText },
|
||||
message: { label: 'Message Draft', Icon: MessageSquare },
|
||||
};
|
||||
|
||||
export function CanvasPlaceholder({ content, kind }: CanvasPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = kind ? (KIND_META[kind] ?? { label: kind.charAt(0).toUpperCase() + kind.slice(1), Icon: ScrollText }) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full px-4 py-4 gap-3">
|
||||
{/* Kind badge header */}
|
||||
{meta && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-1">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-primary/15 text-primary border border-primary/20">
|
||||
<meta.Icon size={12} strokeWidth={2} />
|
||||
<span className="text-xs font-medium tracking-tight">{meta.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paper surface */}
|
||||
<div className="flex-1 min-h-0 rounded-2xl bg-background shadow-sm border border-border/30 overflow-hidden">
|
||||
{content ? (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="px-7 py-6">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground leading-relaxed
|
||||
prose-p:text-foreground prose-p:leading-relaxed
|
||||
prose-headings:text-foreground prose-headings:font-semibold
|
||||
prose-strong:text-foreground prose-strong:font-semibold
|
||||
prose-li:text-foreground
|
||||
prose-a:text-primary prose-a:no-underline hover:prose-a:underline">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground/40 text-sm select-none">
|
||||
{t('brief.canvas.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/renderer/components/brief/CarouselControls.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CarouselControlsProps {
|
||||
count: number;
|
||||
activeIndex: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function CarouselControls({ count, activeIndex, onPrev, onNext }: CarouselControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={activeIndex === 0}
|
||||
aria-label="Previous task"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-200',
|
||||
i === activeIndex
|
||||
? 'w-5 bg-foreground'
|
||||
: 'w-1.5 bg-muted-foreground/40',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={activeIndex === count - 1}
|
||||
aria-label="Next task"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
343
src/renderer/components/brief/TaskBriefChat.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { ChatInputBox } from '@/components/ai/ChatInputBox';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'assistant' | 'user';
|
||||
content: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
interface BriefingResult {
|
||||
briefingMarkdown: string;
|
||||
canvasDraft: string | null;
|
||||
canvasKind: string | null;
|
||||
}
|
||||
|
||||
interface TaskBriefChatProps {
|
||||
taskId: string;
|
||||
projectId?: string | null;
|
||||
/** Pre-loaded briefing from DB/session cache. Null triggers research. */
|
||||
initialBriefing: BriefingResult | null;
|
||||
onBriefingReady: (result: BriefingResult) => void;
|
||||
}
|
||||
|
||||
export function TaskBriefChat({ taskId, projectId, initialBriefing, onBriefingReady }: TaskBriefChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const sessionId = useRef(crypto.randomUUID()).current;
|
||||
const cacheKey = `brief-${taskId}`;
|
||||
|
||||
// Load persisted follow-up messages — only when briefing already exists
|
||||
const chatHistoryQuery = trpc.ai.getTaskBriefChats.useQuery(
|
||||
{ taskId },
|
||||
{ enabled: !!initialBriefing },
|
||||
);
|
||||
const saveChatMutation = trpc.ai.saveTaskBriefChat.useMutation();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
if (initialBriefing) {
|
||||
return [{ id: 'briefing', role: 'assistant', content: initialBriefing.briefingMarkdown }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// True until DB history is applied (or skipped when no briefing)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(!initialBriefing);
|
||||
const [isResearching, setIsResearching] = useState(!initialBriefing);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [briefingText, setBriefingText] = useState<string>(initialBriefing?.briefingMarkdown ?? '');
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const researchMutation = trpc.ai.taskBriefResearch.useMutation();
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
// Merge DB history into messages once query settles
|
||||
useEffect(() => {
|
||||
if (historyLoaded) return;
|
||||
if (chatHistoryQuery.isLoading) return;
|
||||
setHistoryLoaded(true);
|
||||
if (!chatHistoryQuery.data || chatHistoryQuery.data.length === 0) return;
|
||||
setMessages([
|
||||
{ id: 'briefing', role: 'assistant', content: initialBriefing!.briefingMarkdown },
|
||||
...chatHistoryQuery.data.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
error: !!m.isError,
|
||||
})),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatHistoryQuery.isLoading, chatHistoryQuery.data]);
|
||||
|
||||
// Auto-scroll on new content
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
// Research phase: fire on mount if no initial briefing
|
||||
useEffect(() => {
|
||||
if (initialBriefing) return;
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
let accumulated = '';
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'stream_text':
|
||||
accumulated += event.chunk;
|
||||
streamingRef.current = accumulated;
|
||||
setStreamingContent(accumulated);
|
||||
break;
|
||||
|
||||
case 'stream_end': {
|
||||
const finalText = stripCanvas(streamingRef.current);
|
||||
const mutations = event.mutations as Array<Record<string, unknown>> | undefined;
|
||||
const canvasMut = mutations?.find((m) => m.type === 'canvas_draft');
|
||||
const canvasDraft = (canvasMut?.content as string) ?? null;
|
||||
const canvasKind = (canvasMut?.kind as string) ?? null;
|
||||
|
||||
setBriefingText(finalText);
|
||||
setMessages([{ id: 'briefing', role: 'assistant', content: finalText }]);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsResearching(false);
|
||||
setHistoryLoaded(true);
|
||||
|
||||
onBriefingReady({ briefingMarkdown: finalText, canvasDraft, canvasKind });
|
||||
unsubscribe();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
researchMutation.mutate({ taskId, requestId });
|
||||
return () => unsubscribe();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback((message: string) => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || isStreaming || isResearching || !historyLoaded) return;
|
||||
|
||||
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
saveChatMutation.mutate({ id: userMsg.id, taskId, role: 'user', content: trimmed, createdAt: Date.now() });
|
||||
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'stream_text':
|
||||
streamingRef.current += event.chunk;
|
||||
setStreamingContent(streamingRef.current);
|
||||
break;
|
||||
|
||||
case 'stream_end': {
|
||||
const finalContent = streamingRef.current;
|
||||
const assistantMsgId = crypto.randomUUID();
|
||||
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content: finalContent }]);
|
||||
if (finalContent) {
|
||||
saveChatMutation.mutate({ id: assistantMsgId, taskId, role: 'assistant', content: finalContent, createdAt: Date.now() });
|
||||
}
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const conversationHistory = messages.slice(-20).map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
chatMutation.mutate(
|
||||
{
|
||||
requestId,
|
||||
message: trimmed,
|
||||
conversationHistory,
|
||||
sessionId,
|
||||
mode: 'contextual',
|
||||
scope: { type: 'task', id: taskId },
|
||||
briefMode: true,
|
||||
briefingContext: briefingText || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
unsubscribe();
|
||||
setMessages((prev) => [...prev, {
|
||||
id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true,
|
||||
}]);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
unsubscribe();
|
||||
const content = streamingRef.current;
|
||||
if (content) {
|
||||
const assistantMsgId = crypto.randomUUID();
|
||||
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content }]);
|
||||
saveChatMutation.mutate({ id: assistantMsgId, taskId, role: 'assistant', content, createdAt: Date.now() });
|
||||
}
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
unsubscribe();
|
||||
setMessages((prev) => [...prev, {
|
||||
id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true,
|
||||
}]);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [isStreaming, isResearching, historyLoaded, messages, taskId, sessionId, briefingText, chatMutation, saveChatMutation]);
|
||||
|
||||
const isInputBlocked = isStreaming || isResearching || !historyLoaded;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Researching state */}
|
||||
{isResearching && (
|
||||
<div>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-[32px] space-y-2">
|
||||
{streamingContent ? (
|
||||
<BriefMarkdown content={stripCanvas(streamingContent, true)} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('brief.researching')}</p>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message list */}
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
>
|
||||
{msg.role === 'assistant' ? (
|
||||
<div>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`pl-[32px] ${msg.error ? 'text-destructive' : ''}`}>
|
||||
<BriefMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-2xl bg-muted px-4 py-2.5 text-sm">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Streaming follow-up */}
|
||||
{isStreaming && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-[32px]">
|
||||
{streamingContent ? (
|
||||
<BriefMarkdown content={streamingContent} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 px-4 pb-4 pt-2">
|
||||
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-md ring-1 ring-border/20 transition-shadow focus-within:shadow-lg focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
cacheKey={cacheKey}
|
||||
isStreaming={isInputBlocked}
|
||||
onSend={handleSend}
|
||||
placeholder={t('brief.inputPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Strip <canvas> block — canvas goes to right panel, not chat text
|
||||
const CANVAS_COMPLETE_RE = /<canvas\b[^>]*>[\s\S]*?<\/canvas>/gi;
|
||||
const CANVAS_PARTIAL_RE = /<canvas\b[\s\S]*$/i;
|
||||
|
||||
function stripCanvas(text: string, partial = false): string {
|
||||
if (partial) return text.replace(CANVAS_PARTIAL_RE, '');
|
||||
return text.replace(CANVAS_COMPLETE_RE, '').trim();
|
||||
}
|
||||
|
||||
const BriefMarkdown = memo(function BriefMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
20
src/renderer/components/brief/TaskBriefEmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function TaskBriefEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-8">
|
||||
<CheckCircle size={40} className="text-muted-foreground/50" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{t('brief.empty.title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('brief.empty.description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/tasks">{t('brief.empty.cta')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/renderer/components/brief/TaskBriefingOverlay.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBriefTasks } from '@/hooks/useBriefTasks';
|
||||
import { useTaskBriefing } from '@/context/TaskBriefingContext';
|
||||
import { TaskCarousel, clearCarouselBriefingCache } from './TaskCarousel';
|
||||
import { TaskBriefEmptyState } from './TaskBriefEmptyState';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
/**
|
||||
* Inline task briefing section — renders inside the home page content area.
|
||||
* No overlay/backdrop/fixed positioning; the parent hides/shows this via AnimatePresence.
|
||||
*/
|
||||
export function TaskBriefingOverlay() {
|
||||
const { close, initialTaskId } = useTaskBriefing();
|
||||
const { t } = useTranslation();
|
||||
const { tasks, isLoading } = useBriefTasks();
|
||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [close]);
|
||||
|
||||
// Focus back button on mount
|
||||
useEffect(() => {
|
||||
backBtnRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Clear session cache when unmounted (i.e. closed)
|
||||
useEffect(() => {
|
||||
return () => clearCarouselBriefingCache();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col h-full w-full"
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 24 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 38 }}
|
||||
>
|
||||
{/* Top bar — mirrors note page: SidebarTrigger | sep | back */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-1 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
|
||||
<Button
|
||||
ref={backBtnRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={close}
|
||||
aria-label={t('brief.controls.close')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="ml-auto text-xs text-muted-foreground/60">
|
||||
{t('brief.overlayTitle')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<TaskBriefEmptyState />
|
||||
) : (
|
||||
<TaskCarousel tasks={tasks} initialTaskId={initialTaskId} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
240
src/renderer/components/brief/TaskCarousel.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { BriefChatHeader } from './BriefChatHeader';
|
||||
import { TaskBriefChat } from './TaskBriefChat';
|
||||
import { CanvasPlaceholder } from './CanvasPlaceholder';
|
||||
import { CarouselControls } from './CarouselControls';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
|
||||
interface BriefingResult {
|
||||
briefingMarkdown: string;
|
||||
canvasDraft: string | null;
|
||||
canvasKind: string | null;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
dueDate?: number | null;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
}
|
||||
|
||||
interface TaskCarouselProps {
|
||||
tasks: Task[];
|
||||
initialTaskId?: string;
|
||||
}
|
||||
|
||||
// Session-level briefing cache (survives carousel navigation, cleared on overlay close)
|
||||
const briefingSessionCache = new Map<string, BriefingResult>();
|
||||
|
||||
export function clearCarouselBriefingCache() {
|
||||
briefingSessionCache.clear();
|
||||
}
|
||||
|
||||
const SLIDE_VARIANTS = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 60 : -60, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir > 0 ? -60 : 60, opacity: 0 }),
|
||||
};
|
||||
|
||||
const SLIDE_TRANSITION = { type: 'spring' as const, stiffness: 400, damping: 40 };
|
||||
|
||||
export function TaskCarousel({ tasks, initialTaskId }: TaskCarouselProps) {
|
||||
const initialIndex = initialTaskId
|
||||
? Math.max(0, tasks.findIndex((t) => t.id === initialTaskId))
|
||||
: 0;
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [slideDir, setSlideDir] = useState(1);
|
||||
// Per-task canvas drafts derived from briefings
|
||||
const [canvasData, setCanvasData] = useState<Map<string, BriefingResult>>(new Map(briefingSessionCache));
|
||||
|
||||
const activeTask = tasks[activeIndex];
|
||||
|
||||
// Per-task DB briefing query
|
||||
const dbBriefingQuery = trpc.ai.getTaskBriefing.useQuery(
|
||||
{ taskId: activeTask?.id ?? '' },
|
||||
{
|
||||
enabled: !!activeTask && !briefingSessionCache.has(activeTask.id),
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve initial briefing from session cache → DB → null (triggers research)
|
||||
const getCachedBriefing = (taskId: string): BriefingResult | null => {
|
||||
if (briefingSessionCache.has(taskId)) return briefingSessionCache.get(taskId)!;
|
||||
if (dbBriefingQuery.data && dbBriefingQuery.data.taskId === taskId) {
|
||||
return {
|
||||
briefingMarkdown: dbBriefingQuery.data.briefingMarkdown,
|
||||
canvasDraft: dbBriefingQuery.data.canvasDraft ?? null,
|
||||
canvasKind: dbBriefingQuery.data.canvasKind ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Promote DB briefings to session cache so navigating back doesn't re-research
|
||||
useEffect(() => {
|
||||
if (!dbBriefingQuery.data || !activeTask) return;
|
||||
if (dbBriefingQuery.data.taskId !== activeTask.id) return;
|
||||
if (briefingSessionCache.has(activeTask.id)) return;
|
||||
const result: BriefingResult = {
|
||||
briefingMarkdown: dbBriefingQuery.data.briefingMarkdown,
|
||||
canvasDraft: dbBriefingQuery.data.canvasDraft ?? null,
|
||||
canvasKind: dbBriefingQuery.data.canvasKind ?? null,
|
||||
};
|
||||
briefingSessionCache.set(activeTask.id, result);
|
||||
setCanvasData((prev) => new Map(prev).set(activeTask.id, result));
|
||||
}, [dbBriefingQuery.data, activeTask]);
|
||||
|
||||
const handleBriefingReady = useCallback((result: BriefingResult) => {
|
||||
if (!activeTask) return;
|
||||
briefingSessionCache.set(activeTask.id, result);
|
||||
setCanvasData((prev) => new Map(prev).set(activeTask.id, result));
|
||||
}, [activeTask]);
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
if (index < 0 || index >= tasks.length) return;
|
||||
setSlideDir(index > activeIndex ? 1 : -1);
|
||||
setActiveIndex(index);
|
||||
}, [activeIndex, tasks.length]);
|
||||
|
||||
const lastWheelNavRef = useRef<number>(0);
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastWheelNavRef.current < 600) return;
|
||||
if (e.deltaX > 30) { lastWheelNavRef.current = now; goTo(activeIndex + 1); }
|
||||
else if (e.deltaX < -30) { lastWheelNavRef.current = now; goTo(activeIndex - 1); }
|
||||
}, [activeIndex, goTo]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') goTo(activeIndex - 1);
|
||||
if (e.key === 'ArrowRight') goTo(activeIndex + 1);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [activeIndex, goTo]);
|
||||
|
||||
// Prefetch next slide's briefing from DB so it's warm
|
||||
const nextTaskId = tasks[activeIndex + 1]?.id;
|
||||
trpc.ai.getTaskBriefing.useQuery(
|
||||
{ taskId: nextTaskId ?? '' },
|
||||
{ enabled: !!nextTaskId && !briefingSessionCache.has(nextTaskId), staleTime: Infinity },
|
||||
);
|
||||
|
||||
if (!activeTask) return null;
|
||||
|
||||
// True while DB is still being checked — prevents TaskBriefChat mounting
|
||||
// with undefined initialBriefing and firing unnecessary research
|
||||
const isDbCheckPending = !briefingSessionCache.has(activeTask.id) && dbBriefingQuery.isFetching;
|
||||
|
||||
const initialBriefing = getCachedBriefing(activeTask.id);
|
||||
const activeCanvas = canvasData.get(activeTask.id) ?? initialBriefing;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" onWheel={handleWheel}>
|
||||
{/* Carousel slide area */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={slideDir} mode="wait">
|
||||
<motion.div
|
||||
key={activeTask.id}
|
||||
custom={slideDir}
|
||||
variants={SLIDE_VARIANTS}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={SLIDE_TRANSITION}
|
||||
className="h-full w-full"
|
||||
>
|
||||
{activeCanvas?.canvasDraft ? (
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
||||
{/* Left: Chat panel */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<BriefChatHeader
|
||||
title={activeTask.title}
|
||||
projectName={activeTask.projectName}
|
||||
priority={activeTask.priority}
|
||||
dueDate={activeTask.dueDate}
|
||||
/>
|
||||
<div className="flex-1 min-h-0">
|
||||
{isDbCheckPending ? (
|
||||
<div className="px-5 py-5 space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskBriefChat
|
||||
key={activeTask.id}
|
||||
taskId={activeTask.id}
|
||||
projectId={activeTask.projectId}
|
||||
initialBriefing={initialBriefing}
|
||||
onBriefingReady={handleBriefingReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Right: Canvas */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<CanvasPlaceholder
|
||||
content={activeCanvas.canvasDraft}
|
||||
kind={activeCanvas.canvasKind ?? null}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
/* No canvas: chat centered */
|
||||
<div className="flex flex-col h-full w-full max-w-2xl mx-auto">
|
||||
<BriefChatHeader
|
||||
title={activeTask.title}
|
||||
projectName={activeTask.projectName}
|
||||
priority={activeTask.priority}
|
||||
dueDate={activeTask.dueDate}
|
||||
/>
|
||||
<div className="flex-1 min-h-0">
|
||||
{isDbCheckPending ? (
|
||||
<div className="px-5 py-5 space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskBriefChat
|
||||
key={activeTask.id}
|
||||
taskId={activeTask.id}
|
||||
projectId={activeTask.projectId}
|
||||
initialBriefing={initialBriefing}
|
||||
onBriefingReady={handleBriefingReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="shrink-0">
|
||||
<CarouselControls
|
||||
count={tasks.length}
|
||||
activeIndex={activeIndex}
|
||||
onPrev={() => goTo(activeIndex - 1)}
|
||||
onNext={() => goTo(activeIndex + 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Link, useRouterState, useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { LayoutGroup } from 'framer-motion';
|
||||
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
|
||||
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
|
||||
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
|
||||
import { HeaderProvider, useHeader } from '@/context/HeaderContext';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
import type { PanelSize } from 'react-resizable-panels';
|
||||
import {
|
||||
House,
|
||||
ChartGantt,
|
||||
ClipboardCheck,
|
||||
FolderKanban,
|
||||
PanelLeft,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Check,
|
||||
LogOut,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette
|
||||
ChevronsUpDown,
|
||||
SquarePen,
|
||||
Folder,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
|
||||
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', icon: House, label: 'Home' },
|
||||
{ to: '/timeline', icon: ChartGantt, label: 'Timeline' },
|
||||
{ to: '/tasks', icon: ClipboardCheck, label: 'Tasks' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||
{ to: '/', icon: House, labelKey: 'nav.home' },
|
||||
{ to: '/timeline', icon: ChartGantt, labelKey: 'nav.timeline' },
|
||||
{ to: '/tasks', icon: ClipboardCheck, labelKey: 'nav.tasks' },
|
||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
] as const;
|
||||
|
||||
const SIDEBAR_SIZE_KEY = 'chat.sidebar.size';
|
||||
const SIDEBAR_SIZE_MIN = 22;
|
||||
const SIDEBAR_SIZE_MAX = 60;
|
||||
const SIDEBAR_SIZE_DEFAULT = 38;
|
||||
|
||||
function readSidebarSize(): number {
|
||||
if (typeof window === 'undefined') return SIDEBAR_SIZE_DEFAULT;
|
||||
const v = window.localStorage.getItem(SIDEBAR_SIZE_KEY);
|
||||
if (!v) return SIDEBAR_SIZE_DEFAULT;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return SIDEBAR_SIZE_DEFAULT;
|
||||
return Math.max(SIDEBAR_SIZE_MIN, Math.min(SIDEBAR_SIZE_MAX, n));
|
||||
}
|
||||
|
||||
function MainArea({ children }: { children: React.ReactNode }) {
|
||||
const loc = useLocation();
|
||||
const isHome = loc.pathname === '/';
|
||||
const { open } = useContextualChat();
|
||||
// Read once per mount of the open state. When the user reopens the sidebar
|
||||
// we want the most recent persisted size, so we key the PanelGroup on
|
||||
// `open` so it remounts each open/close cycle.
|
||||
const initialSize = useMemo(() => readSidebarSize(), [open]);
|
||||
|
||||
if (isHome || !open) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
key={`sidebar-open-${initialSize}`}
|
||||
orientation="horizontal"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<ResizablePanel defaultSize={`${100 - initialSize}%`} minSize="30%">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle
|
||||
className="bg-border/40 hover:bg-border/70 transition-colors after:w-3! cursor-col-resize"
|
||||
/>
|
||||
<ResizablePanel
|
||||
defaultSize={`${initialSize}%`}
|
||||
minSize={`${SIDEBAR_SIZE_MIN}%`}
|
||||
maxSize={`${SIDEBAR_SIZE_MAX}%`}
|
||||
onResize={(panelSize: PanelSize) => {
|
||||
const clamped = Math.max(
|
||||
SIDEBAR_SIZE_MIN,
|
||||
Math.min(SIDEBAR_SIZE_MAX, panelSize.asPercentage),
|
||||
);
|
||||
window.localStorage.setItem(SIDEBAR_SIZE_KEY, String(clamped));
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<ContextualSidebar />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</FloatingChatProvider>
|
||||
<ExpandedClientsProvider>
|
||||
<TaskBriefingProvider>
|
||||
<HeaderProvider>
|
||||
<div className="flex w-full h-full">
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</div>
|
||||
</HeaderProvider>
|
||||
</TaskBriefingProvider>
|
||||
</ExpandedClientsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShellInner({ children }: AppShellProps) {
|
||||
useDoubleClickAI();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
||||
staleTime: Infinity,
|
||||
@@ -87,8 +173,6 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
const routerState = useRouterState();
|
||||
const currentPath = routerState.location.pathname;
|
||||
|
||||
// Controlled open state (spec: "Controlled Sidebar" pattern)
|
||||
// Default to collapsed (false) until the persisted preference loads
|
||||
const [open, setOpen] = useState(() =>
|
||||
collapsedQuery.data === undefined ? false : !collapsedQuery.data
|
||||
);
|
||||
@@ -98,106 +182,114 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
||||
};
|
||||
|
||||
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
|
||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
const setTokenMutation = trpc.ai.setToken.useMutation({
|
||||
onSuccess: () => {
|
||||
setSaved(true);
|
||||
setTokenInput('');
|
||||
void utils.ai.hasToken.invalidate();
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
const taskBriefing = useTaskBriefing();
|
||||
const chatActionsRef = useRef<{ clear: () => void } | null>(null);
|
||||
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
|
||||
|
||||
const isHomePage = currentPath === '/';
|
||||
const isSettingsPage = currentPath.startsWith('/settings');
|
||||
|
||||
// Derive the page label from the current path for the breadcrumb
|
||||
const matchedItem = NAV_ITEMS.find(
|
||||
(item) => item.to !== '/' && currentPath.startsWith(item.to),
|
||||
);
|
||||
const routeLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
|
||||
|
||||
// Dynamic label/extras published by child pages (e.g. ProjectDetail)
|
||||
const { label: dynamicLabel, extras: headerExtras, leftExtras, rightExtras } = useHeader();
|
||||
const pageLabel = dynamicLabel ?? routeLabel;
|
||||
|
||||
// All non-home, non-settings routes show the shared AppShell header.
|
||||
// Projects and notes previously managed their own header; they now receive
|
||||
// the shared header (with SidebarTrigger + AdiuvaTriggerButton) from here.
|
||||
const showHeader = !isSettingsPage && !isHomePage;
|
||||
|
||||
if (authStatusQuery.data?.authenticated === false) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
if (
|
||||
authStatusQuery.data?.profile &&
|
||||
authStatusQuery.data.profile.onboardingCompletedAt == null
|
||||
) {
|
||||
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange} className="h-full">
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
profile={authStatusQuery.data?.profile ?? null}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
|
||||
{isHomePage ? (
|
||||
<AIChatPanel
|
||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||
isHomePage
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col h-full">
|
||||
<header className="flex items-center gap-2 p-2 md:hidden">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{!taskBriefing.isOpen && (
|
||||
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-1 rounded-lg bg-background/60 backdrop-blur-md px-1 py-1">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
{children}
|
||||
{homeChatHasMessages && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px mx-1" />
|
||||
<button
|
||||
onClick={() => chatActionsRef.current?.clear()}
|
||||
aria-label="New conversation"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<SquarePen size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
|
||||
</div>
|
||||
) : (
|
||||
<ContextualChatProvider>
|
||||
{/* MainArea wraps EVERYTHING (header + content) so the contextual
|
||||
sidebar, when open, spans the full SidebarInset height. The
|
||||
left ResizablePanel contains the header + scrollable body;
|
||||
the right panel is the sidebar.
|
||||
The inner overflow-hidden div scopes sticky elements (e.g.
|
||||
ProjectTabBar) below the header without sliding behind it. */}
|
||||
<MainArea>
|
||||
<div className="flex flex-col h-full min-w-0">
|
||||
{showHeader && (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className={`data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:h-4${leftExtras ? '' : ' mr-2'}`} />
|
||||
{leftExtras ?? (
|
||||
<h4 className="text-sm font-medium text-foreground">{pageLabel}</h4>
|
||||
)}
|
||||
{headerExtras}
|
||||
<div className="flex-1" />
|
||||
{rightExtras}
|
||||
<AdiuvaTriggerButton />
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</MainArea>
|
||||
</ContextualChatProvider>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
{/* Floating AI Chat — portal to document.body */}
|
||||
<FloatingChatPortal />
|
||||
|
||||
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
||||
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
||||
setTokenDialogOpen(open);
|
||||
if (!open) { setTokenInput(''); setSaved(false); }
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your AI provider credentials for chat, summaries, and suggestions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">GitHub Copilot Token</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste your token here"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your token is stored securely in the OS keychain.
|
||||
{hasTokenQuery.data === true && (
|
||||
<span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
|
||||
<Check size={14} />
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
disabled={!tokenInput.trim() || setTokenMutation.isPending}
|
||||
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
|
||||
>
|
||||
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentPath: string;
|
||||
setTokenDialogOpen: (open: boolean) => void;
|
||||
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
function AppSidebar({ currentPath, profile }: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
{/* Logo */}
|
||||
@@ -207,21 +299,15 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<div className="cursor-default">
|
||||
<div className="size-7 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-primary-foreground"
|
||||
>
|
||||
<path
|
||||
d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="18" height="18">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
Adiuva
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
@@ -234,11 +320,12 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, label }) => {
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, labelKey }) => {
|
||||
const isActive =
|
||||
to === '/'
|
||||
? currentPath === '/'
|
||||
: currentPath.startsWith(to);
|
||||
const label = t(labelKey);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
@@ -258,61 +345,323 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<NavProjects />
|
||||
</SidebarContent>
|
||||
|
||||
{/* Settings gear + Collapse toggle */}
|
||||
{/* User avatar + dropdown */}
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton tooltip="Settings">
|
||||
<Settings />
|
||||
<span>Settings</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 size-4" />
|
||||
AI Provider
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 size-4" />
|
||||
<span>Theme</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => setTheme('light')}>
|
||||
<Sun className="mr-2 size-4" />
|
||||
Light
|
||||
{theme === 'light' && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 size-4" />
|
||||
Dark
|
||||
{theme === 'dark' && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 size-4" />
|
||||
System
|
||||
{theme === 'system' && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
|
||||
<PanelLeft />
|
||||
<span>Collapse</span>
|
||||
</SidebarMenuButton>
|
||||
<NavUser profile={profile} currentPath={currentPath} />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavProjects — clients + projects tree in the sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NO_CLIENT_KEY = '__no_client__';
|
||||
|
||||
function NavProjects() {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const currentPath = routerState.location.pathname;
|
||||
const currentProjectId = useMemo(() => {
|
||||
const params = new URLSearchParams(routerState.location.search);
|
||||
return params.get('projectId') ?? undefined;
|
||||
}, [routerState.location.search]);
|
||||
|
||||
const { expandedClients, toggleClient, expandClients } = useExpandedClients();
|
||||
|
||||
const { data: projectList = [] } = trpc.projects.list.useQuery({ includeArchived: false });
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
|
||||
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
|
||||
|
||||
const subClientsByParent = useMemo(() => {
|
||||
const m = new Map<string, typeof clientList>();
|
||||
for (const c of clientList) {
|
||||
if (c.parentId) {
|
||||
const arr = m.get(c.parentId);
|
||||
if (arr) arr.push(c);
|
||||
else m.set(c.parentId, [c]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const projectsByClient = useMemo(() => {
|
||||
const m = new Map<string, typeof projectList>();
|
||||
for (const p of projectList) {
|
||||
const key = p.clientId ?? NO_CLIENT_KEY;
|
||||
const arr = m.get(key);
|
||||
if (arr) arr.push(p);
|
||||
else m.set(key, [p]);
|
||||
}
|
||||
return m;
|
||||
}, [projectList]);
|
||||
|
||||
function handleSelectProject(projectId: string) {
|
||||
void navigate({ to: '/projects', search: { projectId } });
|
||||
}
|
||||
|
||||
if (state === 'collapsed') return null;
|
||||
if (currentPath.startsWith('/projects')) return null;
|
||||
if (projectList.length === 0 && clientList.length === 0) return null;
|
||||
|
||||
const isProjectsActive = currentPath.startsWith('/projects');
|
||||
const unassignedProjects = projectsByClient.get(NO_CLIENT_KEY) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('projects.projects')}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{topLevelClients.map((client) => {
|
||||
const isExpanded = expandedClients.has(client.id);
|
||||
const directProjects = projectsByClient.get(client.id) ?? [];
|
||||
const subClients = subClientsByParent.get(client.id) ?? [];
|
||||
const hasChildren = directProjects.length > 0 || subClients.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={client.id}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleClient(client.id)}
|
||||
asChild
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={client.name}>
|
||||
<Folder />
|
||||
<span>{client.name}</span>
|
||||
{hasChildren && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'ml-auto transition-transform duration-200',
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{subClients.map((subClient) => {
|
||||
const subIsExpanded = expandedClients.has(subClient.id);
|
||||
const subProjects = projectsByClient.get(subClient.id) ?? [];
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={subClient.id}
|
||||
open={subIsExpanded}
|
||||
onOpenChange={() => toggleClient(subClient.id)}
|
||||
asChild
|
||||
>
|
||||
<SidebarMenuSubItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuSubButton>
|
||||
<Folder />
|
||||
<span>{subClient.name}</span>
|
||||
{subProjects.length > 0 && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'ml-auto size-3 transition-transform duration-200',
|
||||
subIsExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuSubButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{subProjects.map((p) => (
|
||||
<SidebarMenuSubItem key={p.id}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={isProjectsActive && currentProjectId === p.id}
|
||||
onClick={() => handleSelectProject(p.id)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuSubItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{directProjects.map((p) => (
|
||||
<SidebarMenuSubItem key={p.id}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={isProjectsActive && currentProjectId === p.id}
|
||||
onClick={() => handleSelectProject(p.id)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
|
||||
{unassignedProjects.map((p) => (
|
||||
<SidebarMenuItem key={p.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isProjectsActive && currentProjectId === p.id}
|
||||
onClick={() => handleSelectProject(p.id)}
|
||||
tooltip={p.name}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavUser — avatar with dropdown (inspired by shadcn sidebar-07)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NavUser({
|
||||
profile,
|
||||
currentPath,
|
||||
}: {
|
||||
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
|
||||
currentPath: string;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const logoutMutation = trpc.auth.logout.useMutation();
|
||||
const { notify } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const email = profile?.email ?? 'User';
|
||||
const displayName = [profile?.name, profile?.surname].filter(Boolean).join(' ') || email?.split('@')[0];
|
||||
const initials = profile?.name && profile?.surname
|
||||
? `${profile.name[0]}${profile.surname[0]}`.toUpperCase()
|
||||
: (email?.split('@')[0] ?? 'US').slice(0, 2).toUpperCase();
|
||||
|
||||
function handleLogout() {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
notify('info', 'toast.auth.loggedOut');
|
||||
void utils.auth.status.invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: 'Light', icon: Sun },
|
||||
{ value: 'dark' as const, label: 'Dark', icon: Moon },
|
||||
{ value: 'system' as const, label: 'System', icon: Monitor },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">
|
||||
<Settings className="mr-2 size-4" />
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="mr-2 size-4" />
|
||||
) : theme === 'light' ? (
|
||||
<Sun className="mr-2 size-4" />
|
||||
) : (
|
||||
<Monitor className="mr-2 size-4" />
|
||||
)}
|
||||
Theme
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{themeOptions.map(({ value, label, icon: Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
>
|
||||
<Icon className="mr-2 size-4" />
|
||||
{label}
|
||||
{theme === value && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={logoutMutation.isPending}>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
{t('settings.signOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/renderer/components/notes/PendingEditBlock.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Sparkles, Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NoteEdit } from '../../../shared/api-types';
|
||||
|
||||
interface PendingEditBlockProps {
|
||||
edit: NoteEdit;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const EDIT_TYPE_LABEL: Record<NoteEdit['type'], string> = {
|
||||
append: 'Add at end',
|
||||
insert: 'Insert',
|
||||
replace: 'Replace',
|
||||
};
|
||||
|
||||
export function PendingEditBlock({ edit, onApprove, onReject, isPending }: PendingEditBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-dashed border-muted-foreground/40 bg-muted/30 p-4',
|
||||
'flex flex-col gap-3',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="font-medium uppercase tracking-wide">
|
||||
AI suggestion — {EDIT_TYPE_LABEL[edit.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
{edit.reasoning && (
|
||||
<p className="text-xs text-muted-foreground italic">{edit.reasoning}</p>
|
||||
)}
|
||||
|
||||
{/* Proposed content preview */}
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-background/60 p-3 text-sm leading-relaxed text-foreground font-sans">
|
||||
{edit.proposedContent}
|
||||
</pre>
|
||||
|
||||
{/* Anchor hint for insert/replace */}
|
||||
{edit.type === 'replace' && edit.anchorText && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replaces: <span className="font-mono">{edit.anchorText.slice(0, 80)}</span>
|
||||
</p>
|
||||
)}
|
||||
{edit.type === 'insert' && edit.anchorBefore && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After: <span className="font-mono">{edit.anchorBefore.slice(0, 80)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
723
src/renderer/components/onboarding/OnboardingFlow.tsx
Normal file
@@ -0,0 +1,723 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronRight, ChevronLeft, Pencil, Check, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { JOB_ROLES, INDUSTRIES, USE_CASES, TONES } from './onboardingOptions';
|
||||
import type { UserProfile } from '../../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Step =
|
||||
| 'welcome'
|
||||
| 'jobRole'
|
||||
| 'industry'
|
||||
| 'useCase'
|
||||
| 'tone'
|
||||
| 'language'
|
||||
| 'reviewing'
|
||||
| 'done';
|
||||
|
||||
const STEP_ORDER: Step[] = [
|
||||
'welcome',
|
||||
'jobRole',
|
||||
'industry',
|
||||
'useCase',
|
||||
'tone',
|
||||
'language',
|
||||
'reviewing',
|
||||
];
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
profile: UserProfile;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const spring = { type: 'spring' as const, stiffness: 400, damping: 32 };
|
||||
|
||||
function AIBubble({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={spring}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="16" height="16">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/60 backdrop-blur-md border border-border/30 px-5 py-3.5 max-w-[85%]">
|
||||
<div className="text-sm leading-relaxed">{children}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBubble({ text }: { text: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={spring}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="rounded-2xl bg-primary/10 border border-primary/20 px-4 py-2.5 max-w-[70%]">
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle-chip for multi-select. */
|
||||
function Chip({
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={selected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OnboardingFlow({ profile }: OnboardingFlowProps) {
|
||||
const [step, setStep] = useState<Step>('welcome');
|
||||
// answers stores comma-joined values per field (supports multi-select)
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
// per-step selected chip sets (for multi-select toggle UI)
|
||||
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
|
||||
const [freeTexts, setFreeTexts] = useState<Record<string, string>>({});
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
const [reviewValues, setReviewValues] = useState<Record<string, string>>({});
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [editBuffer, setEditBuffer] = useState('');
|
||||
const [normalizeError, setNormalizeError] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const normalizeMutation = trpc.auth.normalizeOnboarding.useMutation();
|
||||
const updateMemoryMutation = trpc.auth.updateMemory.useMutation();
|
||||
|
||||
const displayName =
|
||||
[profile.name, profile.surname].filter(Boolean).join(' ') ||
|
||||
profile.email.split('@')[0];
|
||||
|
||||
// -- Chip toggle --
|
||||
|
||||
const toggleChip = useCallback((field: string, value: string) => {
|
||||
setSelected((prev) => {
|
||||
const set = new Set(prev[field] ?? []);
|
||||
if (set.has(value)) set.delete(value);
|
||||
else set.add(value);
|
||||
return { ...prev, [field]: set };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isChipSelected = useCallback(
|
||||
(field: string, value: string) => selected[field]?.has(value) ?? false,
|
||||
[selected],
|
||||
);
|
||||
|
||||
// -- Navigation helpers --
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
const idx = STEP_ORDER.indexOf(step);
|
||||
if (idx >= 0 && idx < STEP_ORDER.length - 1) {
|
||||
setCustomInput('');
|
||||
setStep(STEP_ORDER[idx + 1]);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
const idx = STEP_ORDER.indexOf(step);
|
||||
if (idx > 0) {
|
||||
setCustomInput('');
|
||||
setStep(STEP_ORDER[idx - 1]);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
/** Commit selections for a field and advance. */
|
||||
const commitAndNext = useCallback(
|
||||
(field: string) => {
|
||||
const chips = selected[field];
|
||||
const chipValues = chips ? [...chips] : [];
|
||||
const custom = customInput.trim();
|
||||
|
||||
// Merge chip selections + optional custom input
|
||||
const allValues = [...chipValues];
|
||||
if (custom && !allValues.includes(custom)) allValues.push(custom);
|
||||
|
||||
if (allValues.length > 0) {
|
||||
const joined = allValues.join(', ');
|
||||
setAnswers((prev) => ({ ...prev, [field]: joined }));
|
||||
// Track free text if custom input was used
|
||||
if (custom) {
|
||||
setFreeTexts((prev) => ({ ...prev, [field]: joined }));
|
||||
}
|
||||
}
|
||||
setCustomInput('');
|
||||
goNext();
|
||||
},
|
||||
[selected, customInput, goNext],
|
||||
);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
updateMemoryMutation.mutate(
|
||||
{ memory: {}, markOnboarded: true },
|
||||
{
|
||||
onSuccess: () => void utils.auth.status.invalidate(),
|
||||
},
|
||||
);
|
||||
}, [updateMemoryMutation, utils]);
|
||||
|
||||
/** Check if the current step has at least one selection. */
|
||||
const hasSelection = useCallback(
|
||||
(field: string) => {
|
||||
const chips = selected[field];
|
||||
return (chips && chips.size > 0) || customInput.trim().length > 0;
|
||||
},
|
||||
[selected, customInput],
|
||||
);
|
||||
|
||||
// -- Reviewing step --
|
||||
|
||||
const startReview = useCallback(async () => {
|
||||
setStep('reviewing');
|
||||
|
||||
const chipAnswers = { ...answers };
|
||||
const freeTextAnswers: Record<string, string> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(freeTexts)) {
|
||||
if (val && answers[key] === val) {
|
||||
freeTextAnswers[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
setReviewValues({ ...chipAnswers });
|
||||
|
||||
if (Object.keys(freeTextAnswers).length > 0) {
|
||||
try {
|
||||
const normalized = await normalizeMutation.mutateAsync({
|
||||
inputs: freeTextAnswers,
|
||||
});
|
||||
setReviewValues((prev) => ({ ...prev, ...normalized }));
|
||||
setNormalizeError(false);
|
||||
} catch {
|
||||
setNormalizeError(true);
|
||||
}
|
||||
}
|
||||
}, [answers, freeTexts, normalizeMutation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setSaveError(false);
|
||||
const memory = { ...reviewValues };
|
||||
const fullName = [profile.name, profile.surname].filter(Boolean).join(' ');
|
||||
if (fullName) memory.user_name = fullName;
|
||||
updateMemoryMutation.mutate(
|
||||
{ memory, markOnboarded: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.onboarding.completed', { descriptionKey: 'toast.onboarding.completedDescription' });
|
||||
void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setSaveError(true);
|
||||
notifyError('toast.onboarding.error', err);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [reviewValues, profile, updateMemoryMutation, utils, notify, notifyError]);
|
||||
|
||||
const handleEditStart = useCallback(
|
||||
(key: string) => {
|
||||
setEditingField(key);
|
||||
setEditBuffer(reviewValues[key] ?? '');
|
||||
},
|
||||
[reviewValues],
|
||||
);
|
||||
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (editingField) {
|
||||
setReviewValues((prev) => ({ ...prev, [editingField]: editBuffer }));
|
||||
setEditingField(null);
|
||||
setEditBuffer('');
|
||||
}
|
||||
}, [editingField, editBuffer]);
|
||||
|
||||
// -- Past answers --
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
job_role: 'Role',
|
||||
industry: 'Industry',
|
||||
primary_use_case: 'Use case',
|
||||
tone_preference: 'Tone',
|
||||
language: 'Language',
|
||||
};
|
||||
const fieldOrder = ['job_role', 'industry', 'primary_use_case', 'tone_preference', 'language'];
|
||||
|
||||
const pastAnswers: { label: string; value: string }[] = [];
|
||||
for (const key of fieldOrder) {
|
||||
if (answers[key]) {
|
||||
pastAnswers.push({ label: fieldLabels[key], value: answers[key] });
|
||||
}
|
||||
}
|
||||
|
||||
// -- Detected language (human-readable) --
|
||||
const detectedLang = useMemo(() => {
|
||||
const raw =
|
||||
profile.memory?.language ??
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
// If it already looks like a display name (not a locale code), return as-is
|
||||
if (raw.length > 5 || !raw.includes('-')) {
|
||||
// Could be 'English', 'Italiano', or a bare code like 'en'
|
||||
try {
|
||||
const display = new Intl.DisplayNames([raw], { type: 'language' });
|
||||
return display.of(raw) ?? raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const display = new Intl.DisplayNames([raw], { type: 'language' });
|
||||
return display.of(raw) ?? raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}, [profile.memory?.language]);
|
||||
|
||||
// -- Step index for progress indicator --
|
||||
const stepIdx = STEP_ORDER.indexOf(step);
|
||||
const showBack = stepIdx > 1; // show back from jobRole onwards (not on welcome)
|
||||
|
||||
// -- Render --
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="w-full max-w-xl px-6 py-10">
|
||||
{/* Progress dots */}
|
||||
{step !== 'welcome' && step !== 'done' && (
|
||||
<div className="flex justify-center gap-1.5 mb-6">
|
||||
{STEP_ORDER.slice(1, -1).map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i < stepIdx - 1
|
||||
? 'w-6 bg-primary'
|
||||
: i === stepIdx - 1
|
||||
? 'w-6 bg-primary'
|
||||
: 'w-1.5 bg-muted-foreground/20',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={spring}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Past answers (user bubbles) */}
|
||||
{step !== 'welcome' && step !== 'reviewing' && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{pastAnswers.map(({ label, value }) => (
|
||||
<UserBubble key={label} text={`${label}: ${value}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── WELCOME ── */}
|
||||
{step === 'welcome' && (
|
||||
<>
|
||||
<AIBubble>
|
||||
<p>
|
||||
Hi <span className="font-medium">{displayName}</span>! I'm
|
||||
your AI assistant. Let me learn a few things about you so I can
|
||||
help better.
|
||||
</p>
|
||||
</AIBubble>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button onClick={goNext} size="sm">
|
||||
Let's go <ChevronRight size={14} className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── JOB ROLE ── */}
|
||||
{step === 'jobRole' && (
|
||||
<>
|
||||
<AIBubble>What's your role? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{JOB_ROLES.map((role) => (
|
||||
<Chip
|
||||
key={role}
|
||||
label={role}
|
||||
selected={isChipSelected('job_role', role)}
|
||||
onClick={() => toggleChip('job_role', role)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pl-11">
|
||||
<Input
|
||||
placeholder="Type your own…"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && hasSelection('job_role')) {
|
||||
commitAndNext('job_role');
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('job_role')}
|
||||
canNext={hasSelection('job_role')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── INDUSTRY ── */}
|
||||
{step === 'industry' && (
|
||||
<>
|
||||
<AIBubble>What industry do you work in? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{INDUSTRIES.map((ind) => (
|
||||
<Chip
|
||||
key={ind}
|
||||
label={ind}
|
||||
selected={isChipSelected('industry', ind)}
|
||||
onClick={() => toggleChip('industry', ind)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pl-11">
|
||||
<Input
|
||||
placeholder="Type your own…"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && hasSelection('industry')) {
|
||||
commitAndNext('industry');
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('industry')}
|
||||
canNext={hasSelection('industry')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── USE CASE ── */}
|
||||
{step === 'useCase' && (
|
||||
<>
|
||||
<AIBubble>How will you mainly use adiuvAI? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{USE_CASES.map((uc) => (
|
||||
<Chip
|
||||
key={uc}
|
||||
label={uc}
|
||||
selected={isChipSelected('primary_use_case', uc)}
|
||||
onClick={() => toggleChip('primary_use_case', uc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('primary_use_case')}
|
||||
canNext={hasSelection('primary_use_case')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── TONE ── */}
|
||||
{step === 'tone' && (
|
||||
<>
|
||||
<AIBubble>How should I talk to you? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{TONES.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
selected={isChipSelected('tone_preference', t)}
|
||||
onClick={() => toggleChip('tone_preference', t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('tone_preference')}
|
||||
canNext={hasSelection('tone_preference')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── LANGUAGE ── */}
|
||||
{step === 'language' && (
|
||||
<>
|
||||
<AIBubble>
|
||||
I'll respond in <span className="font-medium">{detectedLang}</span>.
|
||||
Want to change it?
|
||||
</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
<Chip
|
||||
label={`Keep ${detectedLang}`}
|
||||
selected={isChipSelected('language', detectedLang)}
|
||||
onClick={() => toggleChip('language', detectedLang)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pl-11">
|
||||
<Input
|
||||
placeholder="Type a language…"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && hasSelection('language')) {
|
||||
// Commit language, then go to review
|
||||
const chips = selected.language;
|
||||
const chipValues = chips ? [...chips] : [];
|
||||
const custom = customInput.trim();
|
||||
const allValues = [...chipValues];
|
||||
if (custom && !allValues.includes(custom)) allValues.push(custom);
|
||||
if (allValues.length > 0) {
|
||||
const joined = allValues.join(', ');
|
||||
setAnswers((prev) => ({ ...prev, language: joined }));
|
||||
if (custom) setFreeTexts((prev) => ({ ...prev, language: joined }));
|
||||
}
|
||||
setCustomInput('');
|
||||
void startReview();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => {
|
||||
// Commit language, then go to review
|
||||
const chips = selected.language;
|
||||
const chipValues = chips ? [...chips] : [];
|
||||
const custom = customInput.trim();
|
||||
const allValues = [...chipValues];
|
||||
if (custom && !allValues.includes(custom)) allValues.push(custom);
|
||||
if (allValues.length > 0) {
|
||||
const joined = allValues.join(', ');
|
||||
setAnswers((prev) => ({ ...prev, language: joined }));
|
||||
if (custom) setFreeTexts((prev) => ({ ...prev, language: joined }));
|
||||
}
|
||||
setCustomInput('');
|
||||
void startReview();
|
||||
}}
|
||||
canNext={hasSelection('language')}
|
||||
onSkip={handleSkip}
|
||||
nextLabel="Review"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── REVIEWING ── */}
|
||||
{step === 'reviewing' && (
|
||||
<>
|
||||
<AIBubble>Here's what I'll remember about you.</AIBubble>
|
||||
|
||||
{normalizeError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="ml-11 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
Couldn't auto-tidy — review and save as-is.
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<Card className="ml-11 rounded-xl">
|
||||
<CardContent className="px-5 py-4 flex flex-col gap-3">
|
||||
{fieldOrder.map((key) => {
|
||||
const value = reviewValues[key];
|
||||
if (!value) return null;
|
||||
const original = freeTexts[key];
|
||||
const wasTidied = original && original !== value;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fieldLabels[key]}
|
||||
</p>
|
||||
{editingField === key ? (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Input
|
||||
value={editBuffer}
|
||||
onChange={(e) => setEditBuffer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleEditConfirm();
|
||||
if (e.key === 'Escape') setEditingField(null);
|
||||
}}
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleEditConfirm}
|
||||
>
|
||||
<Check size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium">{value}</p>
|
||||
{wasTidied && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
auto-tidied from “{original}”
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editingField !== key && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => handleEditStart(key)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{normalizeMutation.isPending && (
|
||||
<div className="ml-11 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Tidying up…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div className="ml-11 text-xs text-red-500">
|
||||
Failed to save — please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 ml-11 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goBack}
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
disabled={updateMemoryMutation.isPending}
|
||||
>
|
||||
{updateMemoryMutation.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin mr-1.5" />
|
||||
) : null}
|
||||
Looks good — save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepNav({
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
canNext,
|
||||
onSkip,
|
||||
nextLabel = 'Next',
|
||||
}: {
|
||||
showBack: boolean;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
canNext: boolean;
|
||||
onSkip: () => void;
|
||||
nextLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-11 mt-2">
|
||||
{showBack && (
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ChevronLeft size={14} className="mr-1" /> Back
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={onNext} disabled={!canNext}>
|
||||
{nextLabel} <ChevronRight size={14} className="ml-1" />
|
||||
</Button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/renderer/components/onboarding/onboardingOptions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const JOB_ROLES = [
|
||||
'Developer',
|
||||
'Designer',
|
||||
'Consultant',
|
||||
'Founder',
|
||||
'Project Manager',
|
||||
] as const;
|
||||
|
||||
export const INDUSTRIES = [
|
||||
'Tech',
|
||||
'Design',
|
||||
'Consulting',
|
||||
'Legal',
|
||||
'Marketing',
|
||||
'Education',
|
||||
] as const;
|
||||
|
||||
export const USE_CASES = [
|
||||
'Solo freelancer',
|
||||
'Client manager',
|
||||
'Team lead',
|
||||
'Personal productivity',
|
||||
] as const;
|
||||
|
||||
export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'todo', label: 'To Do' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'done', label: 'Completed' },
|
||||
] as const;
|
||||
|
||||
type ColumnId = (typeof COLUMNS)[number]['id'];
|
||||
|
||||
type KanbanBoardProps = {
|
||||
projectId: string;
|
||||
newTaskOpen: boolean;
|
||||
onNewTaskOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||
const { state: floatingState } = useFloatingChat();
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
});
|
||||
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
});
|
||||
|
||||
// Edit / view task dialog state
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
// Group tasks by status (exclude unapproved AI suggestions)
|
||||
const columns = useMemo(() => {
|
||||
const tasks = (tasksList ?? []).filter(
|
||||
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
|
||||
);
|
||||
const grouped: Record<ColumnId, TaskItem[]> = {
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
done: [],
|
||||
};
|
||||
for (const task of tasks) {
|
||||
const status = (task.status ?? 'todo') as ColumnId;
|
||||
if (status in grouped) {
|
||||
grouped[status].push(task);
|
||||
} else {
|
||||
grouped.todo.push(task);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}, [tasksList]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
const { destination, source, draggableId } = result;
|
||||
if (!destination) return;
|
||||
if (destination.droppableId === source.droppableId) return;
|
||||
|
||||
updateTask.mutate({
|
||||
id: draggableId,
|
||||
status: destination.droppableId,
|
||||
});
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(taskId: string, currentStatus: string | null) => {
|
||||
const nextStatus =
|
||||
currentStatus === 'todo' ? 'in_progress' :
|
||||
currentStatus === 'in_progress' ? 'done' : 'todo';
|
||||
updateTask.mutate({ id: taskId, status: nextStatus });
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.id} className="flex flex-col gap-3">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{columns[col.id].length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Droppable column */}
|
||||
<Droppable droppableId={col.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`flex flex-col gap-2 min-h-[120px] rounded-md transition-colors ${
|
||||
snapshot.isDraggingOver ? 'bg-muted/50' : 'bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{columns[col.id].map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id}
|
||||
index={index}
|
||||
>
|
||||
{(dragProvided) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
onToggle={handleToggle}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
hideBreadcrumb
|
||||
layoutId={
|
||||
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||
? floatingState.morphTargetId
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
<NewTaskDialog
|
||||
open={newTaskOpen}
|
||||
onOpenChange={onNewTaskOpenChange}
|
||||
defaultProjectId={projectId}
|
||||
/>
|
||||
<EditTaskDialog
|
||||
task={editTask}
|
||||
open={!!editTask}
|
||||
onOpenChange={(open) => { if (!open) setEditTask(null); }}
|
||||
/>
|
||||
<TaskDetailDialog
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||