Compare commits
5 Commits
feature/ai
...
0fcfa3e5bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fcfa3e5bb | |||
| e8c8ddd48d | |||
| b77c6d1195 | |||
| 489e8e3bc9 | |||
| 1ba9c9eee2 |
@@ -104,14 +104,16 @@ Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tR
|
|||||||
|
|
||||||
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
|
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
|
||||||
|
|
||||||
### Token Storage (`token.ts`)
|
|
||||||
|
|
||||||
Three-tier fallback (keytar service name: `'adiuva'`):
|
|
||||||
1. keytar (OS keychain) — preferred; `keytarFailed` flag skips after first failure
|
|
||||||
2. electron-store + `safeStorage` — encrypted at rest
|
|
||||||
3. Plain electron-store — WSL fallback
|
|
||||||
|
|
||||||
### Vector Embeddings (`db/vectordb.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.
|
||||||
|
|
||||||
|
### Vector Embeddings (`src/main/db/vectordb.ts`)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
124
.gitea/workflows/build.yaml
Normal file
124
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
name: Release Electron App
|
||||||
|
run-name: Releasing ${{ gitea.ref_name }}
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-desktop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: electronuserland/builder:wine
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install System Dependencies for Linux Makers
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y fakeroot dpkg mono-complete
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set version from tag
|
||||||
|
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Make App (Linux)
|
||||||
|
run: npm run make -- --platform=linux --arch=x64
|
||||||
|
|
||||||
|
- name: Initialize Wine
|
||||||
|
run: |
|
||||||
|
export WINEDEBUG=-all
|
||||||
|
export DISPLAY=
|
||||||
|
wineboot --init
|
||||||
|
env:
|
||||||
|
WINEDEBUG: '-all'
|
||||||
|
|
||||||
|
- name: Make App (Windows)
|
||||||
|
run: npm run make -- --platform=win32 --arch=x64
|
||||||
|
env:
|
||||||
|
WINEDEBUG: '-all'
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
run: |
|
||||||
|
GITEA_URL="http://10.0.0.119:3000"
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
TOKEN="${{ gitea.token }}"
|
||||||
|
|
||||||
|
# Check if release exists, create if not
|
||||||
|
RELEASE_ID=$(curl -sf \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
RELEASE_ID=$(curl -sf \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
|
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload Release Assets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
GITEA_URL="http://10.0.0.119:3000"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
TOKEN="${{ gitea.token }}"
|
||||||
|
MAX_RETRIES=3
|
||||||
|
|
||||||
|
upload_file() {
|
||||||
|
local file="$1"
|
||||||
|
local name
|
||||||
|
name=$(basename "$file")
|
||||||
|
local encoded_name
|
||||||
|
encoded_name=$(printf '%s' "$name" | sed 's/ /%20/g')
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
while [ $attempt -le $MAX_RETRIES ]; do
|
||||||
|
local filesize
|
||||||
|
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
|
||||||
|
echo "Uploading ${name} (${filesize} bytes, attempt ${attempt}/${MAX_RETRIES})..."
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
--max-time 300 \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Expect:" \
|
||||||
|
-F "attachment=@${file}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | head -n -1)
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -ge 200 ] 2>/dev/null && [ "$HTTP_CODE" -lt 300 ] 2>/dev/null; then
|
||||||
|
echo "✅ Uploaded ${name}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⚠️ Upload failed (HTTP ${HTTP_CODE}), body: ${BODY}"
|
||||||
|
echo "Retrying in 5s..."
|
||||||
|
sleep 5
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ Failed to upload ${name} after ${MAX_RETRIES} attempts"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
upload_file "$file" || FAILED=1
|
||||||
|
done < <(find out/make -type f \( -name "*.exe" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) -print0)
|
||||||
|
|
||||||
|
if [ $FAILED -eq 1 ]; then
|
||||||
|
echo "Some uploads failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# AI Refactor Plan — Adiuva Electron App
|
# AI Refactor Plan — Adiuva Electron App
|
||||||
|
|
||||||
> **Objective:** Transform the Electron app from a single-process AI integration into a local-first multi-agent client with plugin-based batch agents, multi-provider LLM support, E2E encrypted backup, granular permissions, and cloud backend integration.
|
> **Objective:** Transform the Electron app into a hybrid-first multi-agent client. The user controls where data is stored (local / cloud / sync), which AI provider to use (BYOK multi-provider), and which automations to run — either custom batch agents built with the LLM-powered Batch Builder, or pre-built plugins from the marketplace. All data access is opt-in, transparent, and auditable.
|
||||||
>
|
>
|
||||||
> **Backend:** Lives in a separate repository. See `BACKEND_PLAN.md` for the API contract and backend implementation guide.
|
> **Backend:** Lives in a separate repository. See `../adiuva-api/BACKEND_PLAN.md` for the API contract and backend implementation guide.
|
||||||
>
|
>
|
||||||
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
|
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
### Step 0.1 — Define backend API contract types
|
### Step 0.1 — Define backend API contract types
|
||||||
- [ ] Create `src/shared/api-types.ts` with all interfaces the Electron app needs to communicate with the backend:
|
- [ ] Create `src/shared/api-types.ts` with all interfaces the Electron app needs to communicate with the backend:
|
||||||
- `ExecutionPlan`, `PlanStep`, `PlanAction` (action types: `create_record`, `update_record`, `delete_record`, `index_document`, `send_notification`)
|
- `ExecutionPlan`, `PlanStep`, `PlanAction` (action types: `create_record`, `update_record`, `delete_record`, `index_document`, `send_notification`, `call_agent`)
|
||||||
- `ChatRequest` (message, context, execution_mode: `'direct'` | `'plan'`)
|
- `ChatRequest` (message, context, execution_mode: `'direct'` | `'plan'`)
|
||||||
- `ChatResponse` (response, actions)
|
- `ChatResponse` (response, actions)
|
||||||
- `ChatContext` (user_profile, relevant_documents, recent_tasks, conversation_history)
|
- `ChatContext` (user_profile, relevant_documents, recent_tasks, conversation_history)
|
||||||
@@ -22,9 +22,25 @@
|
|||||||
- `BillingTier` enum (`free`, `pro`, `power`, `team`)
|
- `BillingTier` enum (`free`, `pro`, `power`, `team`)
|
||||||
- `AuthTokens` (access_token, refresh_token, expires_at)
|
- `AuthTokens` (access_token, refresh_token, expires_at)
|
||||||
- `UserProfile` (id, email, tier)
|
- `UserProfile` (id, email, tier)
|
||||||
|
- [ ] Create `src/shared/batch-types.ts` with all types for the batch builder and storage layer:
|
||||||
|
- `StorageTarget` — `'local'` | `'cloud'` | `'sync'` | `'none'`
|
||||||
|
- `ConnectorType` — `'imap'` | `'filesystem'` | `'calendar'` | `'api'` | `'gmail'` | `'gdrive'` | `'outlook'`
|
||||||
|
- `BatchActionType` — `'create_record'` | `'update_record'` | `'delete_record'` | `'index_document'` | `'send_notification'` | `'call_agent'`
|
||||||
|
- `BatchSource` — `{ connector: ConnectorType, config: Record<string, unknown> }`
|
||||||
|
- `BatchTrigger` — `{ type: 'cron' | 'event', schedule?: string, timezone?: string }`
|
||||||
|
- `BatchAnalysis` — `{ prompt: string, model_override?: string, output_schema?: object }`
|
||||||
|
- `BatchAction` — `{ type: BatchActionType, table?: string, mapping?: Record<string, string> }`
|
||||||
|
- `BatchStorage` — `{ records: StorageTarget, vectors: StorageTarget, raw_data: StorageTarget }`
|
||||||
|
- `BatchConfig` — full config object: `id`, `name`, `description`, `enabled`, `source`, `trigger`, `analysis`, `actions`, `storage`, `permissions`
|
||||||
|
- `BatchStatus` — `'idle'` | `'running'` | `'error'` | `'disabled'`
|
||||||
|
- `BatchRunResult` — `{ batchId, runAt, status, itemsProcessed, errors }`
|
||||||
|
- `PluginListing` — `{ id, name, description, author, version, rating, installs, category, permissions, price }`
|
||||||
|
- `InstalledPlugin` — `{ listing: PluginListing, installedAt, enabled, storageConfig: BatchStorage }`
|
||||||
|
- `DataSourceInfo` — `{ type: ConnectorType, label, recordCount, sizeBytes, storageTarget: StorageTarget }`
|
||||||
|
- `StorageStats` — `{ localUsedBytes, cloudUsedBytes, cloudLimitBytes, sources: DataSourceInfo[] }`
|
||||||
- [ ] Update `tsconfig.json` paths if needed to include `src/shared/`
|
- [ ] Update `tsconfig.json` paths if needed to include `src/shared/`
|
||||||
- **Files:** `src/shared/api-types.ts`, `tsconfig.json`
|
- **Files:** `src/shared/api-types.ts`, `src/shared/batch-types.ts`, `tsconfig.json`
|
||||||
- **Outcome:** Type-safe contracts for all backend communication. Backend repo mirrors these as Pydantic schemas.
|
- **Outcome:** Type-safe contracts for all backend communication and the batch/storage subsystem. Backend repo mirrors these as Pydantic schemas.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -279,55 +295,173 @@
|
|||||||
|
|
||||||
## Phase 6 — Renderer UI Updates
|
## Phase 6 — Renderer UI Updates
|
||||||
|
|
||||||
### Step 6.1 — Update Settings page for multi-provider config
|
> **Navigation model:** The app has a sidebar with top-level routes matching the pages below. Each page is a full-screen view. Shared hooks live in `src/renderer/hooks/`. All data access goes through tRPC procedures — no direct IPC calls from components.
|
||||||
- [ ] Add provider management UI to Settings:
|
|
||||||
- List of configured providers with status (active/inactive/error)
|
|
||||||
- Add provider form: name dropdown (OpenAI, Anthropic, Google, Mistral, Groq, Ollama), API key input, model selection, endpoint (for Ollama)
|
|
||||||
- Set primary and fallback providers
|
|
||||||
- Test connection button per provider
|
|
||||||
- [ ] Add auth section to Settings:
|
|
||||||
- Login/register form
|
|
||||||
- Current tier display with upgrade CTA
|
|
||||||
- Logout button
|
|
||||||
- [ ] Add backup section to Settings:
|
|
||||||
- Create/view recovery passphrase
|
|
||||||
- Manual backup trigger
|
|
||||||
- Backup history with restore points
|
|
||||||
- Auto-backup schedule toggle
|
|
||||||
- **Files:** `src/renderer/components/settings/` (new), route file
|
|
||||||
- **Outcome:** Users can manage AI providers, auth, and backups from Settings.
|
|
||||||
|
|
||||||
### Step 6.2 — Add Permission Dialog and Activity Log
|
### Step 6.1 — Restructure app shell and routing
|
||||||
- [ ] Create `src/renderer/components/permissions/PermissionDialog.tsx`:
|
- [ ] Update `src/renderer/App.tsx`:
|
||||||
- Modal shown when a plugin requests new permissions
|
- Define top-level routes: `/chat`, `/batch-builder`, `/plugins`, `/data-manager`, `/settings`, `/activity`
|
||||||
- Lists requested permissions with reasons
|
- Add sidebar navigation with icons and labels for each route
|
||||||
- Per-permission approve/deny toggles
|
- Persist last active route in electron-store
|
||||||
- Shows plugin manifest info (name, description, version)
|
- [ ] Create `src/renderer/hooks/useProvider.ts`:
|
||||||
- [ ] Create `src/renderer/components/permissions/ActivityLog.tsx`:
|
- `useProvider()` — returns active provider config, `setProvider()`, `testProvider()`, list of configured providers
|
||||||
- Filterable table of all plugin activity
|
- Backed by tRPC `provider.*` procedures (to be added in Phase 1)
|
||||||
- Columns: timestamp, plugin name, action type, resource, status
|
- [ ] Create `src/renderer/hooks/useStorage.ts`:
|
||||||
- Filter by plugin, date range, action type
|
- `useStorage()` — returns `StorageStats`, `setStorageTarget(source, target)`, `migrateData(source, from, to)`
|
||||||
- Export as CSV
|
- Backed by tRPC `storage.*` procedures (to be added in Phase 2)
|
||||||
- [ ] Add tRPC procedures for permission management and activity log queries
|
- **Files:** `src/renderer/App.tsx`, `src/renderer/hooks/useProvider.ts`, `src/renderer/hooks/useStorage.ts`
|
||||||
- **Files:** `src/renderer/components/permissions/PermissionDialog.tsx`, `src/renderer/components/permissions/ActivityLog.tsx`, `src/main/router/index.ts`
|
- **Outcome:** App shell with all top-level routes and shared data hooks.
|
||||||
- **Outcome:** Transparent permission system with full activity audit trail.
|
|
||||||
|
|
||||||
### Step 6.3 — Update AIChatPanel for backend-powered chat
|
### Step 6.2 — ChatPage with context panel
|
||||||
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
|
- [ ] Create `src/renderer/pages/ChatPage.tsx`:
|
||||||
- Support WebSocket streaming from backend (when online)
|
- Two-column layout: chat area (left/main) + collapsible `ContextPanel` (right)
|
||||||
- Fall back to IPC streaming (when offline, using local orchestrator)
|
- Wraps `ChatWindow` and `ContextPanel` components
|
||||||
- Add connection status indicator (online/offline/reconnecting)
|
- Online/offline status bar at top
|
||||||
- Support execution plan responses: show plan preview, allow user to approve/modify before execution
|
- [ ] Create `src/renderer/components/chat/ChatWindow.tsx`:
|
||||||
- [ ] Update `src/renderer/components/ai/AIChatPanel.tsx`:
|
- Message list rendering `MessageBubble` for each entry
|
||||||
- Add connection status badge
|
- Input bar with send button and attachment support
|
||||||
- Add tier indicator (shows current plan limitations)
|
- Handles streaming tokens from `useChat` hook
|
||||||
- Plan approval UI: expandable plan steps with approve/reject buttons
|
- Plan approval UI inline: expandable plan steps with approve/reject per-step
|
||||||
- Enhanced error states: differentiate between offline, auth expired, rate limited, server error
|
- Error states: offline, auth expired, rate limited, server error (distinct UI for each)
|
||||||
- [ ] Update `src/renderer/components/ai/FloatingChat.tsx`:
|
- [ ] Create `src/renderer/components/chat/MessageBubble.tsx`:
|
||||||
- Same streaming changes as AIChatPanel
|
- Renders user / assistant / system messages
|
||||||
- Compact plan approval for inline context
|
- Supports markdown rendering for assistant messages
|
||||||
- **Files:** `src/renderer/hooks/useAIChat.ts`, `src/renderer/components/ai/AIChatPanel.tsx`, `src/renderer/components/ai/FloatingChat.tsx`
|
- Shows tool-call indicators when the agent uses a tool
|
||||||
- **Outcome:** Chat UI seamlessly handles both online (backend) and offline (local) modes.
|
- Timestamp and copy-to-clipboard action
|
||||||
|
- [ ] Create `src/renderer/components/chat/ContextPanel.tsx`:
|
||||||
|
- Shows what context the agent used for the last response: matched documents, recent tasks, memory entries
|
||||||
|
- Each context item links to its source (note, file, batch result)
|
||||||
|
- Collapsible, persists open/closed state
|
||||||
|
- [ ] Create `src/renderer/hooks/useChat.ts`:
|
||||||
|
- `useChat(sessionId)` — message list, `sendMessage()`, streaming state, connection mode (`'backend'` | `'local'`)
|
||||||
|
- Automatically falls back to local orchestrator when offline
|
||||||
|
- Exposes `approveStep(stepId)` / `rejectStep(stepId)` for plan execution
|
||||||
|
- **Files:** `src/renderer/pages/ChatPage.tsx`, `src/renderer/components/chat/ChatWindow.tsx`, `src/renderer/components/chat/MessageBubble.tsx`, `src/renderer/components/chat/ContextPanel.tsx`, `src/renderer/hooks/useChat.ts`
|
||||||
|
- **Outcome:** Full chat UI with context transparency, plan approval, and seamless online/offline fallback.
|
||||||
|
|
||||||
|
### Step 6.3 — BatchBuilderPage
|
||||||
|
- [ ] Create `src/renderer/pages/BatchBuilderPage.tsx`:
|
||||||
|
- Two views: **Active Batches** list (default) and **Create New Batch** wizard
|
||||||
|
- Active list renders `BatchCard` for each active batch config
|
||||||
|
- "Create" button opens the wizard
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/NaturalLanguageInput.tsx`:
|
||||||
|
- Textarea where the user describes the batch in plain language
|
||||||
|
- "Generate" button calls `useBatchBuilder().generate(description)`
|
||||||
|
- Loading skeleton while the LLM generates the config
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/ConfigPreview.tsx`:
|
||||||
|
- Shows the generated `BatchConfig` as an editable form (not raw JSON)
|
||||||
|
- Sections: Source, Trigger, Analysis, Actions, Storage — each collapsible
|
||||||
|
- Inline editing for every field (prompt textarea, cron expression with human-readable label, mapping table)
|
||||||
|
- "Edit raw JSON" toggle for power users
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/ConnectorPicker.tsx`:
|
||||||
|
- Dropdown of available connector types (IMAP, Filesystem, Gmail, GDrive, Outlook, Calendar, Generic API)
|
||||||
|
- When selected, shows connector-specific config fields (e.g. IMAP: host, folder, filter_from; Filesystem: path picker)
|
||||||
|
- OAuth connectors show "Connect account" button that opens the OAuth flow
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/StoragePicker.tsx`:
|
||||||
|
- Three-way toggle per storage dimension: **Local** / **Cloud** / **Sync** / **None**
|
||||||
|
- Dimensions: Records, Vectors, Raw data
|
||||||
|
- Shows storage impact estimate per option
|
||||||
|
- Disabled options grayed out with tier tooltip if current tier doesn't support cloud
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/SchedulePicker.tsx`:
|
||||||
|
- Mode toggle: **Cron** (with human-readable label, e.g. "Every day at 08:00") / **Event** (on new data from connector)
|
||||||
|
- Timezone selector (defaults to system timezone)
|
||||||
|
- Visual cron builder for non-technical users (with raw cron input fallback)
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/BatchCard.tsx`:
|
||||||
|
- Shows batch name, connector icon, last run time, next run time, status badge (`idle` / `running` / `error` / `disabled`)
|
||||||
|
- Actions: Run now, Edit, Disable/Enable, Delete
|
||||||
|
- Expandable to show last run summary (items processed, errors)
|
||||||
|
- [ ] Create `src/renderer/components/batch-builder/BatchTestRunner.tsx`:
|
||||||
|
- "Dry Run" panel: picks one real item from the source, runs the full analysis pipeline, shows output without saving
|
||||||
|
- Shows LLM output, action mapping preview, what would be stored and where
|
||||||
|
- Pass/Fail indicator with detailed error on failure
|
||||||
|
- [ ] Create `src/renderer/hooks/useBatchBuilder.ts`:
|
||||||
|
- `useBatchBuilder()` — `generate(description): Promise<BatchConfig>`, `validate(config)`, `save(config)`, `activate(id)`, `deactivate(id)`, `runNow(id)`, `dryRun(id)`, `delete(id)`, list of saved configs with live status
|
||||||
|
- Backed by tRPC `batch.*` procedures
|
||||||
|
- **Files:** `src/renderer/pages/BatchBuilderPage.tsx`, `src/renderer/components/batch-builder/{NaturalLanguageInput,ConfigPreview,ConnectorPicker,StoragePicker,SchedulePicker,BatchCard,BatchTestRunner}.tsx`, `src/renderer/hooks/useBatchBuilder.ts`
|
||||||
|
- **Outcome:** Full Batch Builder UI — users can describe a batch in natural language, review/edit the generated config, dry-run it, and activate it with a single flow.
|
||||||
|
|
||||||
|
### Step 6.4 — PluginStorePage
|
||||||
|
- [ ] Create `src/renderer/pages/PluginStorePage.tsx`:
|
||||||
|
- Two tabs: **Marketplace** (browse available plugins) and **Installed** (manage installed plugins)
|
||||||
|
- Marketplace: search bar, category filter chips, grid of plugin cards sorted by rating/installs
|
||||||
|
- Installed: list of `InstalledPlugin` entries with enable/disable toggles and settings links
|
||||||
|
- [ ] Create plugin card component (inline or shared `common/`):
|
||||||
|
- Shows name, author, description, rating (stars), install count, category badge, price/free badge
|
||||||
|
- "Install" button → triggers permission request dialog → installs plugin
|
||||||
|
- "Settings" button (installed) → opens plugin-specific config drawer
|
||||||
|
- [ ] Plugin install flow:
|
||||||
|
- On install click: fetch plugin manifest from backend
|
||||||
|
- Show `PermissionDialog` with the permissions the plugin requires
|
||||||
|
- On approve: call tRPC `plugins.install(id)`, download and register the plugin worker
|
||||||
|
- Show `StoragePicker` for the plugin's data (what goes local/cloud/sync)
|
||||||
|
- **Files:** `src/renderer/pages/PluginStorePage.tsx`
|
||||||
|
- **Outcome:** Users can discover and install pre-built plugins from the marketplace with full permission visibility.
|
||||||
|
|
||||||
|
### Step 6.5 — DataManagerPage
|
||||||
|
- [ ] Create `src/renderer/pages/DataManagerPage.tsx`:
|
||||||
|
- Top section: `StorageOverview` dashboard
|
||||||
|
- Below: list of `DataSourceCard` for each active data source (one card per connector/plugin)
|
||||||
|
- "Migrate" button opens `MigrationWizard`
|
||||||
|
- [ ] Create `src/renderer/components/data-manager/StorageOverview.tsx`:
|
||||||
|
- Visual breakdown: local disk used vs. cloud used vs. cloud limit
|
||||||
|
- Per-category breakdown (emails, files, notes, calendar, vectors)
|
||||||
|
- Tier upgrade CTA if approaching cloud limit
|
||||||
|
- [ ] Create `src/renderer/components/data-manager/DataSourceCard.tsx`:
|
||||||
|
- Card per data source (e.g. "Gmail Scanner", "Documenti/Fatture watcher")
|
||||||
|
- Shows record count, size, last sync time
|
||||||
|
- Inline `StoragePicker` toggle for that source (where its data lives)
|
||||||
|
- "Clear local cache" / "Delete all data" actions with confirmation
|
||||||
|
- [ ] Create `src/renderer/components/data-manager/MigrationWizard.tsx`:
|
||||||
|
- Step wizard: select source → select direction (local → cloud or cloud → local) → confirm
|
||||||
|
- Shows estimated data size and time
|
||||||
|
- Progress indicator during migration
|
||||||
|
- Rolls back on error
|
||||||
|
- **Files:** `src/renderer/pages/DataManagerPage.tsx`, `src/renderer/components/data-manager/{StorageOverview,DataSourceCard,MigrationWizard}.tsx`
|
||||||
|
- **Outcome:** Users have full visibility and control over where every piece of their data lives.
|
||||||
|
|
||||||
|
### Step 6.6 — ActivityLogPage
|
||||||
|
- [ ] Create `src/renderer/pages/ActivityLogPage.tsx`:
|
||||||
|
- Full-page filterable table of all batch/plugin activity entries
|
||||||
|
- Columns: timestamp, source (batch name / plugin name), action type, data accessed, storage destination, status
|
||||||
|
- Filters: source, date range, action type, status (success/error)
|
||||||
|
- Row expand: shows full detail — which records were created/updated, which files were read, LLM calls made
|
||||||
|
- Export as CSV button
|
||||||
|
- **Files:** `src/renderer/pages/ActivityLogPage.tsx`
|
||||||
|
- **Outcome:** Complete transparency log so users can audit exactly what each agent did and when.
|
||||||
|
|
||||||
|
### Step 6.7 — SettingsPage (multi-provider, auth, backup, embeddings)
|
||||||
|
- [ ] Create `src/renderer/pages/SettingsPage.tsx` with tabbed sections:
|
||||||
|
- **AI Providers** tab:
|
||||||
|
- List of configured providers with status badge (active / inactive / error)
|
||||||
|
- Add provider form: name dropdown (OpenAI, Anthropic, Google, Mistral, Groq, Ollama), API key input, model selection, endpoint (for Ollama)
|
||||||
|
- Set primary provider and fallback chain
|
||||||
|
- Test connection button per provider
|
||||||
|
- Separate "Embeddings provider" section: provider + model for embeddings (OpenAI, Cohere, Voyage, Mistral Embed)
|
||||||
|
- Info callout: "Text sent to the embeddings provider to generate vectors — make sure you trust this provider with your data"
|
||||||
|
- **Account & Billing** tab:
|
||||||
|
- Login/register form (when not authenticated)
|
||||||
|
- Current tier display with feature list and upgrade CTA
|
||||||
|
- Usage indicators (batch count, cloud storage used)
|
||||||
|
- Logout button
|
||||||
|
- **Backup & Sync** tab:
|
||||||
|
- Recovery passphrase: generate new / view existing (masked, reveal on click)
|
||||||
|
- Manual backup trigger with last backup timestamp
|
||||||
|
- Auto-backup schedule toggle + interval picker
|
||||||
|
- Backup history table (timestamp, size, restore button)
|
||||||
|
- **Permissions** tab:
|
||||||
|
- Table of all active permission grants (plugin/batch, permission type, resource, granted date)
|
||||||
|
- Revoke button per grant
|
||||||
|
- Links to ActivityLogPage for per-source audit
|
||||||
|
- [ ] Create `src/renderer/components/common/ProviderSelector.tsx`:
|
||||||
|
- Reusable dropdown that lists configured LLM providers
|
||||||
|
- Used in BatchBuilder (model_override field) and Settings
|
||||||
|
- [ ] Create `src/renderer/components/common/PermissionDialog.tsx`:
|
||||||
|
- Modal triggered when a plugin/batch requests new permissions
|
||||||
|
- Lists each requested permission with its reason and resource path
|
||||||
|
- Per-permission approve/deny toggles (deny is default)
|
||||||
|
- Shows plugin/batch manifest info (name, description, version)
|
||||||
|
- "Approve selected" confirms; "Deny all" closes without granting
|
||||||
|
- **Files:** `src/renderer/pages/SettingsPage.tsx`, `src/renderer/components/common/PermissionDialog.tsx`, `src/renderer/components/common/ProviderSelector.tsx`
|
||||||
|
- **Outcome:** Centralised settings covering providers, embeddings, auth, backup, and permissions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -375,8 +509,10 @@
|
|||||||
| `argon2` | Key derivation for E2E backup |
|
| `argon2` | Key derivation for E2E backup |
|
||||||
| `node-cron` | Batch agent scheduling |
|
| `node-cron` | Batch agent scheduling |
|
||||||
| `chokidar` | File watching (FileWatcher plugin) |
|
| `chokidar` | File watching (FileWatcher plugin) |
|
||||||
| `imapflow` | IMAP client (EmailScanner plugin) |
|
| `imapflow` | IMAP client (IMAP connector) |
|
||||||
| `onnxruntime-node` | Local embeddings (optional) |
|
| `googleapis` | Gmail + GDrive OAuth connectors |
|
||||||
|
| `lancedb` | Local vector store |
|
||||||
|
| `onnxruntime-node` | Local embeddings (optional, future) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -387,3 +523,4 @@
|
|||||||
- **Phase 3** (backend integration) requires the backend repo to have the `/api/v1/chat` endpoint ready.
|
- **Phase 3** (backend integration) requires the backend repo to have the `/api/v1/chat` endpoint ready.
|
||||||
- **Phase 5.2** (SQLCipher) is intentionally late to avoid encryption overhead during active schema changes.
|
- **Phase 5.2** (SQLCipher) is intentionally late to avoid encryption overhead during active schema changes.
|
||||||
- **The existing app continues to work** throughout the migration. Local orchestration is preserved until backend is ready (Step 3.2).
|
- **The existing app continues to work** throughout the migration. Local orchestration is preserved until backend is ready (Step 3.2).
|
||||||
|
- **One step at a time.** Implement one numbered step per session. When the step is fully done, mark all its checkboxes as `[x]` in this file and commit with message `step N complete: <outcome line>`.
|
||||||
|
|||||||
358
BACKEND_PLAN.md
358
BACKEND_PLAN.md
@@ -1,358 +0,0 @@
|
|||||||
# Backend Plan — Adiuva Cloud API
|
|
||||||
|
|
||||||
> **Separate repository.** This document defines the FastAPI backend that the Electron app communicates with.
|
|
||||||
>
|
|
||||||
> The backend owns: orchestration logic, chat agent intelligence, prompt IP, auth, billing, and backup blob storage.
|
|
||||||
> The backend NEVER persists user data. It receives context in requests, uses it for orchestration, and discards it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
adiuva-backend/
|
|
||||||
├── app/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── main.py # FastAPI entry + CORS + lifespan + router includes
|
|
||||||
│ ├── core/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── agent_registry.py # Base classes + singleton registry
|
|
||||||
│ │ ├── orchestrator.py # LLM-based intent router
|
|
||||||
│ │ ├── execution_plan.py # Plan builder + cache
|
|
||||||
│ │ └── plugin_loader.py # Dynamic agent loading
|
|
||||||
│ ├── agents/
|
|
||||||
│ │ ├── __init__.py # Auto-registers all agents
|
|
||||||
│ │ ├── task_agent.py
|
|
||||||
│ │ ├── calendar_agent.py
|
|
||||||
│ │ ├── email_agent.py
|
|
||||||
│ │ └── analytics_agent.py
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── routes/
|
|
||||||
│ │ │ ├── __init__.py
|
|
||||||
│ │ │ ├── chat.py # POST /chat + WS /chat/stream
|
|
||||||
│ │ │ ├── plans.py # GET /plans/playbook
|
|
||||||
│ │ │ ├── backup.py # PUT/GET /backup
|
|
||||||
│ │ │ ├── auth.py # Register/login/refresh
|
|
||||||
│ │ │ └── billing.py # Checkout/webhook/subscription
|
|
||||||
│ │ └── middleware/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── auth.py # JWT validation
|
|
||||||
│ │ ├── rate_limit.py # Tier-aware rate limiting
|
|
||||||
│ │ └── sanitizer.py # Strip prompt metadata from responses
|
|
||||||
│ ├── billing/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── stripe_service.py # Stripe checkout + webhooks
|
|
||||||
│ │ └── tier_manager.py # Feature matrix per tier
|
|
||||||
│ └── config/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── settings.py # Pydantic BaseSettings (env-based)
|
|
||||||
├── tests/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── conftest.py # Fixtures: test client, mock agents, mock LLM
|
|
||||||
│ ├── test_orchestrator.py
|
|
||||||
│ ├── test_agents.py
|
|
||||||
│ ├── test_auth.py
|
|
||||||
│ └── test_backup.py
|
|
||||||
├── alembic/ # DB migrations (auth/billing tables only)
|
|
||||||
│ ├── alembic.ini
|
|
||||||
│ └── versions/
|
|
||||||
├── requirements.txt
|
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml # App + PostgreSQL + Redis (dev)
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step-by-Step Implementation
|
|
||||||
|
|
||||||
### Step 1 — Project scaffolding
|
|
||||||
- [ ] Initialize repo with the directory structure above
|
|
||||||
- [ ] Write `requirements.txt`:
|
|
||||||
```
|
|
||||||
fastapi>=0.115.0
|
|
||||||
uvicorn[standard]>=0.34.0
|
|
||||||
langchain>=0.3.0
|
|
||||||
langchain-openai>=0.3.0
|
|
||||||
pydantic>=2.10.0
|
|
||||||
python-jose[cryptography]>=3.3.0
|
|
||||||
stripe>=11.0.0
|
|
||||||
boto3>=1.35.0
|
|
||||||
slowapi>=0.1.9
|
|
||||||
sqlalchemy>=2.0.0
|
|
||||||
asyncpg>=0.30.0
|
|
||||||
alembic>=1.14.0
|
|
||||||
bcrypt>=4.2.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
httpx>=0.28.0
|
|
||||||
websockets>=14.0
|
|
||||||
pytest>=8.0.0
|
|
||||||
pytest-asyncio>=0.24.0
|
|
||||||
```
|
|
||||||
- [ ] Write `app/main.py`: FastAPI app with CORS (allow `app://`, `http://localhost:*`), lifespan (init DB pool, init agent registry), include all routers under `/api/v1`
|
|
||||||
- [ ] Write `app/config/settings.py`: `Settings(BaseSettings)` with fields: `DATABASE_URL`, `JWT_SECRET`, `JWT_ALGORITHM` (default HS256), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `S3_BUCKET`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `CORS_ORIGINS`, `ENV` (dev/prod)
|
|
||||||
- [ ] Write `Dockerfile`: Python 3.12 slim, multi-stage (builder + runtime), non-root user
|
|
||||||
- [ ] Write `docker-compose.yml`: app, postgres:16, optional redis
|
|
||||||
- [ ] Write `.env.example`
|
|
||||||
- **Outcome:** Runnable FastAPI skeleton (returns 404 on all routes).
|
|
||||||
|
|
||||||
### Step 2 — Pydantic schemas (API contracts)
|
|
||||||
- [ ] Create `app/schemas.py` (mirrors `src/shared/api-types.ts` from Electron repo):
|
|
||||||
- `ChatRequest`: `message: str`, `context: ChatContext`, `execution_mode: Literal['direct', 'plan']`
|
|
||||||
- `ChatContext`: `user_profile: dict`, `relevant_documents: list[str]`, `recent_tasks: list[dict]`, `conversation_history: list[dict]`
|
|
||||||
- `ChatResponse`: `response: str`, `actions: list[PlanAction]`
|
|
||||||
- `PlanAction`: `type: Literal['create_record', 'update_record', 'delete_record', 'index_document', 'send_notification']`, `table: str | None`, `data: dict | None`
|
|
||||||
- `ExecutionPlan`: `agent: str`, `steps: list[PlanStep]`
|
|
||||||
- `PlanStep`: `action: str`, `prompt_template: str | None`, `variables: dict | None`, `data_from_step: int | None`
|
|
||||||
- `BackupMetadata`: `version: int`, `timestamp: int`, `checksum: str`, `chunk_count: int`
|
|
||||||
- `BillingTier`: `Literal['free', 'pro', 'power', 'team']`
|
|
||||||
- `AuthTokens`: `access_token: str`, `refresh_token: str`, `expires_at: int`
|
|
||||||
- `UserProfile`: `id: str`, `email: str`, `tier: BillingTier`
|
|
||||||
- **Outcome:** All request/response models defined and validated.
|
|
||||||
|
|
||||||
### Step 3 — Agent Registry + base classes
|
|
||||||
- [ ] `app/core/agent_registry.py`:
|
|
||||||
- `BaseAgent(ABC)`:
|
|
||||||
- `user_id: str`, `shared_memory: dict`, `vector_store_context: list[str]`, `skills: list[str]`
|
|
||||||
- Abstract `get_name() -> str`, `get_description() -> str`
|
|
||||||
- `ChatAgent(BaseAgent)`:
|
|
||||||
- Abstract `async handle(query: str, context: dict) -> str`
|
|
||||||
- Abstract `get_tools() -> list` (LangChain tool definitions)
|
|
||||||
- Concrete `_tool_loop(llm, messages, tools, max_iter=5) -> str` — shared tool-calling loop
|
|
||||||
- `AgentRegistry` (singleton):
|
|
||||||
- `_agents: dict[str, ChatAgent]`
|
|
||||||
- `register(agent_class)` — decorator pattern
|
|
||||||
- `get(name) -> ChatAgent`
|
|
||||||
- `list_agents() -> list[dict]` — returns `[{name, description}]` for orchestrator prompt
|
|
||||||
- `async call_agent(name, query, context) -> str` — for inter-agent calls
|
|
||||||
- [ ] Unit tests: register, get, list, call_agent with mock
|
|
||||||
- **Outcome:** Pluggable agent framework.
|
|
||||||
|
|
||||||
### Step 4 — Orchestrator
|
|
||||||
- [ ] `app/core/orchestrator.py`:
|
|
||||||
- `async classify_intent(message, context, registry) -> str`:
|
|
||||||
- System prompt: "You are an intent classifier. Given the user message and context, decide which agent to route to. Available agents: {registry.list_agents()}. Respond with just the agent name."
|
|
||||||
- Uses gpt-4o-mini via LangChain for low latency
|
|
||||||
- Falls back to `task_agent` if no clear match
|
|
||||||
- `async route_single(agent_name, message, context) -> ChatResponse`:
|
|
||||||
- Instantiates agent from registry
|
|
||||||
- Calls `agent.handle(message, context)`
|
|
||||||
- Returns response + any actions the agent produced
|
|
||||||
- `async route_pipeline(agent_names, message, context) -> ChatResponse`:
|
|
||||||
- Executes agents in sequence
|
|
||||||
- Each agent receives `{...context, previous_results: [...]}`
|
|
||||||
- Final synthesis via LLM: "Summarize these agent results into a coherent response"
|
|
||||||
- `async orchestrate(request: ChatRequest) -> ChatResponse | ExecutionPlan`:
|
|
||||||
- Main entry point
|
|
||||||
- Classifies intent
|
|
||||||
- If `execution_mode == 'direct'`: route + return response
|
|
||||||
- If `execution_mode == 'plan'`: route + return execution plan with template IDs
|
|
||||||
- `async orchestrate_stream(request: ChatRequest) -> AsyncGenerator[str, None]`:
|
|
||||||
- Same as orchestrate but yields tokens for WebSocket streaming
|
|
||||||
- [ ] Integration tests with mocked LLM and mocked agents
|
|
||||||
- **Outcome:** Intelligent routing with single-agent and pipeline modes.
|
|
||||||
|
|
||||||
### Step 5 — Execution Plan generator
|
|
||||||
- [ ] `app/core/execution_plan.py`:
|
|
||||||
- `PromptTemplateRegistry`: dict of `template_id -> prompt_text`. Templates are server-side only — client receives IDs.
|
|
||||||
- `ExecutionPlanBuilder`:
|
|
||||||
- `add_step(action, params) -> self`
|
|
||||||
- `add_llm_step(template_id, variables) -> self`
|
|
||||||
- `add_data_step(action, data_from_step) -> self`
|
|
||||||
- `build() -> ExecutionPlan` — validates step references
|
|
||||||
- `PlanCache`:
|
|
||||||
- In-memory LRU (maxsize=1000)
|
|
||||||
- `cache_plan(key, plan)`, `get_plan(key)`, `get_all_playbooks() -> list[ExecutionPlan]`
|
|
||||||
- Playbooks are pre-built plans for common operations (e.g., "create task from email", "generate weekly report")
|
|
||||||
- **Outcome:** Plans are cacheable as playbooks. Prompt IP never leaves the server.
|
|
||||||
|
|
||||||
### Step 6 — Chat Agents
|
|
||||||
- [ ] `app/agents/task_agent.py` — `@registry.register`:
|
|
||||||
- Description: "Manages tasks: create, update, list, suggest"
|
|
||||||
- Tools: `create_task(title, description, priority, due_date)`, `update_task(id, updates)`, `list_tasks(filters)`, `suggest_tasks(notes_context)`
|
|
||||||
- System prompt: PM-oriented, validates task structure, infers priority from context
|
|
||||||
- `handle()`: LLM + tool loop via `_tool_loop()`, returns response text + list of actions performed
|
|
||||||
- [ ] `app/agents/calendar_agent.py` — `@registry.register`:
|
|
||||||
- Description: "Calendar management: events, conflicts, scheduling"
|
|
||||||
- Tools: `list_events(date_range)`, `detect_conflicts(events)`, `suggest_reschedule(conflict)`
|
|
||||||
- Works with event metadata passed in context (never raw calendar data stored)
|
|
||||||
- [ ] `app/agents/email_agent.py` — `@registry.register`:
|
|
||||||
- Description: "Email analysis: classify, extract actions, draft responses"
|
|
||||||
- Tools: `classify_email(metadata)`, `extract_action_items(metadata)`, `draft_response(thread_context)`
|
|
||||||
- Only processes metadata sent by client — never raw email bodies
|
|
||||||
- [ ] `app/agents/analytics_agent.py` — `@registry.register`:
|
|
||||||
- Description: "Workspace analytics: metrics, reports, trends"
|
|
||||||
- Tools: `calculate_metrics(task_data)`, `generate_report(period, data)`, `trend_analysis(data_points)`
|
|
||||||
- Crunches numbers from context, returns structured insights
|
|
||||||
- [ ] `app/agents/__init__.py`: imports all agent modules to trigger `@registry.register` decorators
|
|
||||||
- [ ] Unit tests per agent with mocked LLM
|
|
||||||
- **Outcome:** Four specialized agents, all registered and tested.
|
|
||||||
|
|
||||||
### Step 7 — API Routes
|
|
||||||
|
|
||||||
#### 7a — Chat endpoint
|
|
||||||
- [ ] `app/api/routes/chat.py`:
|
|
||||||
- `POST /api/v1/chat`:
|
|
||||||
- Request: `ChatRequest`
|
|
||||||
- Calls `orchestrate(request)` or `orchestrate()` + `build_plan()`
|
|
||||||
- Response: `ChatResponse` or `ExecutionPlan`
|
|
||||||
- `WebSocket /api/v1/chat/stream`:
|
|
||||||
- Client sends `ChatRequest` as first JSON frame
|
|
||||||
- Server yields token strings via `orchestrate_stream()`
|
|
||||||
- Final frame: JSON `ChatResponse` with `{"done": true, "response": "...", "actions": [...]}`
|
|
||||||
- Heartbeat ping every 30s to keep connection alive
|
|
||||||
|
|
||||||
#### 7b — Plans endpoint
|
|
||||||
- [ ] `app/api/routes/plans.py`:
|
|
||||||
- `GET /api/v1/plans/playbook`: Returns all playbooks available for the user's tier
|
|
||||||
- `GET /api/v1/plans/playbook/{plan_id}`: Returns a specific plan
|
|
||||||
|
|
||||||
#### 7c — Backup endpoint
|
|
||||||
- [ ] `app/api/routes/backup.py`:
|
|
||||||
- `PUT /api/v1/backup`: Accepts binary blob + metadata headers (`X-Backup-Version`, `X-Backup-Timestamp`, `X-Backup-Checksum`). Stores in S3 keyed by `{user_id}/{timestamp}`. Enforces tier limits:
|
|
||||||
- Free: 0 (no backup)
|
|
||||||
- Pro: 5 GB
|
|
||||||
- Power: 50 GB
|
|
||||||
- Team: unlimited
|
|
||||||
- `GET /api/v1/backup`: Returns latest blob for authenticated user. Supports `If-Modified-Since`.
|
|
||||||
- `GET /api/v1/backup/history`: Returns list of `BackupMetadata` (no blobs).
|
|
||||||
- `DELETE /api/v1/backup/{backup_id}`: Delete specific backup.
|
|
||||||
|
|
||||||
#### 7d — Auth endpoint
|
|
||||||
- [ ] `app/api/routes/auth.py`:
|
|
||||||
- `POST /api/v1/auth/register`: `{email, password}` → bcrypt hash → insert user → return `AuthTokens`
|
|
||||||
- `POST /api/v1/auth/login`: Validate credentials → return `AuthTokens`
|
|
||||||
- `POST /api/v1/auth/refresh`: Rotate refresh token → return new `AuthTokens`
|
|
||||||
- `GET /api/v1/auth/me`: Return `UserProfile` for current JWT
|
|
||||||
|
|
||||||
#### 7e — Billing endpoint
|
|
||||||
- [ ] `app/api/routes/billing.py`:
|
|
||||||
- `POST /api/v1/billing/checkout`: Creates Stripe checkout session → returns URL
|
|
||||||
- `POST /api/v1/billing/webhook`: Handles Stripe webhooks (subscription lifecycle)
|
|
||||||
- `GET /api/v1/billing/subscription`: Returns current subscription info
|
|
||||||
- `DELETE /api/v1/billing/subscription`: Cancels subscription
|
|
||||||
|
|
||||||
- **Outcome:** Complete REST + WebSocket API.
|
|
||||||
|
|
||||||
### Step 8 — Middleware
|
|
||||||
|
|
||||||
#### 8a — Auth middleware
|
|
||||||
- [ ] `app/api/middleware/auth.py`:
|
|
||||||
- FastAPI dependency: `get_current_user(token: str = Depends(oauth2_scheme)) -> UserProfile`
|
|
||||||
- Validates JWT signature, expiry, extracts `user_id` and `tier`
|
|
||||||
- Raises `401` on invalid/expired token
|
|
||||||
- Exempt routes: `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/billing/webhook`
|
|
||||||
|
|
||||||
#### 8b — Rate limiter
|
|
||||||
- [ ] `app/api/middleware/rate_limit.py`:
|
|
||||||
- Uses `slowapi` with `Limiter(key_func=get_user_id_from_jwt)`
|
|
||||||
- Tier-based limits:
|
|
||||||
- Free: 20 req/min
|
|
||||||
- Pro: 60 req/min
|
|
||||||
- Power: 120 req/min
|
|
||||||
- Team: 200 req/seat/min
|
|
||||||
- Custom 429 response with `Retry-After` header
|
|
||||||
|
|
||||||
#### 8c — Sanitizer
|
|
||||||
- [ ] `app/api/middleware/sanitizer.py`:
|
|
||||||
- Response middleware that scans response bodies
|
|
||||||
- Strips: system prompt fragments, agent internal reasoning, tool schemas, routing metadata
|
|
||||||
- Pattern-based detection + exact match against known prompt fingerprints
|
|
||||||
- Logs sanitization events for monitoring
|
|
||||||
|
|
||||||
- **Outcome:** Secure, rate-limited API with prompt IP protection.
|
|
||||||
|
|
||||||
### Step 9 — Billing & Tier management
|
|
||||||
- [ ] `app/billing/stripe_service.py`:
|
|
||||||
- `create_checkout_session(user_id, tier) -> str`
|
|
||||||
- `handle_webhook(payload, sig_header) -> None`: processes `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`
|
|
||||||
- `get_subscription(user_id) -> dict | None`
|
|
||||||
- `cancel_subscription(user_id) -> None`
|
|
||||||
- [ ] `app/billing/tier_manager.py`:
|
|
||||||
- `TierManager`:
|
|
||||||
- Feature matrix:
|
|
||||||
```python
|
|
||||||
FEATURES = {
|
|
||||||
'free': {'agents': 3, 'batch': False, 'providers': 1, 'backup_gb': 0},
|
|
||||||
'pro': {'agents': -1, 'batch': True, 'providers': -1, 'backup_gb': 5},
|
|
||||||
'power': {'agents': -1, 'batch': True, 'providers': -1, 'backup_gb': 50, 'byok': True},
|
|
||||||
'team': {'agents': -1, 'batch': True, 'providers': -1, 'backup_gb': -1, 'sso': True},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `get_tier(user_id) -> BillingTier`
|
|
||||||
- `check_feature(user_id, feature) -> bool`
|
|
||||||
- `get_rate_limit(tier) -> int`
|
|
||||||
- **Outcome:** Stripe integration with tier-based feature gating.
|
|
||||||
|
|
||||||
### Step 10 — Database (auth/billing only)
|
|
||||||
- [ ] PostgreSQL schema via Alembic:
|
|
||||||
- `users`: `id UUID PK`, `email UNIQUE`, `password_hash`, `tier` (default 'free'), `stripe_customer_id`, `created_at`, `updated_at`
|
|
||||||
- `refresh_tokens`: `id UUID PK`, `user_id FK`, `token_hash`, `expires_at`, `created_at`
|
|
||||||
- `subscriptions`: `id UUID PK`, `user_id FK`, `stripe_subscription_id`, `tier`, `status`, `current_period_end`, `created_at`
|
|
||||||
- `backup_metadata`: `id UUID PK`, `user_id FK`, `s3_key`, `version`, `timestamp`, `checksum`, `size_bytes`, `created_at`
|
|
||||||
- [ ] Initial Alembic migration
|
|
||||||
- [ ] SQLAlchemy models in `app/models.py`
|
|
||||||
- **Outcome:** Auth and billing persistence. Zero user data stored.
|
|
||||||
|
|
||||||
### Step 11 — Testing & deployment
|
|
||||||
- [ ] `tests/conftest.py`: TestClient fixture, mock LLM fixture (`AsyncMock` returning canned responses), mock agent fixture, test DB (SQLite in-memory for speed)
|
|
||||||
- [ ] `tests/test_orchestrator.py`: classify_intent routing, single agent, pipeline, plan mode
|
|
||||||
- [ ] `tests/test_agents.py`: each agent with mocked tools
|
|
||||||
- [ ] `tests/test_auth.py`: register → login → access protected → refresh → expired token
|
|
||||||
- [ ] `tests/test_backup.py`: upload → download → history → delete, tier limit enforcement
|
|
||||||
- [ ] `Dockerfile` optimized for production (gunicorn + uvicorn workers)
|
|
||||||
- [ ] GitHub Actions CI: lint (ruff), test (pytest), build Docker image
|
|
||||||
- **Outcome:** Fully tested, deployable backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Contract Summary
|
|
||||||
|
|
||||||
| Method | Endpoint | Auth | Request | Response |
|
|
||||||
|--------|----------|------|---------|----------|
|
|
||||||
| POST | `/api/v1/auth/register` | No | `{email, password}` | `AuthTokens` |
|
|
||||||
| POST | `/api/v1/auth/login` | No | `{email, password}` | `AuthTokens` |
|
|
||||||
| POST | `/api/v1/auth/refresh` | No | `{refresh_token}` | `AuthTokens` |
|
|
||||||
| GET | `/api/v1/auth/me` | JWT | — | `UserProfile` |
|
|
||||||
| POST | `/api/v1/chat` | JWT | `ChatRequest` | `ChatResponse \| ExecutionPlan` |
|
|
||||||
| WS | `/api/v1/chat/stream` | JWT | `ChatRequest` (first frame) | Token stream + final JSON |
|
|
||||||
| GET | `/api/v1/plans/playbook` | JWT | — | `ExecutionPlan[]` |
|
|
||||||
| GET | `/api/v1/plans/playbook/:id` | JWT | — | `ExecutionPlan` |
|
|
||||||
| PUT | `/api/v1/backup` | JWT | Binary blob + headers | `{ok: true}` |
|
|
||||||
| GET | `/api/v1/backup` | JWT | — | Binary blob |
|
|
||||||
| GET | `/api/v1/backup/history` | JWT | — | `BackupMetadata[]` |
|
|
||||||
| DELETE | `/api/v1/backup/:id` | JWT | — | `{ok: true}` |
|
|
||||||
| POST | `/api/v1/billing/checkout` | JWT | `{tier}` | `{checkout_url}` |
|
|
||||||
| POST | `/api/v1/billing/webhook` | Stripe sig | Stripe event | `{ok: true}` |
|
|
||||||
| GET | `/api/v1/billing/subscription` | JWT | — | Subscription info |
|
|
||||||
| DELETE | `/api/v1/billing/subscription` | JWT | — | `{ok: true}` |
|
|
||||||
| GET | `/api/v1/health` | No | — | `{status, version}` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|-----------|
|
|
||||||
| Framework | FastAPI + Uvicorn |
|
|
||||||
| LLM | LangChain + langchain-openai |
|
|
||||||
| Auth | PyJWT + bcrypt + OAuth2 |
|
|
||||||
| Billing | stripe-python |
|
|
||||||
| Storage | boto3 (S3) |
|
|
||||||
| Database | PostgreSQL + SQLAlchemy + Alembic |
|
|
||||||
| Rate limiting | slowapi |
|
|
||||||
| Testing | pytest + pytest-asyncio + httpx |
|
|
||||||
| Deployment | Docker → fly.io / Railway / AWS ECS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Rules
|
|
||||||
|
|
||||||
1. **NEVER persist user data.** The DB stores only auth, billing, and backup metadata. User context arrives in requests and is discarded after processing.
|
|
||||||
2. **NEVER expose prompts.** System prompts are composed server-side from fragments. Responses are sanitized before sending.
|
|
||||||
3. **Stateless request handling.** No server-side session state. All context comes from the client + JWT.
|
|
||||||
4. **Type hints everywhere.** All functions have full type annotations.
|
|
||||||
5. **Test every agent.** Each chat agent has unit tests with mocked LLM responses.
|
|
||||||
6. **Structured logging.** JSON logs with request ID correlation.
|
|
||||||
222
forge.config.ts
222
forge.config.ts
@@ -7,12 +7,232 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati
|
|||||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// Packages externalized in vite.main.config.mts that must be installed at runtime.
|
||||||
|
// 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',
|
||||||
|
'electron-squirrel-startup',
|
||||||
|
'electron-store',
|
||||||
|
];
|
||||||
|
|
||||||
const config: ForgeConfig = {
|
const config: ForgeConfig = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: {
|
||||||
|
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
|
||||||
|
},
|
||||||
|
name: 'adiuva',
|
||||||
},
|
},
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
|
hooks: {
|
||||||
|
packageAfterCopy: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
|
||||||
|
// The VitePlugin's ignore filter only copies .vite/ into the build.
|
||||||
|
// Externalized packages need to be installed into node_modules here.
|
||||||
|
// At this point, only .vite/ exists. The VitePlugin writes package.json
|
||||||
|
// in its own afterCopy hook (which may run after ours). Read from source.
|
||||||
|
const srcPjPath = path.resolve(__dirname, 'package.json');
|
||||||
|
const pjPath = path.resolve(buildPath, 'package.json');
|
||||||
|
const pj = JSON.parse(fs.readFileSync(srcPjPath, 'utf-8'));
|
||||||
|
|
||||||
|
// Keep only externalized packages in dependencies
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
for (const pkg of externalPackages) {
|
||||||
|
if (pj.dependencies?.[pkg]) {
|
||||||
|
filtered[pkg] = pj.dependencies[pkg];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pj.dependencies = filtered;
|
||||||
|
delete pj.devDependencies;
|
||||||
|
fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2));
|
||||||
|
|
||||||
|
// Copy lockfile for reproducible installs
|
||||||
|
const lockSrc = path.resolve(buildPath, '..', '..', 'package-lock.json');
|
||||||
|
if (fs.existsSync(lockSrc)) {
|
||||||
|
fs.copyFileSync(lockSrc, path.resolve(buildPath, 'package-lock.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install only the externalized runtime deps
|
||||||
|
console.log('[forge] Installing externalized dependencies...');
|
||||||
|
execSync('npm install --omit=dev', {
|
||||||
|
cwd: buildPath,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, npm_config_nodedir: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetKey = `${platform}-${arch}`;
|
||||||
|
|
||||||
|
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<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': '',
|
||||||
|
},
|
||||||
|
'linux-x64': {
|
||||||
|
'@lancedb/vectordb-linux-x64-gnu': '',
|
||||||
|
},
|
||||||
|
'darwin-x64': {
|
||||||
|
'@lancedb/vectordb-darwin-x64': '',
|
||||||
|
},
|
||||||
|
'darwin-arm64': {
|
||||||
|
'@lancedb/vectordb-darwin-arm64': '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const nativePkgs = platformNativePackages[targetKey];
|
||||||
|
if (nativePkgs) {
|
||||||
|
// Remove wrong-platform lancedb native packages
|
||||||
|
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}`)) {
|
||||||
|
fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true });
|
||||||
|
console.log(`[forge] Removed non-target native package: @lancedb/${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Install correct platform packages
|
||||||
|
const pkgsToInstall = Object.keys(nativePkgs).join(' ');
|
||||||
|
console.log(`[forge] Installing platform-specific packages for ${targetKey}: ${pkgsToInstall}`);
|
||||||
|
execSync(`npm install ${pkgsToInstall} --omit=dev --no-save --force`, {
|
||||||
|
cwd: buildPath,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const nodeModulesPath = path.join(buildPath, 'node_modules');
|
||||||
|
const findPrebuilds = (dir: string): string[] => {
|
||||||
|
const results: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return results;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'prebuilds') {
|
||||||
|
results.push(full);
|
||||||
|
} else {
|
||||||
|
results.push(...findPrebuilds(full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const prebuildsDir of findPrebuilds(nodeModulesPath)) {
|
||||||
|
for (const entry of fs.readdirSync(prebuildsDir)) {
|
||||||
|
if (entry !== targetKey) {
|
||||||
|
const fullPath = path.join(prebuildsDir, entry);
|
||||||
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
console.log(`[forge] Removed non-target prebuild: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @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 ──────
|
||||||
|
// Forge runs @electron/rebuild AFTER packageAfterCopy, which
|
||||||
|
// recompiles native addons for the BUILD platform (Linux).
|
||||||
|
// packageAfterPrune runs AFTER rebuild+prune, so we can safely
|
||||||
|
// replace the Linux .node files with the correct target prebuilts.
|
||||||
|
packageAfterPrune: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
|
||||||
|
const targetKey = `${platform}-${arch}`;
|
||||||
|
const buildKey = `${process.platform}-${process.arch}`;
|
||||||
|
if (targetKey === buildKey) return; // native build — nothing to fix
|
||||||
|
|
||||||
|
console.log(`[forge:afterPrune] Cross-compile fixup: ${buildKey} → ${targetKey}`);
|
||||||
|
const electronVersion = JSON.parse(
|
||||||
|
fs.readFileSync(path.resolve(__dirname, 'node_modules', 'electron', 'package.json'), 'utf-8'),
|
||||||
|
).version;
|
||||||
|
|
||||||
|
// Replace native addons that @electron/rebuild compiled for the host.
|
||||||
|
const nativeModules = ['better-sqlite3'];
|
||||||
|
for (const mod of nativeModules) {
|
||||||
|
const modDir = path.join(buildPath, 'node_modules', mod);
|
||||||
|
if (!fs.existsSync(modDir)) continue;
|
||||||
|
|
||||||
|
// Remove the host-platform binary left by @electron/rebuild
|
||||||
|
const buildRelease = path.join(modDir, 'build', 'Release');
|
||||||
|
if (fs.existsSync(buildRelease)) {
|
||||||
|
fs.rmSync(buildRelease, { recursive: true, force: true });
|
||||||
|
console.log(`[forge:afterPrune] Cleaned host-platform build/Release for ${mod}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the correct prebuilt for the TARGET platform
|
||||||
|
console.log(`[forge:afterPrune] Downloading ${mod} prebuilt for ${targetKey} (Electron ${electronVersion})...`);
|
||||||
|
execSync(
|
||||||
|
`npx --yes prebuild-install -r electron -t ${electronVersion} ` +
|
||||||
|
`--platform ${platform} --arch ${arch} --tag-prefix v --verbose`,
|
||||||
|
{ cwd: modDir, stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the binary exists and is for the correct platform.
|
||||||
|
const releaseDir = path.join(modDir, 'build', 'Release');
|
||||||
|
if (!fs.existsSync(releaseDir)) {
|
||||||
|
throw new Error(
|
||||||
|
`[forge] FATAL: build/Release/ not found for ${mod} after prebuild-install. ` +
|
||||||
|
`The native binary was not downloaded.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nodeFiles = fs.readdirSync(releaseDir).filter((f) => f.endsWith('.node'));
|
||||||
|
if (nodeFiles.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`[forge] FATAL: No .node files in build/Release/ for ${mod} after prebuild-install.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const f of nodeFiles) {
|
||||||
|
const buf = Buffer.alloc(4);
|
||||||
|
const fd = fs.openSync(path.join(releaseDir, f), 'r');
|
||||||
|
fs.readSync(fd, buf, 0, 4, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
// ELF magic: 0x7f 'E' 'L' 'F'
|
||||||
|
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
|
||||||
|
throw new Error(
|
||||||
|
`[forge] FATAL: ${mod} build/Release/${f} is an ELF binary! ` +
|
||||||
|
`Cross-compilation failed — refusing to package a Linux .node for Windows.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// PE magic: 'M' 'Z' (0x4d 0x5a) — expected for win32
|
||||||
|
if (platform === 'win32' && !(buf[0] === 0x4d && buf[1] === 0x5a)) {
|
||||||
|
throw new Error(
|
||||||
|
`[forge] FATAL: ${mod} build/Release/${f} is not a PE (Windows) binary! ` +
|
||||||
|
`Magic bytes: ${buf.toString('hex')}. Refusing to package.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[forge:afterPrune] Verified ${mod}/${f} — correct platform ✓`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
makers: [
|
makers: [
|
||||||
new MakerSquirrel({}),
|
new MakerSquirrel({}),
|
||||||
new MakerZIP({}, ['darwin']),
|
new MakerZIP({}, ['darwin']),
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -32,7 +32,6 @@
|
|||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"framer-motion": "^12.34.2",
|
"framer-motion": "^12.34.2",
|
||||||
"keytar": "^7.9.0",
|
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -16007,17 +16006,6 @@
|
|||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/keytar": {
|
|
||||||
"version": "7.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
|
|
||||||
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"node-addon-api": "^4.3.0",
|
|
||||||
"prebuild-install": "^7.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -18209,12 +18197,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-addon-api": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/node-api-version": {
|
"node_modules/node-api-version": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"knip": "knip"
|
"knip": "knip"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "rmusso",
|
"author": "roberto",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.11.1",
|
"@electron-forge/cli": "^7.11.1",
|
||||||
@@ -71,7 +71,6 @@
|
|||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"framer-motion": "^12.34.2",
|
"framer-motion": "^12.34.2",
|
||||||
"keytar": "^7.9.0",
|
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -2,27 +2,11 @@ import { safeStorage } from 'electron';
|
|||||||
import { getStore } from '../store';
|
import { getStore } from '../store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token storage with three-tier fallback:
|
* Token storage with two-tier fallback:
|
||||||
* 1. OS keychain via keytar (best — encrypted, per-user)
|
* 1. Electron safeStorage + electron-store (encrypted at rest)
|
||||||
* 2. Electron safeStorage + electron-store (encrypted at rest)
|
* 2. Plain electron-store (last resort — e.g. WSL with no keyring)
|
||||||
* 3. Plain electron-store (last resort — e.g. WSL with no keyring)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let keytar: typeof import('keytar') | null = null;
|
|
||||||
let keytarFailed = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
keytar = require('keytar') as typeof import('keytar');
|
|
||||||
} catch {
|
|
||||||
keytarFailed = true;
|
|
||||||
console.log('[Token] keytar native module unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
function useKeytar(): boolean {
|
|
||||||
return keytar !== null && !keytarFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canUseSafeStorage(): boolean {
|
function canUseSafeStorage(): boolean {
|
||||||
try {
|
try {
|
||||||
return safeStorage.isEncryptionAvailable();
|
return safeStorage.isEncryptionAvailable();
|
||||||
@@ -31,8 +15,6 @@ function canUseSafeStorage(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_NAME = 'adiuva';
|
|
||||||
|
|
||||||
// --- electron-store helpers (with optional safeStorage encryption) ---
|
// --- electron-store helpers (with optional safeStorage encryption) ---
|
||||||
|
|
||||||
function readFromStore(providerName: string): string | null {
|
function readFromStore(providerName: string): string | null {
|
||||||
@@ -74,41 +56,16 @@ function removeFromStore(providerName: string): void {
|
|||||||
|
|
||||||
/** Read a stored token for the given provider. */
|
/** Read a stored token for the given provider. */
|
||||||
export async function getToken(providerName: string): Promise<string | null> {
|
export async function getToken(providerName: string): Promise<string | null> {
|
||||||
if (useKeytar()) {
|
|
||||||
try {
|
|
||||||
return await keytar!.getPassword(SERVICE_NAME, providerName);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
|
|
||||||
keytarFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return readFromStore(providerName);
|
return readFromStore(providerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Store a token for the given provider. */
|
/** Store a token for the given provider. */
|
||||||
export async function setToken(providerName: string, token: string): Promise<void> {
|
export async function setToken(providerName: string, token: string): Promise<void> {
|
||||||
if (useKeytar()) {
|
|
||||||
try {
|
|
||||||
await keytar!.setPassword(SERVICE_NAME, providerName, token);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
|
|
||||||
keytarFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeToStore(providerName, token);
|
writeToStore(providerName, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a stored token for the given provider. */
|
/** Delete a stored token for the given provider. */
|
||||||
async function deleteToken(providerName: string): Promise<boolean> {
|
async function deleteToken(providerName: string): Promise<boolean> {
|
||||||
if (useKeytar()) {
|
|
||||||
try {
|
|
||||||
return await keytar!.deletePassword(SERVICE_NAME, providerName);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
|
|
||||||
keytarFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removeFromStore(providerName);
|
removeFromStore(providerName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export default defineConfig({
|
|||||||
// Externalize native Node modules — they're rebuilt by electron-forge
|
// Externalize native Node modules — they're rebuilt by electron-forge
|
||||||
external: [
|
external: [
|
||||||
'better-sqlite3',
|
'better-sqlite3',
|
||||||
'keytar',
|
|
||||||
'@github/copilot-sdk',
|
'@github/copilot-sdk',
|
||||||
'@github/copilot',
|
'@github/copilot',
|
||||||
// LangChain — externalize to avoid bundling Node.js-specific code
|
// LangChain — externalize to avoid bundling Node.js-specific code
|
||||||
|
|||||||
Reference in New Issue
Block a user