update skill config

This commit is contained in:
Roberto Musso
2026-04-15 11:26:46 +02:00
parent 25a5a6672e
commit 2ee3bb37db
13 changed files with 2026 additions and 76 deletions

View File

@@ -1,22 +1,22 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guide Claude Code when work in repo.
## Keeping This File Up to Date
Update this file whenever a lesson is learned during development. Specifically, update CLAUDE.md when:
Update when lesson learned. Update when:
- A non-obvious architectural decision is made or discovered
- A gotcha, footgun, or surprising behavior is encountered (and the fix/workaround)
- A new command, workflow, or tool is added to the project
- A convention is established that isn't obvious from reading the code
- An integration detail is clarified (e.g., how the IPC protocol actually behaves, edge cases in the agent tool call cycle)
- Non-obvious arch decision made or found
- Gotcha, footgun, surprising behavior hit (+ fix/workaround)
- New command, workflow, tool added
- Convention set that not obvious from code
- Integration detail clarified (IPC protocol behavior, agent tool call edge cases)
Do **not** add things already derivable from reading the code, generic best practices, or ephemeral task notes — only durable, reusable knowledge.
Do **not** add derivable-from-code things, generic best practices, or ephemeral task notes — durable knowledge only.
## Repository Layout
This is a **monorepo with git submodules**. Each submodule is an independent repo with its own `.claude/CLAUDE.md` for detailed guidance.
**Monorepo with git submodules.** Each submodule independent repo with own `.claude/CLAUDE.md`.
| Directory | What | Submodule |
|-----------|------|-----------|
@@ -25,13 +25,13 @@ This is a **monorepo with git submodules**. Each submodule is an independent rep
| **`website/`** | Landing page (single `index.html`) | `git.muticolturano.com/adiuvAI/website` |
| **`docs/`** | Planning docs & working memory (not a submodule) | -- |
After cloning, run `git submodule update --init --recursive` to populate submodule contents.
After clone, run `git submodule update --init --recursive` to populate submodules.
---
## adiuvAI (Electron App)
> **Detailed docs**: `adiuvAI/.claude/CLAUDE.md` covers commands, architecture, AI subsystem, design context, and conventions in depth.
> **Detailed docs**: `adiuvAI/.claude/CLAUDE.md` commands, architecture, AI subsystem, design context, conventions.
### Commands
@@ -62,12 +62,12 @@ Main Process (Node.js)
└── LangGraph orchestrator (3 specialist agents, pluggable LLM providers)
```
**This is a local-first app.** All user data (tasks, notes, projects) lives in local SQLite. The AI system (LangGraph + LangChain) runs entirely in the Electron main process with pluggable providers (OpenAI, Anthropic, GitHub Copilot).
**Local-first app.** All user data (tasks, notes, projects) in local SQLite. AI system (LangGraph + LangChain) runs in Electron main process, pluggable providers (OpenAI, Anthropic, GitHub Copilot).
**IPC channels**:
- `'trpc'` — bidirectional tRPC request/response (all CRUD)
- `'ai:stream'` — one-way token streaming from main → renderer
- `'ai:action'` — AI side-effects (e.g., task auto-created by agent)
- `'ai:stream'` — one-way token streaming main → renderer
- `'ai:action'` — AI side-effects (e.g. agent auto-creates task)
**Key source paths**:
- `src/main/ipc.ts` — Custom tRPC↔IPC bridge
@@ -80,46 +80,54 @@ Main Process (Node.js)
- `src/main/auth/backup-key.ts` — Device-specific AES-256 backup key (safeStorage, not password-derived)
- `src/main/ai/token.ts` — Two-tier token storage: safeStorage + electron-store fallback
- `src/main/auth/locale-defaults.ts` — Detects timezone, date/time format, language from OS locale
- `src/main/api/format-row.ts` — Formats timestamp columns in query results using user's FormatPrefs
- `src/main/api/format-row.ts` — Formats timestamp columns using user's FormatPrefs
**Non-obvious details**:
- `electron-trpc` is NOT used — custom IPC bridge in `ipc.ts` + `ipcLink.ts` because electron-trpc bundles tRPC v10 internals
- `electron-trpc` NOT used — custom IPC bridge in `ipc.ts` + `ipcLink.ts` because electron-trpc bundles tRPC v10 internals
- Vite configs use `.mts` extension to avoid ESM/CJS conflicts with electron-forge
- `forge.config.ts` has complex cross-compilation hooks (downloads platform-specific native binaries for better-sqlite3 and LanceDB)
- DB has no foreign key constraints — cascade deletes are implemented in tRPC procedures
- Timestamps are milliseconds (JavaScript `Date.getTime()`), not ISO strings
- DB has no foreign key constraints — cascade deletes in tRPC procedures
- Timestamps are milliseconds (`Date.getTime()`), not ISO strings
- Notes auto-embed to LanceDB on create/update (fire-and-forget, errors swallowed)
**Settings Page (shared Electron + Web)**:
- Settings page runs in **both** Electron and standalone web SPA (future landing-page portal). Same React components — no duplication.
- **Platform Adapter pattern**: `PlatformProvider` context (`src/renderer/lib/platform.tsx`) exposes `isElectron`/`isWeb`/`hasLocalAgents`/`hasFileDialog` flags. Components use `usePlatform()` to gate Electron-only features or disable on web.
- **6 sections**: Profile, AI Preferences, Account, Billing, Appearance, Agents. Sidebar nav with icons in `types.ts` (`SECTIONS` array).
- **Web build**: `vite.web.config.mts` builds standalone SPA to `dist-web/`. Entry: `web.html``src/renderer/web-main.tsx` (uses `httpBatchLink` via `src/renderer/lib/httpLink.ts` instead of `ipcLink`). Scripts: `npm run dev:web`, `npm run build:web`, `npm run preview:web`.
- **Electron-only gating**: Device ID card and local agent filesystem gated behind `platform.isElectron`. On web: visible but disabled, not hidden.
- **Gotcha**: Do NOT add Electron-specific settings (server URL, native file pickers) without wrapping in `platform.isElectron`. Same component tree renders on web.
**Onboarding Wizard**:
- First-run wizard collects 5 fields: `job_role`, `industry`, `primary_use_case`, `tone_preference`, `language`. Plus `user_name` derived from profile `name`+`surname`.
- First-run wizard collects 5 fields: `job_role`, `industry`, `primary_use_case`, `tone_preference`, `language`. Plus `user_name` from `name`+`surname`.
- All fields stored as encrypted core memory (backend `MemoryMiddleware`), not local electron-store.
- `onboarding_completed_at` on the `users` table (nullable TIMESTAMPTZ) gates the flow — `null` = show wizard, non-null = skip.
- `AppShell.tsx` gates: if `profile.onboardingCompletedAt == null` → render `<OnboardingFlow>` instead of the app.
- `onboarding_completed_at` on `users` table (nullable TIMESTAMPTZ) gates flow — `null` = show wizard, non-null = skip.
- `AppShell.tsx` gates: if `profile.onboardingCompletedAt == null` → render `<OnboardingFlow>` instead of app.
- `auth.status` tRPC procedure auto-seeds `language` and `user_name` into MemoryCore if missing (fire-and-forget `.catch(() => {})`).
- Format prefs (timezone, dateFormat, timeFormat) are stored in electron-store (`FormatPrefs`), not core memory — they're device-specific.
- `drizzle-executor.ts` wraps all query results through `formatRow()`/`formatRows()` using the user's FormatPrefs.
- Settings > Profile section allows post-onboarding editing of all fields + format prefs.
- **Gotcha — shadcn Button `outline` variant in dark mode**: The variant defines `dark:bg-input/30 dark:border-input dark:hover:bg-input/50` which overrides any custom `className` background. Fix: switch between `variant="default"` and `variant="outline"` instead of adding className overrides.
- **Gotcha — locale codes vs human names**: `app.getLocale()` and `navigator.language` return codes like `en-US`. Use `Intl.DisplayNames(undefined, { type: 'language' })` to convert to "English". This must be done in both the main process (`locale-defaults.ts`) and renderer (`OnboardingFlow.tsx`).
- Format prefs (timezone, dateFormat, timeFormat) stored in electron-store (`FormatPrefs`), not core memory — device-specific.
- `drizzle-executor.ts` wraps all query results through `formatRow()`/`formatRows()` using user's FormatPrefs.
- Settings > Profile allows post-onboarding edit of all fields + format prefs.
- **Gotcha — shadcn Button `outline` variant in dark mode**: Variant defines `dark:bg-input/30 dark:border-input dark:hover:bg-input/50` overrides custom `className` background. Fix: switch between `variant="default"` and `variant="outline"` instead of className overrides.
- **Gotcha — locale codes vs human names**: `app.getLocale()` and `navigator.language` return codes like `en-US`. Use `Intl.DisplayNames(undefined, { type: 'language' })` to convert to "English". Must do in both main process (`locale-defaults.ts`) and renderer (`OnboardingFlow.tsx`).
**i18n (Internationalization)**:
- Uses `i18next` + `react-i18next` with bundled JSON translations (no lazy loading).
- Config in `src/renderer/i18n.ts`. 5 languages: EN, IT, ES, FR, DE. `SUPPORTED_LANGUAGES` array exported for UI selectors.
- Config in `src/renderer/i18n.ts`. 5 languages: EN, IT, ES, FR, DE. `SUPPORTED_LANGUAGES` exported for UI selectors.
- Translation files: `src/renderer/locales/{en,it,es,fr,de}/translation.json`. Namespaces: `nav`, `auth`, `tasks`, `settings`, `common`, `errors`, `home`, `timeline`, `projects`, `agents`.
- **`common.*` namespace** holds shared labels (`save`, `cancel`, `delete`, `edit`, `add`, `rename`, `saving`, `deleting`, `creating`, `renameDescription`, `deleteTitle`). Before adding a new key, check if `common.*` already has it.
- **`common.*` namespace** holds shared labels (`save`, `cancel`, `delete`, `edit`, `add`, `rename`, `saving`, `deleting`, `creating`, `renameDescription`, `deleteTitle`). Check `common.*` before adding new key.
- Pluralization uses i18next `_one`/`_other` suffixes (e.g. `tasksDueToday_one`, `tasksDueToday_other`).
- `LanguageSync` component in `src/renderer/index.tsx` reads persisted `uiLanguage` from electron-store via tRPC on startup and syncs to i18next.
- Language selector lives in `GeneralSection.tsx` (Settings > General). On change it: (1) calls `i18n.changeLanguage()`, (2) persists to electron-store via `setUiLanguage` mutation, (3) writes to backend core memory so AI responds in the same language.
- `LanguageSync` component in `src/renderer/index.tsx` reads persisted `uiLanguage` from electron-store via tRPC on startup, syncs to i18next.
- Language selector in `GeneralSection.tsx` (Settings > General). On change: (1) calls `i18n.changeLanguage()`, (2) persists to electron-store via `setUiLanguage` mutation, (3) writes to backend core memory so AI responds in same language.
- `getUiLanguage()` exported from `src/main/store.ts` — used by `orchestrator.ts` to append language hint to daily brief prompt.
- Static data arrays that need translation use `labelKey` pattern (not `label`): store a translation key, call `t(labelKey)` at render time. Used in `NAV_ITEMS`, `COLUMNS`, `SECTIONS`, `SUGGESTION_CHIPS`.
- When adding new translated text: add the key to **all 5** JSON files. Keep `common.*` keys consistent across all languages.
- Static data arrays needing translation use `labelKey` pattern (not `label`): store translation key, call `t(labelKey)` at render. Used in `NAV_ITEMS`, `COLUMNS`, `SECTIONS`, `SUGGESTION_CHIPS`.
- When adding new translated text: add key to **all 5** JSON files. Keep `common.*` consistent across all languages.
**Google OAuth (adiuvAI side)**:
- `adiuvai://` is NOT accepted by Google as a redirect URI — Google only accepts `http://localhost` or `https://`. The API backend exposes `GET /auth/oauth/google/web-callback` which receives the Google redirect and immediately bounces to `adiuvai://oauth/callback?...`. The redirect URI registered in Google Cloud Console points to the backend, not the Electron app.
- `app.requestSingleInstanceLock()` is required for the `second-instance` event to fire on Windows/Linux. If it returns `false`, call `app.quit()` immediately (another instance is already running).
- In dev (`process.defaultApp === true`), `setAsDefaultProtocolClient('adiuvai')` must include `[path.resolve(process.argv[1])]` as the third argument so the OS protocol registration includes the entry script.
- `loginWithOAuth` uses `fetch()` directly (not `this.get()`) because the authorize endpoint is public `get()` throws when not authenticated.
- The backup key in `backup-key.ts` is stored in `encryptedTokens` under the key `backup_key`, reusing `getToken/setToken` from `token.ts`. It is device-bound and never password-derived, so social-login users can use backup features without issue.
- `adiuvai://` NOT accepted by Google as redirect URI — Google only accepts `http://localhost` or `https://`. API backend exposes `GET /auth/oauth/google/web-callback` which receives Google redirect and bounces to `adiuvai://oauth/callback?...`. Redirect URI in Google Cloud Console points to backend, not Electron app.
- `app.requestSingleInstanceLock()` required for `second-instance` event on Windows/Linux. If returns `false`, call `app.quit()` immediately.
- In dev (`process.defaultApp === true`), `setAsDefaultProtocolClient('adiuvai')` must include `[path.resolve(process.argv[1])]` as third arg so OS protocol registration includes entry script.
- `loginWithOAuth` uses `fetch()` directly (not `this.get()`) authorize endpoint is public, `get()` throws when not authenticated.
- Backup key in `backup-key.ts` stored in `encryptedTokens` under key `backup_key`, reusing `getToken/setToken` from `token.ts`. Device-bound, never password-derived social-login users can use backup features.
---
@@ -185,11 +193,11 @@ FastAPI app (app/main.py)
└── Marketplace (app/marketplace/) — plugin catalog, review, revenue sharing
```
**LLM routing**: `gpt-4o-mini` classifies intent → routes to domain agent → agent uses `gpt-4o` with tools → tool calls describe client-side operations (JSON) → Electron executes locally and returns results.
**LLM routing**: `gpt-4o-mini` classifies intent → routes to domain agent → agent uses `gpt-4o` with tools → tool calls describe client-side ops (JSON) → Electron executes locally, returns results.
**Zero-trust data model**: The backend never stores or decrypts user content. PostgreSQL holds only auth, billing, plugin metadata, and storage record pointers. All user data is E2E-encrypted before leaving the Electron client.
**Zero-trust data model**: Backend never stores or decrypts user content. PostgreSQL holds only auth, billing, plugin metadata, storage record pointers. All user data E2E-encrypted before leaving Electron client.
**Key config**: `app/config/settings.py` — all env vars via Pydantic Settings. Copy `.env.example` to `.env` for local dev. Stripe and S3 gracefully stub when keys aren't configured.
**Key config**: `app/config/settings.py` — all env vars via Pydantic Settings. Copy `.env.example` to `.env` for local dev. Stripe and S3 gracefully stub when keys absent.
**Database**: PostgreSQL with async SQLAlchemy 2.0 + asyncpg. 9 ORM models in `app/models.py`. Alembic migrations in `alembic/versions/`.
@@ -197,36 +205,36 @@ FastAPI app (app/main.py)
### Non-obvious details
- **Tier from DB, not JWT**: `get_current_user` decodes JWT but fetches authoritative tier from `subscriptions` table — tier changes take effect immediately without re-login
- **Refresh tokens hashed**: Plaintext returned to client, stored as SHA-256 in DB — server can never retrieve the plaintext (intentional)
- **Tier from DB, not JWT**: `get_current_user` decodes JWT but fetches authoritative tier from `subscriptions` table — tier changes take effect immediately, no re-login needed
- **Refresh tokens hashed**: Plaintext returned to client, stored as SHA-256 in DB — server can never retrieve plaintext (intentional)
- **WebSocket auth via query param**: `?token=<jwt>` instead of Bearer header (WebSocket handshake limitation)
- **Prompt IP protection**: `PromptTemplateRegistry` keeps prompts server-side; clients receive opaque `template_id`. `SanitizerMiddleware` strips leaked fragments from responses
- **Agents don't execute operations**: Tools return JSON describing client-side ops — the Electron client executes against local SQLite
- **Alembic async/sync split**: App uses `postgresql+asyncpg`, Alembic CLI needs `postgresql+psycopg2``env.py` handles the URL conversion
- **Agents don't execute operations**: Tools return JSON describing client-side ops — Electron client executes against local SQLite
- **Alembic async/sync split**: App uses `postgresql+asyncpg`, Alembic CLI needs `postgresql+psycopg2``env.py` handles URL conversion
- **Tool loop cap**: Agent `_tool_loop` stops after 5 iterations to prevent infinite loops
- **Route order matters**: `/backup/history` must be declared before `/backup/{backup_id}` to avoid path param shadowing
- **CORS includes `app://`**: Electron uses custom `app://` protocol, not http/https
- **Vector search on encrypted data is not semantic**: Backend derives deterministic 32-dim floats from blob SHA-256 for storage/search — a known trade-off
- **Vector search on encrypted data is not semantic**: Backend derives deterministic 32-dim floats from blob SHA-256 for storage/search — known trade-off
**Onboarding (API side)**:
- `PUT /auth/me/memory` — updates core memory k/v pairs and optionally marks onboarding complete (`mark_onboarded: true` sets `users.onboarding_completed_at`).
- `POST /auth/me/onboarding/reset` — nullifies `onboarding_completed_at` so the wizard re-runs.
- `PUT /auth/me/memory` — updates core memory k/v pairs, optionally marks onboarding complete (`mark_onboarded: true` sets `users.onboarding_completed_at`).
- `POST /auth/me/onboarding/reset` — nullifies `onboarding_completed_at` so wizard re-runs.
- `POST /auth/onboarding/normalize` — LLM-normalizes free-text onboarding inputs via `gpt-4o-mini`; returns inputs unchanged on error.
- `get_current_user()` in `auth.py` middleware now decrypts core memory blocks and includes them in `UserProfile.memory` dict.
- `users.onboarding_completed_at` is a nullable TIMESTAMPTZ column — returned as epoch ms (int) in UserProfile schema.
- `get_current_user()` in `auth.py` middleware decrypts core memory blocks, includes in `UserProfile.memory` dict.
- `users.onboarding_completed_at` nullable TIMESTAMPTZ, returned as epoch ms (int) in UserProfile schema.
**i18n (API side)**:
- `_language_instruction()` in `app/core/deep_agent.py` reads the user's `language` from `MemoryCore` and appends a system prompt directive ("Always respond in {language}") to all 4 `run_*` functions.
- The Electron client writes the user's chosen language to backend core memory on language change, so the API picks it up on the next agent call.
- `_language_instruction()` in `app/core/deep_agent.py` reads user's `language` from `MemoryCore`, appends system prompt directive ("Always respond in {language}") to all 4 `run_*` functions.
- Electron client writes chosen language to backend core memory on change — API picks up on next agent call.
**Google OAuth (api side)**:
- OAuth routes live in `app/api/routes/auth.py`: `GET /auth/oauth/{provider}/authorize`, `POST /auth/oauth/{provider}/callback`, `GET /auth/oauth/{provider}/web-callback` (bounces to deep link, excluded from OpenAPI schema).
- Provider abstraction in `app/auth/oauth_providers.py``GoogleOAuthProvider` uses `httpx` directly (no `authlib`). PKCE S256 is implemented manually via `generate_pkce_pair()`.
- `_pending_states` dict in `routes/auth.py` is **in-memory** — works for single-process dev but does not survive restarts and does not scale to multiple workers. Replace with Redis in production.
- `users.password_hash` is **nullable** — social-only users have `password_hash=None`. `await db.flush()` is required before creating a linked `OAuthAccount` to populate `new_user.id` before commit.
- `OAUTH_REDIRECT_URI` must point to the **API backend** (e.g. `https://api.adiuvai.com/...`), not the website domain. `adiuvai.com` is a static site with no server-side routing.
- **Unverified email + existing account = 409**: if `email_verified=False` and the email is already registered, the callback returns 409. Without this guard, branch 3 would attempt to INSERT a duplicate email and crash with a DB constraint violation (500).
- **Testing OAuth routes**: mock `GoogleOAuthProvider.exchange_code` and `get_userinfo` with `patch.object(..., new=AsyncMock(...))` — works because FastAPI instantiates a new provider per request. Use `monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", ...)` to simulate configured credentials without restarting the app.
- OAuth routes in `app/api/routes/auth.py`: `GET /auth/oauth/{provider}/authorize`, `POST /auth/oauth/{provider}/callback`, `GET /auth/oauth/{provider}/web-callback` (bounces to deep link, excluded from OpenAPI schema).
- Provider abstraction in `app/auth/oauth_providers.py``GoogleOAuthProvider` uses `httpx` directly (no `authlib`). PKCE S256 implemented manually via `generate_pkce_pair()`.
- `_pending_states` dict in `routes/auth.py` is **in-memory** — works for single-process dev, doesn't survive restarts, doesn't scale to multiple workers. Replace with Redis in production.
- `users.password_hash` is **nullable** — social-only users have `password_hash=None`. `await db.flush()` required before creating linked `OAuthAccount` to populate `new_user.id` before commit.
- `OAUTH_REDIRECT_URI` must point to **API backend** (e.g. `https://api.adiuvai.com/...`), not website domain. `adiuvai.com` is static site with no server-side routing.
- **Unverified email + existing account = 409**: if `email_verified=False` and email already registered, callback returns 409. Without this guard, branch 3 would INSERT duplicate email and crash with DB constraint violation (500).
- **Testing OAuth routes**: mock `GoogleOAuthProvider.exchange_code` and `get_userinfo` with `patch.object(..., new=AsyncMock(...))` — works because FastAPI instantiates new provider per request. Use `monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", ...)` to simulate configured credentials without restart.
### Tier System
@@ -243,19 +251,19 @@ Enforced in `app/api/middleware/rate_limit.py` (sliding window) and `app/billing
## Cross-Project Integration
The Electron app and FastAPI backend communicate via **WebSocket** (`/chat/stream`):
Electron app and FastAPI backend communicate via **WebSocket** (`/chat/stream`):
1. Electron connects with `?token=<jwt>` query param
2. Client sends `ChatRequest` JSON frame
3. Server streams text chunks, then a final frame: `{"done": true, "response": "...", "actions": []}`
3. Server streams text chunks, then final frame: `{"done": true, "response": "...", "actions": []}`
4. Server sends `tool_call` frames → Electron executes against local SQLite → returns `tool_result`
5. Server pings every 30 seconds to keep connection alive
5. Server pings every 30 seconds to keep alive
The Electron app also has a **fully local AI path** (LangGraph orchestrator in main process) that doesn't require the backend — this is the primary path for desktop use.
Electron also has **fully local AI path** (LangGraph orchestrator in main process) that doesn't require backend — primary path for desktop use.
---
## MCP Servers
- **Langfuse Docs** (`https://langfuse.com/api/mcp`) — configured at workspace level for prompt management documentation
- **shadcn** (`npx shadcn@latest mcp`) — configured in `adiuvAI/` for UI component generation
- **Langfuse Docs** (`https://langfuse.com/api/mcp`) — workspace-level, prompt management docs
- **shadcn** (`npx shadcn@latest mcp`) — configured in `adiuvAI/` for UI component generation

269
.claude/CLAUDE.original.md Normal file
View File

@@ -0,0 +1,269 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Keeping This File Up to Date
Update this file whenever a lesson is learned during development. Specifically, update CLAUDE.md when:
- A non-obvious architectural decision is made or discovered
- A gotcha, footgun, or surprising behavior is encountered (and the fix/workaround)
- A new command, workflow, or tool is added to the project
- A convention is established that isn't obvious from reading the code
- An integration detail is clarified (e.g., how the IPC protocol actually behaves, edge cases in the agent tool call cycle)
Do **not** add things already derivable from reading the code, generic best practices, or ephemeral task notes — only durable, reusable knowledge.
## Repository Layout
This is a **monorepo with git submodules**. Each submodule is an independent repo with its own `.claude/CLAUDE.md` for detailed guidance.
| Directory | What | Submodule |
|-----------|------|-----------|
| **`adiuvAI/`** | Electron desktop app (TypeScript/React) | `git.muticolturano.com/adiuvAI/adiuvAI` |
| **`api/`** | FastAPI backend (Python) | `git.muticolturano.com/adiuvAI/api` |
| **`website/`** | Landing page (single `index.html`) | `git.muticolturano.com/adiuvAI/website` |
| **`docs/`** | Planning docs & working memory (not a submodule) | -- |
After cloning, run `git submodule update --init --recursive` to populate submodule contents.
---
## adiuvAI (Electron App)
> **Detailed docs**: `adiuvAI/.claude/CLAUDE.md` covers commands, architecture, AI subsystem, design context, and conventions in depth.
### Commands
```bash
cd adiuvAI
npm run start # Start dev server (Electron + Vite)
npm run lint # ESLint
npm run knip # Dead code analysis
npm run make # Build installers (Windows/Linux/macOS)
npm run package # Package without creating installers
npx drizzle-kit generate # Generate migration from schema changes
npx drizzle-kit push # Push schema directly (dev only)
```
No test suite currently.
### Architecture
```
Renderer (React 19 + TanStack Router)
↓ custom ipcLink (NOT electron-trpc — incompatible with tRPC v11)
Preload (contextBridge: window.electronTRPC + window.electronAI)
↓ IPC channels
Main Process (Node.js)
├── tRPC router (all CRUD + AI procedures)
├── SQLite (better-sqlite3 + Drizzle ORM, WAL mode)
├── LanceDB (vector embeddings, 1536-dim text-embedding-3-small)
└── LangGraph orchestrator (3 specialist agents, pluggable LLM providers)
```
**This is a local-first app.** All user data (tasks, notes, projects) lives in local SQLite. The AI system (LangGraph + LangChain) runs entirely in the Electron main process with pluggable providers (OpenAI, Anthropic, GitHub Copilot).
**IPC channels**:
- `'trpc'` — bidirectional tRPC request/response (all CRUD)
- `'ai:stream'` — one-way token streaming from main → renderer
- `'ai:action'` — AI side-effects (e.g., task auto-created by agent)
**Key source paths**:
- `src/main/ipc.ts` — Custom tRPC↔IPC bridge
- `src/main/router/index.ts` — All tRPC routers (~600 LOC)
- `src/main/ai/orchestrator.ts` — LangGraph intent routing + 3 agents (~991 LOC)
- `src/main/db/schema.ts` — 6 tables (clients, projects, tasks, checkpoints, notes, taskComments)
- `src/renderer/routes/` — File-based routing (TanStack Router auto-generates `routeTree.gen.ts`)
- `src/renderer/components/ui/` — shadcn/ui primitives (new-york theme, neutral colors)
- `src/main/auth/auth-manager.ts` — Login, register, logout, OAuth flow (singleton)
- `src/main/auth/backup-key.ts` — Device-specific AES-256 backup key (safeStorage, not password-derived)
- `src/main/ai/token.ts` — Two-tier token storage: safeStorage + electron-store fallback
- `src/main/auth/locale-defaults.ts` — Detects timezone, date/time format, language from OS locale
- `src/main/api/format-row.ts` — Formats timestamp columns in query results using user's FormatPrefs
**Non-obvious details**:
- `electron-trpc` is NOT used — custom IPC bridge in `ipc.ts` + `ipcLink.ts` because electron-trpc bundles tRPC v10 internals
- Vite configs use `.mts` extension to avoid ESM/CJS conflicts with electron-forge
- `forge.config.ts` has complex cross-compilation hooks (downloads platform-specific native binaries for better-sqlite3 and LanceDB)
- DB has no foreign key constraints — cascade deletes are implemented in tRPC procedures
- Timestamps are milliseconds (JavaScript `Date.getTime()`), not ISO strings
- Notes auto-embed to LanceDB on create/update (fire-and-forget, errors swallowed)
**Settings Page (shared between Electron and Web)**:
- The Settings page is designed to run in **both** the Electron app and a standalone web SPA (future landing-page user portal). The same React components are used — no duplication.
- **Platform Adapter pattern**: `PlatformProvider` context (`src/renderer/lib/platform.tsx`) exposes `isElectron`/`isWeb`/`hasLocalAgents`/`hasFileDialog` flags. Components use `usePlatform()` to conditionally render Electron-only features (device ID, local agent filesystem) or disable them on web.
- **6 sections**: Profile, AI Preferences, Account, Billing, Appearance, Agents. Sidebar nav with icons in `types.ts` (`SECTIONS` array).
- **Web build**: `vite.web.config.mts` builds a standalone SPA to `dist-web/`. Entry: `web.html``src/renderer/web-main.tsx` (uses `httpBatchLink` via `src/renderer/lib/httpLink.ts` instead of `ipcLink`). Scripts: `npm run dev:web`, `npm run build:web`, `npm run preview:web`.
- **Electron-only gating**: Device ID card and local agent filesystem features are gated behind `platform.isElectron`. On web, local agents are visible but disabled (not hidden).
- **Gotcha**: Do NOT add Electron-specific settings (e.g. server URL, native file pickers) without wrapping in `platform.isElectron`. The same component tree renders on web.
**Onboarding Wizard**:
- First-run wizard collects 5 fields: `job_role`, `industry`, `primary_use_case`, `tone_preference`, `language`. Plus `user_name` derived from profile `name`+`surname`.
- All fields stored as encrypted core memory (backend `MemoryMiddleware`), not local electron-store.
- `onboarding_completed_at` on the `users` table (nullable TIMESTAMPTZ) gates the flow — `null` = show wizard, non-null = skip.
- `AppShell.tsx` gates: if `profile.onboardingCompletedAt == null` → render `<OnboardingFlow>` instead of the app.
- `auth.status` tRPC procedure auto-seeds `language` and `user_name` into MemoryCore if missing (fire-and-forget `.catch(() => {})`).
- Format prefs (timezone, dateFormat, timeFormat) are stored in electron-store (`FormatPrefs`), not core memory — they're device-specific.
- `drizzle-executor.ts` wraps all query results through `formatRow()`/`formatRows()` using the user's FormatPrefs.
- Settings > Profile section allows post-onboarding editing of all fields + format prefs.
- **Gotcha — shadcn Button `outline` variant in dark mode**: The variant defines `dark:bg-input/30 dark:border-input dark:hover:bg-input/50` which overrides any custom `className` background. Fix: switch between `variant="default"` and `variant="outline"` instead of adding className overrides.
- **Gotcha — locale codes vs human names**: `app.getLocale()` and `navigator.language` return codes like `en-US`. Use `Intl.DisplayNames(undefined, { type: 'language' })` to convert to "English". This must be done in both the main process (`locale-defaults.ts`) and renderer (`OnboardingFlow.tsx`).
**i18n (Internationalization)**:
- Uses `i18next` + `react-i18next` with bundled JSON translations (no lazy loading).
- Config in `src/renderer/i18n.ts`. 5 languages: EN, IT, ES, FR, DE. `SUPPORTED_LANGUAGES` array exported for UI selectors.
- Translation files: `src/renderer/locales/{en,it,es,fr,de}/translation.json`. Namespaces: `nav`, `auth`, `tasks`, `settings`, `common`, `errors`, `home`, `timeline`, `projects`, `agents`.
- **`common.*` namespace** holds shared labels (`save`, `cancel`, `delete`, `edit`, `add`, `rename`, `saving`, `deleting`, `creating`, `renameDescription`, `deleteTitle`). Before adding a new key, check if `common.*` already has it.
- Pluralization uses i18next `_one`/`_other` suffixes (e.g. `tasksDueToday_one`, `tasksDueToday_other`).
- `LanguageSync` component in `src/renderer/index.tsx` reads persisted `uiLanguage` from electron-store via tRPC on startup and syncs to i18next.
- Language selector lives in `GeneralSection.tsx` (Settings > General). On change it: (1) calls `i18n.changeLanguage()`, (2) persists to electron-store via `setUiLanguage` mutation, (3) writes to backend core memory so AI responds in the same language.
- `getUiLanguage()` exported from `src/main/store.ts` — used by `orchestrator.ts` to append language hint to daily brief prompt.
- Static data arrays that need translation use `labelKey` pattern (not `label`): store a translation key, call `t(labelKey)` at render time. Used in `NAV_ITEMS`, `COLUMNS`, `SECTIONS`, `SUGGESTION_CHIPS`.
- When adding new translated text: add the key to **all 5** JSON files. Keep `common.*` keys consistent across all languages.
**Google OAuth (adiuvAI side)**:
- `adiuvai://` is NOT accepted by Google as a redirect URI — Google only accepts `http://localhost` or `https://`. The API backend exposes `GET /auth/oauth/google/web-callback` which receives the Google redirect and immediately bounces to `adiuvai://oauth/callback?...`. The redirect URI registered in Google Cloud Console points to the backend, not the Electron app.
- `app.requestSingleInstanceLock()` is required for the `second-instance` event to fire on Windows/Linux. If it returns `false`, call `app.quit()` immediately (another instance is already running).
- In dev (`process.defaultApp === true`), `setAsDefaultProtocolClient('adiuvai')` must include `[path.resolve(process.argv[1])]` as the third argument so the OS protocol registration includes the entry script.
- `loginWithOAuth` uses `fetch()` directly (not `this.get()`) because the authorize endpoint is public — `get()` throws when not authenticated.
- The backup key in `backup-key.ts` is stored in `encryptedTokens` under the key `backup_key`, reusing `getToken/setToken` from `token.ts`. It is device-bound and never password-derived, so social-login users can use backup features without issue.
---
## api (FastAPI Backend)
### Commands
```bash
cd api
# Development
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Production
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 --timeout 120
# Database migrations
alembic upgrade head
# Testing
pytest # all tests
pytest -v # verbose
pytest tests/test_agents.py # single file
pytest tests/test_agents.py -k test_name # single test
# Linting/formatting
ruff check .
ruff format .
# Docker (full stack: app + postgres + minio + qdrant)
docker compose up --build
```
### Architecture
```
FastAPI app (app/main.py)
├── Middleware: TierRateLimiter → Sanitizer → CORS
├── HTTP Routes (app/api/routes/)
│ ├── auth.py — register, login, token refresh (bcrypt + HS256 JWT)
│ ├── chat.py — POST /chat, WS /chat/stream
│ ├── plans.py — execution plan playbooks
│ ├── storage.py — E2E-encrypted cloud storage (S3)
│ ├── backup.py — encrypted backup upload/download
│ ├── vectors.py — encrypted vector upsert/search (Pinecone/Qdrant)
│ ├── plugins.py — plugin marketplace (Power+ tier)
│ └── billing.py — Stripe subscriptions
├── Agent System (app/agents/)
│ ├── task_agent.py — 8 tools
│ ├── project_agent.py — 6 tools
│ ├── checkpoint_agent.py — 4 tools
│ └── note_agent.py — 5 tools
├── Orchestration (app/core/)
│ ├── orchestrator.py — intent classification + agent routing
│ ├── agent_registry.py — decorator-based agent registry
│ ├── execution_plan.py — server-side prompt templates + plan builder
│ ├── llm.py — LiteLLM factory (100+ providers)
│ └── memory_middleware.py
├── Billing (app/billing/)
│ ├── tier_manager.py — feature matrix (Free/Pro/Power/Team)
│ └── stripe_service.py — Stripe checkout + webhooks
├── Storage (app/storage/) — S3 blob store, vector store, encryption
└── Marketplace (app/marketplace/) — plugin catalog, review, revenue sharing
```
**LLM routing**: `gpt-4o-mini` classifies intent → routes to domain agent → agent uses `gpt-4o` with tools → tool calls describe client-side operations (JSON) → Electron executes locally and returns results.
**Zero-trust data model**: The backend never stores or decrypts user content. PostgreSQL holds only auth, billing, plugin metadata, and storage record pointers. All user data is E2E-encrypted before leaving the Electron client.
**Key config**: `app/config/settings.py` — all env vars via Pydantic Settings. Copy `.env.example` to `.env` for local dev. Stripe and S3 gracefully stub when keys aren't configured.
**Database**: PostgreSQL with async SQLAlchemy 2.0 + asyncpg. 9 ORM models in `app/models.py`. Alembic migrations in `alembic/versions/`.
**Testing**: pytest + pytest-asyncio. Fixtures in `tests/conftest.py` create in-memory SQLite + moto-mocked S3. Test users seeded per tier (free/pro/power/team).
### Non-obvious details
- **Tier from DB, not JWT**: `get_current_user` decodes JWT but fetches authoritative tier from `subscriptions` table — tier changes take effect immediately without re-login
- **Refresh tokens hashed**: Plaintext returned to client, stored as SHA-256 in DB — server can never retrieve the plaintext (intentional)
- **WebSocket auth via query param**: `?token=<jwt>` instead of Bearer header (WebSocket handshake limitation)
- **Prompt IP protection**: `PromptTemplateRegistry` keeps prompts server-side; clients receive opaque `template_id`. `SanitizerMiddleware` strips leaked fragments from responses
- **Agents don't execute operations**: Tools return JSON describing client-side ops — the Electron client executes against local SQLite
- **Alembic async/sync split**: App uses `postgresql+asyncpg`, Alembic CLI needs `postgresql+psycopg2``env.py` handles the URL conversion
- **Tool loop cap**: Agent `_tool_loop` stops after 5 iterations to prevent infinite loops
- **Route order matters**: `/backup/history` must be declared before `/backup/{backup_id}` to avoid path param shadowing
- **CORS includes `app://`**: Electron uses custom `app://` protocol, not http/https
- **Vector search on encrypted data is not semantic**: Backend derives deterministic 32-dim floats from blob SHA-256 for storage/search — a known trade-off
**Onboarding (API side)**:
- `PUT /auth/me/memory` — updates core memory k/v pairs and optionally marks onboarding complete (`mark_onboarded: true` sets `users.onboarding_completed_at`).
- `POST /auth/me/onboarding/reset` — nullifies `onboarding_completed_at` so the wizard re-runs.
- `POST /auth/onboarding/normalize` — LLM-normalizes free-text onboarding inputs via `gpt-4o-mini`; returns inputs unchanged on error.
- `get_current_user()` in `auth.py` middleware now decrypts core memory blocks and includes them in `UserProfile.memory` dict.
- `users.onboarding_completed_at` is a nullable TIMESTAMPTZ column — returned as epoch ms (int) in UserProfile schema.
**i18n (API side)**:
- `_language_instruction()` in `app/core/deep_agent.py` reads the user's `language` from `MemoryCore` and appends a system prompt directive ("Always respond in {language}") to all 4 `run_*` functions.
- The Electron client writes the user's chosen language to backend core memory on language change, so the API picks it up on the next agent call.
**Google OAuth (api side)**:
- OAuth routes live in `app/api/routes/auth.py`: `GET /auth/oauth/{provider}/authorize`, `POST /auth/oauth/{provider}/callback`, `GET /auth/oauth/{provider}/web-callback` (bounces to deep link, excluded from OpenAPI schema).
- Provider abstraction in `app/auth/oauth_providers.py``GoogleOAuthProvider` uses `httpx` directly (no `authlib`). PKCE S256 is implemented manually via `generate_pkce_pair()`.
- `_pending_states` dict in `routes/auth.py` is **in-memory** — works for single-process dev but does not survive restarts and does not scale to multiple workers. Replace with Redis in production.
- `users.password_hash` is **nullable** — social-only users have `password_hash=None`. `await db.flush()` is required before creating a linked `OAuthAccount` to populate `new_user.id` before commit.
- `OAUTH_REDIRECT_URI` must point to the **API backend** (e.g. `https://api.adiuvai.com/...`), not the website domain. `adiuvai.com` is a static site with no server-side routing.
- **Unverified email + existing account = 409**: if `email_verified=False` and the email is already registered, the callback returns 409. Without this guard, branch 3 would attempt to INSERT a duplicate email and crash with a DB constraint violation (500).
- **Testing OAuth routes**: mock `GoogleOAuthProvider.exchange_code` and `get_userinfo` with `patch.object(..., new=AsyncMock(...))` — works because FastAPI instantiates a new provider per request. Use `monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", ...)` to simulate configured credentials without restarting the app.
### Tier System
| Feature | Free | Pro | Power | Team |
|---------|------|-----|-------|------|
| Rate limit | 20/min | 60/min | 120/min | 200/min |
| Agents | 3 | unlimited | unlimited | unlimited |
| Cloud storage | 0 GB | 5 GB | 25 GB | unlimited |
| Plugin marketplace | no | no | yes | yes |
Enforced in `app/api/middleware/rate_limit.py` (sliding window) and `app/billing/tier_manager.py` (feature checks + quota enforcement).
---
## Cross-Project Integration
The Electron app and FastAPI backend communicate via **WebSocket** (`/chat/stream`):
1. Electron connects with `?token=<jwt>` query param
2. Client sends `ChatRequest` JSON frame
3. Server streams text chunks, then a final frame: `{"done": true, "response": "...", "actions": []}`
4. Server sends `tool_call` frames → Electron executes against local SQLite → returns `tool_result`
5. Server pings every 30 seconds to keep connection alive
The Electron app also has a **fully local AI path** (LangGraph orchestrator in main process) that doesn't require the backend — this is the primary path for desktop use.
---
## MCP Servers
- **Langfuse Docs** (`https://langfuse.com/api/mcp`) — configured at workspace level for prompt management documentation
- **shadcn** (`npx shadcn@latest mcp`) — configured in `adiuvAI/` for UI component generation

View File

@@ -5,6 +5,7 @@
]
},
"enabledPlugins": {
"ralph-loop@claude-plugins-official": true
"ralph-loop@claude-plugins-official": true,
"caveman@caveman": true
}
}

View File

@@ -1,9 +1,14 @@
{
"enableAllProjectMcpServers": true,
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit -m ':*)"
"Bash(git commit -m ':*)",
"Skill(shadcn)",
"Skill(shadcn:*)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(python \"C:/_temp/_adiuvai_workspace/.claude/skills/pptx/scripts/office/soffice.py\" --headless --convert-to pdf adiuvAI-per-commercialisti.pptx)",
"Read(//c/Users/musso/.claude/plugins/cache/caveman/caveman/63e797cd753b/**)"
]
}
},
"enableAllProjectMcpServers": true
}

99
.gitignore vendored Normal file
View File

@@ -0,0 +1,99 @@
skills/
unused_skills/
.vscode/mcp.json
.claude/skills/brand-guidelines/LICENSE.txt
.claude/skills/brand-guidelines/SKILL.md
.claude/skills/frontend-design/LICENSE.txt
.claude/skills/frontend-design/SKILL.md
.claude/skills/remotion-best-practices/SKILL.md
.claude/skills/remotion-best-practices/rules/3d.md
.claude/skills/remotion-best-practices/rules/animations.md
.claude/skills/remotion-best-practices/rules/assets.md
.claude/skills/remotion-best-practices/rules/audio-visualization.md
.claude/skills/remotion-best-practices/rules/audio.md
.claude/skills/remotion-best-practices/rules/calculate-metadata.md
.claude/skills/remotion-best-practices/rules/can-decode.md
.claude/skills/remotion-best-practices/rules/charts.md
.claude/skills/remotion-best-practices/rules/compositions.md
.claude/skills/remotion-best-practices/rules/display-captions.md
.claude/skills/remotion-best-practices/rules/extract-frames.md
.claude/skills/remotion-best-practices/rules/ffmpeg.md
.claude/skills/remotion-best-practices/rules/fonts.md
.claude/skills/remotion-best-practices/rules/get-audio-duration.md
.claude/skills/remotion-best-practices/rules/get-video-dimensions.md
.claude/skills/remotion-best-practices/rules/get-video-duration.md
.claude/skills/remotion-best-practices/rules/gifs.md
.claude/skills/remotion-best-practices/rules/images.md
.claude/skills/remotion-best-practices/rules/import-srt-captions.md
.claude/skills/remotion-best-practices/rules/light-leaks.md
.claude/skills/remotion-best-practices/rules/lottie.md
.claude/skills/remotion-best-practices/rules/maps.md
.claude/skills/remotion-best-practices/rules/measuring-dom-nodes.md
.claude/skills/remotion-best-practices/rules/measuring-text.md
.claude/skills/remotion-best-practices/rules/parameters.md
.claude/skills/remotion-best-practices/rules/sequencing.md
.claude/skills/remotion-best-practices/rules/sfx.md
.claude/skills/remotion-best-practices/rules/silence-detection.md
.claude/skills/remotion-best-practices/rules/subtitles.md
.claude/skills/remotion-best-practices/rules/tailwind.md
.claude/skills/remotion-best-practices/rules/text-animations.md
.claude/skills/remotion-best-practices/rules/timing.md
.claude/skills/remotion-best-practices/rules/transcribe-captions.md
.claude/skills/remotion-best-practices/rules/transitions.md
.claude/skills/remotion-best-practices/rules/transparent-videos.md
.claude/skills/remotion-best-practices/rules/trimming.md
.claude/skills/remotion-best-practices/rules/videos.md
.claude/skills/remotion-best-practices/rules/voiceover.md
.claude/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx
.claude/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx
.claude/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx
.claude/skills/shadcn/cli.md
.claude/skills/shadcn/customization.md
.claude/skills/shadcn/mcp.md
.claude/skills/shadcn/SKILL.md
.claude/skills/shadcn/agents/openai.yml
.claude/skills/shadcn/assets/shadcn-small.png
.claude/skills/shadcn/assets/shadcn.png
.claude/skills/shadcn/evals/evals.json
.claude/skills/shadcn/rules/base-vs-radix.md
.claude/skills/shadcn/rules/composition.md
.claude/skills/shadcn/rules/forms.md
.claude/skills/shadcn/rules/icons.md
.claude/skills/shadcn/rules/styling.md
.claude/skills/ui-ux-pro-max/SKILL.md
.claude/skills/ui-ux-pro-max/data/charts.csv
.claude/skills/ui-ux-pro-max/data/colors.csv
.claude/skills/ui-ux-pro-max/data/icons.csv
.claude/skills/ui-ux-pro-max/data/landing.csv
.claude/skills/ui-ux-pro-max/data/products.csv
.claude/skills/ui-ux-pro-max/data/react-performance.csv
.claude/skills/ui-ux-pro-max/data/styles.csv
.claude/skills/ui-ux-pro-max/data/typography.csv
.claude/skills/ui-ux-pro-max/data/ui-reasoning.csv
.claude/skills/ui-ux-pro-max/data/ux-guidelines.csv
.claude/skills/ui-ux-pro-max/data/web-interface.csv
.claude/skills/ui-ux-pro-max/data/stacks/astro.csv
.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv
.claude/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv
.claude/skills/ui-ux-pro-max/data/stacks/jetpack-compose.csv
.claude/skills/ui-ux-pro-max/data/stacks/nextjs.csv
.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv
.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv
.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv
.claude/skills/ui-ux-pro-max/data/stacks/react.csv
.claude/skills/ui-ux-pro-max/data/stacks/shadcn.csv
.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv
.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv
.claude/skills/ui-ux-pro-max/data/stacks/vue.csv
.claude/skills/ui-ux-pro-max/scripts/core.py
.claude/skills/ui-ux-pro-max/scripts/design_system.py
.claude/skills/ui-ux-pro-max/scripts/search.py
.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc
.claude/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc
.claude/skills/ui-ux-pro-max/scripts/__pycache__/search.cpython-314.pyc
.claude/skills/webapp-testing/LICENSE.txt
.claude/skills/webapp-testing/SKILL.md
.claude/skills/webapp-testing/examples/console_logging.py
.claude/skills/webapp-testing/examples/element_discovery.py
.claude/skills/webapp-testing/examples/static_html_automation.py
.claude/skills/webapp-testing/scripts/with_server.py

9
.vscode/mcp.json vendored
View File

@@ -1,9 +1,4 @@
{
"servers": {
"langfuse-docs": {
"url": "https://langfuse.com/api/mcp",
"type": "http"
}
},
"inputs": []
"inputs": [],
"servers": {}
}

Submodule adiuvAI updated: 0371a46731...333b6cb769

View File

@@ -0,0 +1,212 @@
# Sonner Global Notification System — Ralph Loop Prompt
You are implementing a global toast notification system in the adiuvAI Electron app using shadcn's sonner component.
**Full plan:** Read `docs/plan-sonner-notifications.md` for the complete architecture, file list, i18n keys, and categorization of every mutation.
## Rules
- **Always read the plan first** at `docs/plan-sonner-notifications.md` before doing any work.
- **Always read a file before editing it.** Never edit blind.
- **One phase per iteration.** Complete one phase fully, verify it compiles, then move on.
- **Run `cd adiuvAI && npx tsc --noEmit` after each phase** to catch type errors early.
- **Run `cd adiuvAI && npm run lint` after Phase 3 and Phase 4** to catch lint issues.
- **Commit after each phase** with a descriptive message.
- **i18n: add keys to ALL 5 language files** (`en`, `it`, `es`, `fr`, `de`). The plan has complete translations for each.
- **Do NOT touch silent mutations** (note auto-save, kanban drag, sidebar toggle, AI chat/streaming). For these, add `onError` only if missing.
- **When removing `saved`/`setSaved` state patterns:** also remove the `setTimeout`, the button text ternary, and any `setSaved(false)` in `onChange` handlers. Replace button text with `{t('common.save')}`.
- **Import path for useNotify:** `import { useNotify } from '@/hooks/useNotify';`
- **Import path for toast (direct):** `import { toast } from 'sonner';` (only in useNotify.ts itself)
## Progress Tracking
Check the state of the codebase to determine which phase to work on:
1. **If `src/renderer/components/ui/sonner.tsx` does NOT exist** → Start Phase 1
2. **If `sonner.tsx` exists but settings components still have `setSaved`** → Do Phase 2
3. **If settings are done but CRUD components lack `useNotify`** → Do Phase 3
4. **If CRUD is done but auth/onboarding lack `useNotify`** → Do Phase 4
5. **If all phases are done and `npx tsc --noEmit` + `npm run lint` pass** → Do Phase 5 (verification)
---
## Phase 1: Foundation
### Step 1: Install sonner
```bash
cd adiuvAI && npx shadcn@latest add sonner --yes
```
### Step 2: Fix theme import in generated `sonner.tsx`
The generated file imports `useTheme` from `next-themes`. This app does NOT use next-themes. Fix the import:
```tsx
// WRONG (generated):
import { useTheme } from "next-themes"
// CORRECT:
import { useTheme } from "@/components/theme-provider"
```
Also set `position="bottom-right"` and add `richColors` on the `<Sonner>` component.
### Step 3: Add `<Toaster />` to `src/renderer/index.tsx`
Import `Toaster` from `@/components/ui/sonner` and render it as the last child inside `<QueryClientProvider>`, AFTER `<RouterProvider />`. This ensures toasts work during login, onboarding, AND normal app usage.
### Step 4: Create `src/renderer/hooks/useNotify.ts`
Create the hook exactly as specified in the plan (Section 1.4). The hook exports `{ notify, notifyError, notifyPromise }`.
### Step 5: Add i18n keys to all 5 translation files
Add the `"toast"` top-level key with all sub-keys to:
- `src/renderer/locales/en/translation.json` (English — from plan)
- `src/renderer/locales/it/translation.json` (Italian — from plan)
- `src/renderer/locales/es/translation.json` (Spanish — from plan)
- `src/renderer/locales/fr/translation.json` (French — from plan)
- `src/renderer/locales/de/translation.json` (German — from plan)
### Step 6: Verify
```bash
cd adiuvAI && npx tsc --noEmit
```
### Step 7: Commit
```bash
cd adiuvAI && git add -A && git commit -m "feat(notifications): add sonner toast foundation with useNotify hook and i18n keys"
```
---
## Phase 2: Settings Components
For each of these 5 files, apply the pattern: add `useNotify()`, remove `saved`/`setSaved` state, remove `setTimeout`, replace button text ternary, add `notify()` in `onSuccess`, add `notifyError()` in `onError`.
### Files (in order):
1. **`src/renderer/components/settings/GeneralSection.tsx`**
- Remove: `saved`, `setSaved`, `error`, `setError`, `setTimeout`, inline `<p>` error, `setSaved(false)` in onChange
- Add: `notify('success', 'toast.profile.updated')` in handleSave onSuccess
- Add: `notifyError('toast.profile.updateError', err)` in handleSave onError
- Add: `notify('info', 'toast.settings.languageChanged')` in handleLanguageChange
- Button text: `{t('common.save')}`
2. **`src/renderer/components/settings/ProfileSection.tsx`**
- Remove: `profileSaved`, `displaySaved` states and their `setTimeout`s
- Profile save → `notify('success', 'toast.settings.memorySaved')`
- Display save → `notify('success', 'toast.settings.formatPrefsSaved')`
- Reset onboarding → `notify('info', 'toast.onboarding.reset')`
3. **`src/renderer/components/settings/AccountSection.tsx`**
- Remove: `urlSaved`, `setUrlSaved` state and `setTimeout`
- Backend URL save → `notify('success', 'toast.settings.backendUrlSaved')`
- Add onError → `notifyError('toast.settings.backendUrlError', err)`
- Logout → `notify('info', 'toast.auth.loggedOut')`
4. **`src/renderer/components/settings/LocalAgentConfigPanel.tsx`**
- Remove: `saved` state and `setTimeout`
- Save → `notify('success', 'toast.agent.updated')`
- Add onError → `notifyError('toast.agent.updateError', err)`
5. **`src/renderer/components/settings/CloudAgentConfigPanel.tsx`**
- Same as LocalAgentConfigPanel
### Verify and Commit:
```bash
cd adiuvAI && npx tsc --noEmit && npm run lint
cd adiuvAI && git add -A && git commit -m "feat(notifications): replace settings saved-state patterns with sonner toasts"
```
---
## Phase 3: CRUD Operations
Add `useNotify()` to each component and wire `notify` / `notifyError` into existing `onSuccess` / `onError` callbacks. If `onError` doesn't exist, add it.
### Files and mutations:
**Tasks:**
- `src/renderer/components/tasks/NewTaskDialog.tsx``tasks.create`: success `toast.task.created` + inline `clients.create`: success `toast.client.created`
- `src/renderer/components/tasks/EditTaskDialog.tsx``tasks.update`: success `toast.task.updated`
- `src/renderer/components/tasks/TaskDetailDialog.tsx``taskComments.create`: success `toast.comment.created`, `taskComments.delete`: warning `toast.comment.deleted`
- `src/renderer/routes/tasks.tsx``tasks.delete`: warning `toast.task.deleted`, `tasks.update` status toggle: **onError only**
- `src/renderer/components/projects/KanbanBoard.tsx``tasks.update` drag: **onError only**, `tasks.delete`: warning `toast.task.deleted`
- `src/renderer/components/projects/ProjectDetail.tsx``tasks.delete`: warning `toast.task.deleted`, `tasks.update` toggle: **onError only**, `notes.create`: success `toast.note.created`
- `src/renderer/components/ai/blocks/ChatEntityBlock.tsx``tasks.delete`: warning `toast.task.deleted`, `tasks.update` toggle: **onError only**
**Projects & Clients:**
- `src/renderer/components/projects/ProjectSidebar.tsx``projects.create`: success, `projects.update`: success, `projects.delete`: warning, `projects.archiveByClient`: warning (check if archiving or unarchiving), `clients.create`: success, `clients.update`: success, `clients.deleteWithCascade`: warning
**Notes:**
- `src/renderer/routes/notes.$noteId.tsx``notes.delete`: warning `toast.note.deleted`, `notes.update` auto-save: **onError only** (SILENT)
**Timeline:**
- `src/renderer/components/timeline/AddEventDialog.tsx``timelineEvents.create`: success
- `src/renderer/components/timeline/EditEventDialog.tsx``timelineEvents.update`: success
- `src/renderer/routes/timeline.tsx``timelineEvents.delete`: warning, `timelineEvents.update`: success
**Agents:**
- `src/renderer/components/settings/AgentsSection.tsx``agent.*.delete`: warning, `agent.*.update` toggle: **onError only**, `agent.runNow`: use `notifyPromise`
- `src/renderer/components/settings/InlineAgentCreationStepper.tsx``agent.*.create`: success
### Verify and Commit:
```bash
cd adiuvAI && npx tsc --noEmit && npm run lint
cd adiuvAI && git add -A && git commit -m "feat(notifications): add sonner toasts to all CRUD operations"
```
---
## Phase 4: Auth + Onboarding
### Files:
1. **`src/renderer/components/auth/LoginForm.tsx`**
- `auth.login` onError → `notifyError('toast.auth.loginError', err)` (KEEP inline error too)
- `auth.register` onError → `notifyError('toast.auth.registerError', err)` (KEEP inline error too)
- `auth.loginWithOAuth` onError → `notifyError('toast.auth.oauthError', err)`
2. **`src/renderer/components/layout/AppShell.tsx`**
- `auth.logout` onSuccess → `notify('info', 'toast.auth.loggedOut')` (add before `utils.auth.status.invalidate()`)
3. **`src/renderer/components/onboarding/OnboardingFlow.tsx`**
- Final save onSuccess → `notify('success', 'toast.onboarding.completed', { descriptionKey: 'toast.onboarding.completedDescription' })`
- Final save onError → `notifyError('toast.onboarding.error', err)`
- Normalize call → use `notifyPromise` with loading/success/error keys
### Verify and Commit:
```bash
cd adiuvAI && npx tsc --noEmit && npm run lint
cd adiuvAI && git add -A && git commit -m "feat(notifications): add sonner toasts to auth and onboarding flows"
```
---
## Phase 5: Final Verification
Run these checks:
```bash
cd adiuvAI && npx tsc --noEmit
cd adiuvAI && npm run lint
cd adiuvAI && npm run knip
```
Verify:
- No remaining `setSaved` or `setTimeout.*setSaved` patterns in `src/renderer/components/settings/`
- All 5 translation files have the `toast` key with matching sub-keys
- `sonner.tsx` imports from `@/components/theme-provider` (NOT `next-themes`)
- `<Toaster />` renders in `index.tsx` inside `<ThemeProvider>`
- `useNotify.ts` exists in `src/renderer/hooks/`
If everything passes:
<promise>SONNER NOTIFICATIONS COMPLETE</promise>

BIN
docs/adiuvAI.pptx Normal file

Binary file not shown.

View File

@@ -0,0 +1,504 @@
// adiuvAI — Presentazione generica dell'applicazione
// Stile: Light canvas (app light mode)
const pptxgen = require("pptxgenjs");
const path = require("path");
const ASSETS = "C:/_temp/_adiuvai_workspace/adiuvAI/assets";
const LOGO_ICON = `${ASSETS}/logo/logo-icon.png`;
const SHOT_HOME = `${ASSETS}/screenshot/home.png`;
const SHOT_PROJECTS = `${ASSETS}/screenshot/projects.png`;
const SHOT_TASK = `${ASSETS}/screenshot/task.png`;
const SHOT_CHAT = `${ASSETS}/screenshot/home_chat.png`;
// Palette light mode (app)
const C = {
bg: "F4EDF3", // pinkish-white canvas
surface: "FFFFFF", // cards
surface2: "EDE5EC", // subtle header row
gold: "FBC881", // primary accent
goldDark: "C79A5B", // darker gold for contrast on light bg
ink: "0C0C0C", // near-black
ink2: "323232", // body text dark
muted: "8A8EA9", // slate blue-gray
border: "C8C3CD", // dusty lavender border
borderSoft: "E5DFE4",
};
const FONT_H = "Calibri";
const FONT_B = "Calibri";
const pres = new pptxgen();
pres.layout = "LAYOUT_WIDE"; // 13.333 x 7.5
pres.author = "adiuvAI";
pres.title = "adiuvAI";
const SW = 13.333;
const SH = 7.5;
const DARK = { bg: "0C0C0C", surface: "181818", surface2: "222222", text: "FBFBFB", muted: "8A8EA9", border: "2A2A2A" };
function bgLight(slide) { slide.background = { color: C.bg }; }
function bgDark(slide) { slide.background = { color: DARK.bg }; }
function footer(slide, pageNum, total, dark) {
slide.addImage({ path: LOGO_ICON, x: 0.5, y: 0.35, w: 0.35, h: 0.35 });
slide.addText(
[
{ text: "adiuv", options: { color: dark ? DARK.text : C.ink, fontFace: FONT_H, fontSize: 11 } },
{ text: "AI", options: { color: dark ? C.gold : C.goldDark, fontFace: FONT_H, fontSize: 11, bold: true } },
],
{ x: 0.9, y: 0.33, w: 2.5, h: 0.4, margin: 0, valign: "middle" }
);
slide.addText(`${pageNum} / ${total}`, {
x: SW - 1.5, y: 0.33, w: 1.0, h: 0.4,
color: dark ? DARK.muted : C.muted, fontFace: FONT_B, fontSize: 10, align: "right", valign: "middle", margin: 0,
});
}
function slideTitle(slide, eyebrow, title, dark) {
if (eyebrow) {
slide.addText(eyebrow.toUpperCase(), {
x: 0.8, y: 1.05, w: 10, h: 0.35,
color: dark ? C.gold : C.goldDark, fontFace: FONT_H, fontSize: 11, bold: true, charSpacing: 6, margin: 0,
});
}
slide.addText(title, {
x: 0.8, y: 1.4, w: 11.5, h: 1.0,
color: dark ? DARK.text : C.ink, fontFace: FONT_H, fontSize: 36, bold: true, margin: 0,
});
}
function goldDot(slide, x, y) {
slide.addShape(pres.shapes.OVAL, {
x, y, w: 0.12, h: 0.12, fill: { color: C.gold }, line: { color: C.gold },
});
}
const TOTAL = 9;
let page = 0;
// ============================================================
// 1 — COVER
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
s.addImage({ path: LOGO_ICON, x: 1.1, y: 2.4, w: 2.6, h: 2.6 });
s.addText(
[
{ text: "adiuv", options: { color: C.ink, fontFace: FONT_H, fontSize: 72 } },
{ text: "AI", options: { color: C.goldDark, fontFace: FONT_H, fontSize: 72, bold: true } },
],
{ x: 4.0, y: 2.7, w: 7.5, h: 1.3, margin: 0, valign: "middle" }
);
s.addShape(pres.shapes.RECTANGLE, {
x: 4.1, y: 4.05, w: 0.6, h: 0.04, fill: { color: C.gold }, line: { color: C.gold },
});
s.addText("Meet your new chief of staff.", {
x: 4.0, y: 4.15, w: 8.5, h: 0.6,
color: C.ink, fontFace: FONT_H, fontSize: 24, italic: true, margin: 0,
});
s.addText("Una segretaria AI che legge la tua posta, organizza il tuo lavoro, e ogni mattina ti dice cosa conta — tutto sul tuo computer.", {
x: 4.0, y: 4.85, w: 8.5, h: 1.4,
color: C.ink2, fontFace: FONT_B, fontSize: 16, margin: 0,
});
}
// ============================================================
// 2 — L'IDEA: UNA SEGRETARIA
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
footer(s, page, TOTAL);
slideTitle(s, "L'idea", "Non un altro tool. Una segretaria.");
s.addText(
[
{ text: "Gli strumenti di produttività si aspettano che tu li usi.\n", options: { color: C.muted, fontSize: 18 } },
{ text: "adiuvAI lavora per te.", options: { color: C.ink, fontSize: 26, bold: true } },
],
{ x: 0.8, y: 2.8, w: 11.8, h: 1.5, fontFace: FONT_H, margin: 0 }
);
// Metafora: cosa fa una segretaria reale
const duties = [
{ t: "Legge la tua posta", d: "Filtra, prioritizza, segnala solo ciò che richiede la tua attenzione." },
{ t: "Tiene in ordine l'agenda", d: "Scadenze, impegni, follow-up — tutto tracciato senza chiederti nulla." },
{ t: "Prepara il briefing", d: "Ogni mattina arriva con un piano chiaro: ecco cosa conta oggi." },
{ t: "Ti aiuta a eseguire", d: "Prepara bozze, organizza documenti, ti accompagna mentre lavori." },
];
const cardW = 5.85, gap = 0.2;
duties.forEach((d, i) => {
const col = i % 2, row = Math.floor(i / 2);
const x = 0.8 + col * (cardW + gap);
const y = 4.45 + row * 1.35;
s.addShape(pres.shapes.RECTANGLE, {
x, y, w: cardW, h: 1.2,
fill: { color: C.surface }, line: { color: C.borderSoft, width: 0.75 },
});
s.addShape(pres.shapes.RECTANGLE, {
x, y, w: 0.08, h: 1.2, fill: { color: C.gold }, line: { color: C.gold },
});
s.addText(d.t, {
x: x + 0.3, y: y + 0.15, w: cardW - 0.4, h: 0.4,
color: C.ink, fontFace: FONT_H, fontSize: 16, bold: true, margin: 0,
});
s.addText(d.d, {
x: x + 0.3, y: y + 0.55, w: cardW - 0.4, h: 0.65,
color: C.ink2, fontFace: FONT_B, fontSize: 12, margin: 0,
});
});
}
// ============================================================
// 3 — DAILY BRIEF + CAROUSEL (hero feature)
// ============================================================
{
page++;
const s = pres.addSlide();
bgDark(s);
footer(s, page, TOTAL, true);
slideTitle(s, "Il cuore dell'esperienza", "Il briefing del mattino, poi ti prende per mano.", true);
// Left: screenshot home (mostra il daily brief)
s.addImage({ path: SHOT_HOME, x: 0.8, y: 2.8, w: 6.4, h: 3.6 });
s.addShape(pres.shapes.RECTANGLE, {
x: 0.8, y: 2.8, w: 6.4, h: 3.6,
fill: { type: "solid", color: DARK.bg, transparency: 100 },
line: { color: DARK.border, width: 1 },
});
s.addText("Daily Brief", {
x: 0.8, y: 6.5, w: 6.4, h: 0.35,
color: DARK.muted, fontFace: FONT_H, fontSize: 11, bold: true, align: "center", charSpacing: 4, margin: 0,
});
// Right: caption + carousel feature
s.addText("Ogni mattina, un briefing personalizzato ti racconta cosa è cambiato, cosa scade e cosa conta di più.", {
x: 7.5, y: 2.75, w: 5.2, h: 1.4,
color: DARK.text, fontFace: FONT_H, fontSize: 16, margin: 0,
});
// Carousel card (feature)
s.addShape(pres.shapes.RECTANGLE, {
x: 7.5, y: 4.25, w: 5.2, h: 2.6,
fill: { color: DARK.surface }, line: { color: DARK.border, width: 0.75 },
});
s.addShape(pres.shapes.RECTANGLE, {
x: 7.5, y: 4.25, w: 0.08, h: 2.6,
fill: { color: C.gold }, line: { color: C.gold },
});
s.addText("CAROSELLO DELLE ATTIVITÀ", {
x: 7.75, y: 4.4, w: 5, h: 0.4,
color: C.gold, fontFace: FONT_H, fontSize: 10, bold: true, charSpacing: 4, margin: 0,
});
s.addText(
[
{ text: "Dalla home, avvii il carosello: ", options: { color: DARK.text, bold: true, breakLine: true } },
{ text: "ogni scheda è un'attività che l'AI ritiene prioritaria per la giornata. Ti guida passo passo con le indicazioni per completarla, e puoi chattare con lei mentre lavori — come se avessi la tua segretaria al fianco.",
options: { color: DARK.muted } },
],
{ x: 7.75, y: 4.8, w: 5, h: 1.95, fontFace: FONT_B, fontSize: 13, margin: 0 }
);
}
// ============================================================
// 4 — CHAT CONTESTUALE
// ============================================================
{
page++;
const s = pres.addSlide();
bgDark(s);
footer(s, page, TOTAL, true);
slideTitle(s, "Chat", "Parla con la tua segretaria. In italiano, in linguaggio naturale.", true);
// left: examples
const examples = [
"« Qual è la prossima attività su cui concentrarmi? »",
"« Riassumi le email arrivate stamattina. »",
"« Crea un'attività: richiamare Luca giovedì. »",
"« Cosa è cambiato sul progetto Patient Portal? »",
];
examples.forEach((e, i) => {
const y = 2.8 + i * 0.65;
s.addShape(pres.shapes.RECTANGLE, {
x: 0.8, y, w: 5.6, h: 0.55,
fill: { color: DARK.surface }, line: { color: DARK.border, width: 0.75 },
});
s.addShape(pres.shapes.RECTANGLE, {
x: 0.8, y, w: 0.06, h: 0.55, fill: { color: C.gold }, line: { color: C.gold },
});
s.addText(e, {
x: 1.0, y, w: 5.3, h: 0.55,
color: DARK.text, fontFace: FONT_B, fontSize: 12, italic: true, valign: "middle", margin: 0,
});
});
s.addText("Niente prompt engineering. Niente modelli da scegliere. L'AI giusta lavora in background e ti risponde con il contesto del tuo workspace.", {
x: 0.8, y: 5.65, w: 5.6, h: 1.0,
color: DARK.muted, fontFace: FONT_B, fontSize: 13, margin: 0,
});
// right: screenshot
s.addImage({ path: SHOT_CHAT, x: 6.8, y: 2.6, w: 6.0, h: 3.375 });
s.addShape(pres.shapes.RECTANGLE, {
x: 6.8, y: 2.6, w: 6.0, h: 3.375,
fill: { type: "solid", color: DARK.bg, transparency: 100 },
line: { color: DARK.border, width: 1 },
});
s.addText("Chat contestuale sul workspace", {
x: 6.8, y: 6.05, w: 6.0, h: 0.35,
color: DARK.muted, fontFace: FONT_H, fontSize: 11, bold: true, align: "center", charSpacing: 4, margin: 0,
});
}
// ============================================================
// 5 — FUNZIONALITÀ (compattate)
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
footer(s, page, TOTAL);
slideTitle(s, "Cosa fa", "Tutto il lavoro quotidiano, in un unico posto.");
const items = [
{ t: "Email → Attività", d: "Legge Gmail, Outlook, cartelle locali ed estrae automaticamente task, promemoria e note." },
{ t: "Progetti e clienti", d: "Timeline, milestone, riepiloghi AI per ogni progetto. Tutto collegato." },
{ t: "Note con ricerca semantica", d: "Editor markdown e ricerca vettoriale su tutto ciò che scrivi." },
{ t: "Timeline e milestone", d: "Panoramica visiva delle scadenze e degli stati di avanzamento." },
{ t: "Agenti locali", d: "Sorveglianza file, monitor cartelle, integrazione Telegram." },
{ t: "Voce in riunione", d: "Prende note durante le call, estrae action item.", soon: true },
];
const cardW = 3.95, gap = 0.2;
const cols = 3;
items.forEach((it, i) => {
const col = i % cols, row = Math.floor(i / cols);
const x = 0.8 + col * (cardW + gap);
const y = 2.8 + row * 1.9;
s.addShape(pres.shapes.RECTANGLE, {
x, y, w: cardW, h: 1.7,
fill: { color: C.surface }, line: { color: C.borderSoft, width: 0.75 },
});
if (it.soon) {
s.addShape(pres.shapes.RECTANGLE, {
x: x + cardW - 1.1, y: y + 0.18, w: 0.95, h: 0.3,
fill: { color: C.bg }, line: { color: C.gold, width: 0.75 },
});
s.addText("COMING SOON", {
x: x + cardW - 1.1, y: y + 0.18, w: 0.95, h: 0.3,
color: C.goldDark, fontFace: FONT_H, fontSize: 8, bold: true, align: "center", valign: "middle", charSpacing: 2, margin: 0,
});
}
goldDot(s, x + 0.25, y + 0.3);
s.addText(it.t, {
x: x + 0.5, y: y + 0.2, w: cardW - 1.5, h: 0.4,
color: C.ink, fontFace: FONT_H, fontSize: 15, bold: true, margin: 0,
});
s.addText(it.d, {
x: x + 0.25, y: y + 0.7, w: cardW - 0.5, h: 0.95,
color: C.ink2, fontFace: FONT_B, fontSize: 11.5, margin: 0,
});
});
}
// ============================================================
// 5 — RISERVATEZZA
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
footer(s, page, TOTAL);
slideTitle(s, "Riservatezza", "I tuoi dati non lasciano il tuo computer.");
s.addText("Local-first.", {
x: 0.8, y: 2.8, w: 6, h: 0.9,
color: C.goldDark, fontFace: FONT_H, fontSize: 48, bold: true, margin: 0,
});
s.addText("Tutto gira in locale. Il database è sul tuo disco, cifrato. Nessun server adiuvAI vede i contenuti di email, file o documenti.", {
x: 0.8, y: 3.9, w: 6, h: 2.0,
color: C.ink2, fontFace: FONT_B, fontSize: 15, margin: 0,
});
const comp = [
{ t: "GDPR", d: "I dati non vengono mai trasferiti a terzi. Conformità per architettura." },
{ t: "EU AI Act", d: "Progettato dall'inizio per il nuovo quadro normativo europeo." },
{ t: "Cifratura end-to-end", d: "Backup e sincronizzazione opzionali con cifratura client-side." },
{ t: "No training", d: "I tuoi dati non vengono mai usati per addestrare modelli AI." },
];
comp.forEach((c, i) => {
const col = i % 2, row = Math.floor(i / 2);
const x = 7.2 + col * 2.95;
const y = 2.8 + row * 1.85;
s.addShape(pres.shapes.RECTANGLE, {
x, y, w: 2.85, h: 1.65,
fill: { color: C.surface }, line: { color: C.borderSoft, width: 0.75 },
});
s.addShape(pres.shapes.RECTANGLE, {
x, y, w: 2.85, h: 0.05, fill: { color: C.gold }, line: { color: C.gold },
});
s.addText(c.t, {
x: x + 0.2, y: y + 0.15, w: 2.6, h: 0.4,
color: C.goldDark, fontFace: FONT_H, fontSize: 15, bold: true, margin: 0,
});
s.addText(c.d, {
x: x + 0.2, y: y + 0.6, w: 2.6, h: 1.0,
color: C.ink2, fontFace: FONT_B, fontSize: 11, margin: 0,
});
});
}
// ============================================================
// 6 — POSIZIONAMENTO
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
footer(s, page, TOTAL);
slideTitle(s, "Posizionamento", "Perché non Motion, Notion AI o Microsoft Copilot?");
const rows = [
["", "adiuvAI", "Motion", "Notion AI", "Copilot"],
["Locale, dati sul tuo PC", "Sì", "No", "No", "No"],
["Conforme EU AI Act", "Sì", "n/d", "n/d", "Parziale"],
["Legge email + file + chat", "Sì", "Parziale", "No", "Sì"],
["Daily Brief proattivo", "Sì", "No", "No", "No"],
["AI invisibile (zero prompt)", "Sì", "No", "No", "No"],
];
const tableData = rows.map((r, ri) =>
r.map((cell, ci) => {
if (ri === 0) {
return {
text: cell,
options: {
bold: true, color: ci === 1 ? C.goldDark : C.ink,
fill: { color: C.surface2 },
align: "center", fontFace: FONT_H, fontSize: 13, valign: "middle",
},
};
}
if (ci === 0) {
return {
text: cell,
options: {
fontFace: FONT_B, fontSize: 12, color: C.ink, bold: true,
fill: { color: C.surface }, valign: "middle",
},
};
}
const isYes = cell === "Sì";
return {
text: cell,
options: {
fontFace: FONT_B, fontSize: 12, align: "center", valign: "middle",
color: ci === 1 ? (isYes ? C.goldDark : C.muted) : (isYes ? C.ink : C.muted),
bold: ci === 1,
fill: { color: ci === 1 ? C.surface2 : C.surface },
},
};
})
);
s.addTable(tableData, {
x: 0.8, y: 2.8, w: 11.7,
colW: [4.5, 1.8, 1.8, 1.8, 1.8],
rowH: 0.55,
border: { pt: 1, color: C.borderSoft },
fontFace: FONT_B,
});
s.addText("Gli altri sono cloud-first generalisti. adiuvAI è locale, proattivo, pensato per chi lavora con dati propri.", {
x: 0.8, y: 6.55, w: 12.0, h: 0.5,
color: C.goldDark, fontFace: FONT_B, fontSize: 13, italic: true, margin: 0,
});
}
// ============================================================
// 7 — ROADMAP
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
footer(s, page, TOTAL);
slideTitle(s, "Roadmap", "Dove siamo, dove stiamo andando.");
const tlY = 4.2;
s.addShape(pres.shapes.RECTANGLE, {
x: 1.2, y: tlY, w: 11.0, h: 0.04,
fill: { color: C.border }, line: { color: C.border },
});
const milestones = [
{ x: 1.6, label: "Oggi — Beta privata", items: ["Daily Brief", "Carosello attività", "Email → Task", "Progetti & Note"] },
{ x: 5.6, label: "Giugno 2026 — Beta pubblica", items: ["Telegram bot", "Outlook / Teams", "App mobile companion"] },
{ x: 9.6, label: "Oltre", items: ["Assistente vocale in riunione", "Workspace di team", "SSO e ruoli"] },
];
milestones.forEach((m) => {
s.addShape(pres.shapes.OVAL, {
x: m.x - 0.12, y: tlY - 0.1, w: 0.25, h: 0.25,
fill: { color: C.gold }, line: { color: C.goldDark, width: 1 },
});
s.addText(m.label, {
x: m.x - 0.2, y: tlY - 0.9, w: 4.2, h: 0.5,
color: C.goldDark, fontFace: FONT_H, fontSize: 13, bold: true, margin: 0,
});
s.addText(
m.items.map((it, idx) => ({
text: it,
options: { bullet: { code: "25A0" }, color: C.ink2, breakLine: idx < m.items.length - 1, paraSpaceAfter: 4 },
})),
{ x: m.x - 0.2, y: tlY + 0.3, w: 4.0, h: 2.5, fontFace: FONT_B, fontSize: 12, margin: 0 }
);
});
}
// ============================================================
// 8 — CLOSING
// ============================================================
{
page++;
const s = pres.addSlide();
bgLight(s);
s.addImage({ path: LOGO_ICON, x: (SW - 1.5) / 2, y: 1.5, w: 1.5, h: 1.5 });
s.addText("Meet your new chief of staff.", {
x: 1.0, y: 3.3, w: SW - 2, h: 0.9,
color: C.ink, fontFace: FONT_H, fontSize: 32, bold: true, italic: true, align: "center", margin: 0,
});
s.addShape(pres.shapes.RECTANGLE, {
x: (SW - 0.6) / 2, y: 4.25, w: 0.6, h: 0.04, fill: { color: C.gold }, line: { color: C.gold },
});
s.addText("Beta in arrivo a Giugno 2026. Gli early adopter otterranno accesso prioritario e potranno guidare il roadmap.", {
x: 1.5, y: 4.45, w: SW - 3, h: 1.4,
color: C.ink2, fontFace: FONT_B, fontSize: 16, align: "center", margin: 0,
});
s.addShape(pres.shapes.RECTANGLE, {
x: (SW - 7) / 2, y: 6.0, w: 7, h: 1.1,
fill: { color: C.surface }, line: { color: C.gold, width: 1.5 },
});
s.addText(
[
{ text: "Iscriviti alla waitlist · ", options: { color: C.ink, bold: true } },
{ text: "adiuvai.com", options: { color: C.goldDark, bold: true } },
],
{ x: (SW - 7) / 2, y: 6.0, w: 7, h: 1.1,
fontFace: FONT_H, fontSize: 20, align: "center", valign: "middle", margin: 0 }
);
}
// ============================================================
const OUT = path.resolve("C:/_temp/_adiuvai_workspace/docs/adiuvAI.pptx");
pres.writeFile({ fileName: OUT }).then((f) => console.log("WROTE:", f));

View File

@@ -0,0 +1,120 @@
# Evoluzione della gestione memoria/personalizzazione di adiuvAI
> **Versione:** 1.0 — 2026-04-14
> **Scope:** analisi architetturale e raccomandazioni per l'evoluzione del sottosistema di memoria di adiuvAI (Electron + FastAPI), con focus sul posizionamento "segretaria personale" (cfr. `docs/marketing-strategy.md`).
> **Premessa:** lo stato attuale di `api/app/core/memory_middleware.py` implementa già un modello 4-tier ispirato a MemGPT (core / associative / episodic / proactive) con crittografia Fernet per-utente. Le raccomandazioni partono da qui, non da zero.
---
## 1. Stato attuale (sintesi)
| Tier | Storage | Uso | Gap principale |
|---|---|---|---|
| `core` | Postgres (k/v) crittografato | Preferenze stabili (lingua, ruolo, tono, ecc.) — già usato dall'onboarding | Crescita non controllata, nessuna gerarchia, nessun limite per tier |
| `associative` | `MemoryAssociative` + pgvector (campo `embedding` presente ma **inutilizzato**) | Recupero top-k | Fallback keyword: sta funzionando come "lista recenti", non come semantica |
| `episodic` | Summaries conversazione | Iniettato nel contesto | Summary naïf (`message[:200]`), nessuna compressione LLM, nessun decay |
| `proactive` | Pattern con `confidence` | Suggerimenti | Nessun ciclo che alimenta la tabella — resta vuota in produzione |
**Zero-trust:** la crittografia per-utente è un vincolo architetturale forte. Qualunque tecnica che richieda di "leggere" la memoria lato server deve passare dal Fernet dell'utente → esclude servizi gestiti esterni (Mem0 SaaS, Pinecone con payload in chiaro) per il contenuto, ma lascia liberi i **vettori** (già trattati come deterministici da SHA-256 in `vectors.py`).
---
## 2. Allineamento con il posizionamento "segretaria"
Una segretaria umana eccelle in tre dimensioni mnestiche che gli agenti generici trascurano:
1. **Memoria di ruolo** — sa *chi è* il capo, *cosa gli interessa*, *quali persone* sono VIP, *quali progetti* sono caldi.
2. **Memoria di routine** — conosce gli orari, gli stili comunicativi, le abitudini ("di lunedì il capo vuole il brief entro le 8:00").
3. **Memoria relazionale** — conosce *le persone intorno al capo*: clienti, colleghi, fornitori, con contesto (ultimo contatto, tono appropriato, argomenti in sospeso).
Il sistema attuale copre bene (1) tramite `core`, parzialmente (2) via onboarding, **non copre** (3). Questo è il gap più grande rispetto al marketing promise.
---
## 3. Raccomandazione architetturale: ibrido mirato
Nessuno degli approcci citati va adottato in purezza. La proposta è una **combinazione selettiva** guidata dal dominio:
### 3.1 Base (tutti i tier): MemGPT consolidato + Mem0-style extraction
Mantenere la struttura 4-tier già presente, ma **sostituire le scritture naïf con una pipeline Mem0**:
- **Fase Extraction** (post-conversazione, async):
- Trigger: dopo `store_episode`, una task in background fa girare `gpt-4o-mini` (economico) su `(last_turn, recency_window, core_memory)`.
- Output strutturato: `{candidates: [{type: "fact|preference|relation|routine", content, target_tier}]}`.
- **Fase Update** (decisione ADD/UPDATE/DELETE/NOOP):
- Per ogni candidato: similarity search nel tier target → l'LLM decide l'azione via tool call.
- **Perché Mem0 e non MemGPT puro**: su un'app "segretaria" le informazioni importanti sono *fatti stabili* (il CFO si chiama Giulia, il cliente X paga sempre in ritardo), non conversazioni da rimettere in RAM. Il ciclo Extract/Update è più adatto di una coda FIFO di messaggi.
### 3.2 Estensione dominio-specifica: Knowledge Graph leggero (Mem0g)
Aggiungere un **quinto tier** orientato al dominio segretaria:
- **`relational` tier**: grafo *Entità → Relazione → Entità* memorizzato in Postgres (non serve Neo4j).
- Nodi: Person, Company, Project, Topic (già presenti come entità in `agents` dell'Electron — riutilizzabili).
- Archi: `works_at`, `reports_to`, `stakeholder_of`, `last_contacted_on`, `owes_followup`.
- **Perché un grafo e non solo vettori**: la segretaria deve rispondere a domande tipo *"chi è Marco?"* → embedding testuale confonde "Marco Rossi (cliente)" con "Marco Bianchi (collega)". Il grafo disambigua, i vettori no.
- **Implementazione minima**: nuova tabella `memory_relations(user_id, subject_id, predicate, object_id, confidence, source_episode_id, encrypted_notes)`. Popolata dalla stessa pipeline Extraction.
### 3.3 Dove **non** andare
- **A-Mem / memory evolution retroattiva**: affascinante ma costoso (ri-analizza il passato a ogni nuova nota). Per una segretaria è *anti-pattern* — introduce non-determinismo dove l'utente si aspetta stabilità ("ma ieri mi dicevi un'altra cosa"). Skippare.
- **AutoGPT loop riflessivo**: il dominio è reattivo (brief, follow-up), non goal-seeking autonomo. Over-engineering.
- **LangChain `ConversationBufferMemory`** e parenti: già superati dalla struttura attuale. No regressioni.
---
## 4. Differenziazione per tier di prodotto
La memoria è un asset di differenziazione commerciale **naturale**: più memoria = segretaria più "al corrente". Proposta:
| Capability | Free | Pro | Power | Team |
|---|:-:|:-:|:-:|:-:|
| `core` blocks (max k/v) | 20 | 100 | illimitato | illimitato |
| `episodic` retention | 7 gg | 90 gg | illimitato | illimitato |
| `associative` con pgvector reale (OpenAI embeddings) | ❌ (keyword only) | ✅ | ✅ | ✅ |
| `relational` graph (Mem0g) | ❌ | ✅ base (Person/Project) | ✅ completo + custom predicates | ✅ + condivisione team |
| Mem0 Extraction pipeline LLM | batch giornaliero | realtime post-turn | realtime + proactive mining | realtime + team-wide |
| `proactive` pattern mining | ❌ | ❌ | ✅ (pattern "ogni lunedì…") | ✅ |
| Memory export/import cifrato | ✅ | ✅ | ✅ | ✅ |
| Forget/consent UI (GDPR Art. 17) | ✅ | ✅ | ✅ | ✅ |
**Rationale commerciale:**
- Il Free tier ha una segretaria che "ricorda i fatti base" — sufficiente per la wow-moment del daily brief, non sufficiente per sentirla *tua*.
- Il salto Free → Pro si giustifica con "la segretaria inizia a conoscere davvero le persone che tratti" (embeddings reali + grafo base).
- Il salto Pro → Power si vende come "la segretaria nota i tuoi pattern e te li anticipa" (proactive tier attivo).
- Il Team tier abilita memoria condivisa su entità aziendali comuni (clienti, progetti), mantenendo memoria personale cifrata per-utente.
**Vincolo zero-trust**: il tier-gating si applica a *quanto* si memorizza e *quali pipeline* girano, **mai** a chi può leggere. Il backend continua a non decifrare nulla che non sia strettamente necessario al turn corrente.
---
## 5. Piano di implementazione suggerito (ordine)
1. **Quick win (12 gg)**: attivare davvero `pgvector` sull'`associative` tier (oggi c'è il campo `embedding` ma si usa il fallback keyword). Gate dietro tier ≥ Pro.
2. **Extraction pipeline Mem0-style (1 sett)**: task async post-`store_episode``gpt-4o-mini` → update strutturato dei tier. Log trace per debug. Gate per tier (batch Free vs realtime Pro+).
3. **Relational tier (Mem0g leggero) (12 sett)**: schema nuova tabella, alimentazione dalla pipeline, uso nel prompt agent come contesto "persone e relazioni rilevanti".
4. **Settings > Memory UI**: pagina dedicata per vedere/modificare `core` + `relational` (la segretaria deve essere *correggibile* — è una feature, non un bug, che l'utente possa dire "no, Giulia è la CFO non la CEO"). GDPR-compliant by design.
5. **Proactive mining (opzionale, Power)**: job schedulato che cerca pattern temporali nelle `episodic` e promuove a `proactive` con confidence score.
---
## 6. Rischi e mitigazioni
| Rischio | Mitigazione |
|---|---|
| Costo LLM dell'Extraction pipeline esplode sul Free | Batch notturno per Free (1 run/24h con rate cap), realtime solo Pro+ |
| Memoria "sporca" (fatti estratti errati) erode fiducia nella segretaria | UI di review/edit obbligatoria (punto 4); mai scrivere `core` senza conferma implicita (es. utente non corregge entro N turni) |
| Zero-trust vs embeddings: OpenAI vede il testo dell'embedding | Già accettato dall'architettura attuale per altri flussi (cfr. note su vector search in CLAUDE.md). Documentare esplicitamente nella privacy policy. Opzione BYOK-embedding per tier Power come mitigante marketing. |
| Grafo relazionale cresce indefinitamente | TTL per archi con `last_contacted_on` > 18 mesi + decay sulla `confidence` |
| Drift tra memoria locale (Electron SQLite) e backend | Già gestito come "backend = source of truth" per `core`. Estendere la stessa regola al nuovo `relational`. |
---
## 7. TL;DR
- **Base architetturale:** manteniamo MemGPT 4-tier (già in casa), sostituiamo le scritture naïf con pipeline **Mem0 Extract/Update**.
- **Differenziazione dominio:** aggiungiamo un tier **`relational` (Mem0g leggero)** per modellare persone/progetti — è il vero gap rispetto alla promessa "segretaria".
- **Monetizzazione:** la memoria diventa scala di differenziazione tier (retention, embeddings reali, proactive mining, team sharing) senza violare zero-trust.
- **Da evitare:** A-Mem (troppo non-deterministico), AutoGPT loop (fuori scope), wrapper LangChain legacy (regressione).
- **Primo passo concreto:** accendere pgvector reale sull'`associative` tier — è già pre-cablato e sbloccato da un singolo gate.

253
docs/multi-region-guide.md Normal file
View File

@@ -0,0 +1,253 @@
# Guida Multi-Region — adiuvAI API
> Stato attuale: FastAPI containerizzata (docker-compose) su singolo VPS Hetzner in Europa.
> Obiettivo: ridurre la latenza per utenti fuori dall'Europa.
---
## Fase 1 — Ottimizzare Cloudflare (già in uso)
### 1.1 Argo Smart Routing
- **Dashboard → Traffic → Argo** — attivalo (~$5/mese + $0.10/GB)
- Usa i backbone privati Cloudflare invece dell'internet pubblico
- Riduce la latenza del 30-40% senza toccare nulla lato server
- Singolo cambiamento con miglior rapporto costo/beneficio
### 1.2 SSL/TLS
- **Dashboard → SSL/TLS → Overview** → mode **Full (Strict)** (non "Flexible", causa redirect loop)
- Abilita **TLS 1.3** (meno round-trip nell'handshake)
- Abilita **Early Hints** (103) in Speed → Optimization
### 1.3 Cache Rules
Di default Cloudflare non cachea le risposte API (Content-Type `application/json`). Per gli endpoint pubblici (es. `/api/v1/health`):
- **Dashboard → Caching → Cache Rules** → crea regola:
- Match: `URI Path starts with /api/v1/health`
- Action: Cache, Edge TTL 30s, Browser TTL 10s
- Lato codice: aggiungere header `Cache-Control: public, s-maxage=30` e `CDN-Cache-Control: public, max-age=30` all'health endpoint
- **NON** cacheate endpoint autenticati (il JWT rende ogni richiesta unica)
### 1.4 Response Compression
- **Dashboard → Speed → Optimization → Content Optimization**
- Abilita **Brotli** (più efficiente di gzip per payload JSON)
- Le risposte JSON vengono compresse automaticamente al transit
### 1.5 WebSocket
- **Dashboard → Network** → verifica che **WebSockets** sia ON (default nel piano Free)
- Il `/chat/stream` WebSocket viene proxato ma non cacheato
- Il keepalive di 30s che già avete mantiene la connessione viva attraverso Cloudflare
### 1.6 Tiered Cache (piano Pro+)
- **Dashboard → Caching → Tiered Cache** → attiva **Smart Tiered Caching**
- Cloudflare usa data center "upper-tier" come cache intermedia
- Riduce le hit al tuo origin server
### 1.7 Timeout
- **Dashboard → Network → WebSocket timeout**: aumenta se gli utenti hanno sessioni chat lunghe
- **Proxy Read Timeout**: default 100s, sufficiente per le LLM call (il tool loop ha cap 5 iterazioni)
---
## Fase 2 — Secondo nodo in US East
### Architettura target
```
┌─── Cloudflare (Geo Steering) ───┐
│ │
utenti EU/Africa utenti Americas
│ │
┌────────▼─────────┐ ┌──────────▼─────────┐
│ VPS EU (attuale) │ │ VPS US (nuovo) │
│ docker-compose │ │ docker-compose │
│ app + PG primary │ │ app + PG replica │
└────────┬──────────┘ └──────────┬──────────┘
│ │
└── PG streaming replication ────────┘
(async, read-only replica)
```
### Opzione A: Secondo VPS Hetzner (Ashburn) + Cloudflare Load Balancing
Estensione naturale del setup attuale — minimo cambiamento architetturale.
#### Step 1 — Provisioning del VPS US
1. Crea un VPS Hetzner in **Ashburn (us-east)** (stesse specs del nodo EU)
2. Setup identico: Docker, Docker Compose, git
3. Configura un **tunnel WireGuard** tra EU e US per il traffico DB (mai esporre PG sulla rete pubblica)
#### Step 2 — PostgreSQL Streaming Replication
**Sul PRIMARY (EU):**
1. Creare un utente replication:
```sql
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '<strong_password>';
```
2. Creare un replication slot:
```sql
SELECT pg_create_physical_replication_slot('replica_us_east');
```
3. Configurare `pg_hba.conf` per permettere connessioni dal VPS US:
```
host replication replicator <US_VPS_WIREGUARD_IP>/32 scram-sha-256
```
4. Esporre la porta PG solo sull'IP WireGuard nel `docker-compose.yml`:
```yaml
services:
db:
ports:
- "10.0.0.1:5432:5432" # solo interfaccia WireGuard
```
**Sul REPLICA (US):**
1. Base backup dal primary:
```bash
docker run --rm \
-v postgres_data:/var/lib/postgresql/data \
pgvector/pgvector:pg16 \
bash -c "pg_basebackup -h <PRIMARY_WG_IP> -U replicator \
-D /var/lib/postgresql/data -Fp -Xs -P -R"
```
Il flag `-R` crea automaticamente `standby.signal` e scrive `primary_conninfo` in `postgresql.auto.conf`.
2. Avviare PG in modalita replica (legge `standby.signal` e si connette al primary)
3. Verificare:
- Sul primary: `SELECT * FROM pg_stat_replication;` (deve mostrare il replica)
- Sul replica: `SELECT pg_is_in_recovery();` (deve restituire `t`)
#### Step 3 — Modifiche al codice FastAPI
Modifiche necessarie in `app/config/settings.py`:
- Aggiungere `DATABASE_READ_URL: str = ""` — URL del replica locale per le letture
- Aggiungere `REGION: str = "eu"` — identificativo regione per health check e observability
Modifiche in `app/db.py`:
- Creare un secondo engine `read_engine` che usa `DATABASE_READ_URL` (fallback a `DATABASE_URL` se vuoto)
- Esporre un `get_read_session()` dependency da usare nelle query read-only
Modifiche in `app/main.py`:
- L'health endpoint deve restituire `region` nel payload
- Aggiungere header `Cache-Control` / `CDN-Cache-Control` per il caching all'edge
Nelle route, per le query di sola lettura pesanti (es. ricerca, listing):
- Usare `db: AsyncSession = Depends(get_read_session)` invece di `get_session`
- Le scritture (auth, billing, update) continuano a usare `get_session` (→ primary EU)
#### Step 4 — Docker Compose per il nodo US
Creare un `docker-compose.replica.yml` (override) che:
- Sovrascrive le env dell'app con `DATABASE_READ_URL` verso il DB locale e `DATABASE_URL` verso il primary EU
- Imposta `REGION=us-east`
- Avvia PG in modalita replica (con `primary_conninfo` che punta al primary EU via WireGuard)
Il `.env` sul nodo US:
```env
DATABASE_URL=postgresql+asyncpg://postgres:<pass>@<PRIMARY_WG_IP>:5432/adiuvai
DATABASE_READ_URL=postgresql+asyncpg://postgres:postgres@db:5432/adiuvai
REGION=us-east
PRIMARY_DB_HOST=<PRIMARY_WG_IP>
REPLICATOR_PASSWORD=<strong_password>
# ... resto delle variabili (JWT_SECRET, STRIPE, LLM keys, etc.) identiche al nodo EU
```
Avvio: `docker compose -f docker-compose.yml -f docker-compose.replica.yml up -d`
#### Step 5 — Deploy CI multi-region
Estendere il workflow `.gitea/workflows/deploy.yaml` con un secondo job `deploy-us`:
- Identico a `deploy` ma con SSH verso il VPS US
- Usa `secrets.SSH_HOST_US`, `secrets.SSH_USER_US`, `secrets.SSH_KEY_US`
- Il comando di deploy usa `-f docker-compose.yml -f docker-compose.replica.yml`
- **NON** esegue `alembic upgrade head` — le migrazioni girano solo sul primary (il replica riceve le DDL via replication)
I due job `deploy` e `deploy-us` possono girare in parallelo (entrambi dipendono solo da `test`).
#### Step 6 — Cloudflare Geo Steering
1. **Dashboard → Traffic → Load Balancing** (piano Pro, ~$5/mese per pool)
2. Creare due **Origin Pools**:
- `eu-pool`: origin = IP del VPS EU, health check = `GET /api/v1/health`
- `us-pool`: origin = IP del VPS US, health check = `GET /api/v1/health`
3. Creare un **Load Balancer** su `api.adiuvai.com`:
- Steering policy: **Geo**
- EU/Africa → `eu-pool`
- Americas → `us-pool`
- Fallback: `eu-pool`
4. Impostare health monitor: `GET /api/v1/health`, interval 60s, timeout 5s
- Se un nodo va giù, tutto il traffico va al nodo sano (automatic failover)
### Opzione B: Fly.io (alternativa più semplice, meno controllo)
Se preferisci evitare la gestione manuale di un secondo VPS:
1. Crea un `fly.toml` nella root del progetto API
2. `fly launch` — Fly rileva il Dockerfile e deploya
3. `fly regions add iad` — aggiunge US East (Ashburn)
4. Fly gestisce: routing anycast, health checks, TLS, auto-scaling
5. Il DB resta su Hetzner EU — Fly non risolve il problema del database, ma elimina tutta la gestione infrastrutturale dell'app layer
6. Costo: ~$5-15/mese per region (dipende dalle risorse)
7. Contro: meno controllo, vendor lock-in, il DB non ha replica locale
### Opzione C: Hetzner Cloud Load Balancer + geo DNS esterno
- Hetzner offre load balancer nativi, ma sono single-region (non cross-region)
- Non adatto per geo-routing, utile solo per HA nella stessa region
---
## Fase 3 — Terzo nodo in Asia (futuro)
Stessa procedura della Fase 2:
1. VPS Hetzner Singapore (o AWS ap-southeast-1)
2. Secondo PG replica con slot `replica_asia`
3. Terzo pool in Cloudflare Load Balancing con geo steering per Asia-Pacific
4. Terzo job `deploy-asia` nel CI
Da valutare solo quando il traffico dall'Asia lo giustifica.
---
## Sicurezza della rete tra i nodi
| Metodo | Pro | Contro |
|--------|-----|--------|
| **WireGuard** | Semplice, veloce, <1ms overhead, kernel-level | Setup manuale per nodo |
| **Hetzner vSwitch** | Zero config se entrambi su Hetzner | Solo stessa region |
| **Tailscale** | WireGuard gestito, zero config rete | Dipendenza esterna |
| **SSH tunnel** | Nessun software extra | Overhead maggiore, meno stabile |
**Raccomandazione**: WireGuard (o Tailscale per semplicita) tra tutti i nodi. Mai esporre PostgreSQL 5432 sull'IP pubblico.
---
## Considerazioni specifiche per adiuvAI
- **L'app e local-first**: la maggior parte delle operazioni (tasks, notes, projects) avviene in SQLite locale nell'Electron app. Il backend serve solo auth, chat streaming, cloud storage e billing. Questo significa che la latenza del backend impatta meno di quanto sembrerebbe.
- **WebSocket `/chat/stream`**: il geo steering porta l'utente al nodo piu vicino, ma la risposta LLM dipende dalla latenza verso OpenAI/Anthropic (non verso il tuo server). Il beneficio principale e nel tempo di handshake e nel primo token.
- **`_pending_states` in-memory per OAuth**: gia documentato come non scalabile su multi-worker. Con multi-region diventa critico — servira Redis condiviso o spostare lo state su DB.
- **JWT_SECRET deve essere identico** su tutti i nodi — un token emesso dal nodo EU deve essere validato dal nodo US.
- **Alembic migrations**: eseguire SOLO sul primary. Il replica riceve le DDL via streaming replication.
---
## Stima costi
| Componente | Costo mensile |
|------------|---------------|
| Argo Smart Routing | ~$5 + $0.10/GB |
| Cloudflare Load Balancing | ~$5/pool |
| VPS Hetzner US (CX22) | ~$5-10 |
| WireGuard | Gratis |
| **Totale Fase 1** | **~$5** |
| **Totale Fase 2** | **~$15-20** |

View File

@@ -0,0 +1,484 @@
# Global Notification System — Sonner Toast Integration
## Context
The adiuvAI Electron app has **52+ user-facing mutations** (create/update/delete for tasks, projects, clients, notes, timeline events, agents, settings, auth) with **no unified feedback system**. Some components show a transient "Saved" button label for 2s via `useState` + `setTimeout`; most mutations are completely silent. Errors are handled inconsistently — some show inline text, many are swallowed.
This plan adds a global toast notification system using **shadcn's sonner component**, replacing all ad-hoc patterns with a single i18n-aware API.
---
## Phase 1: Foundation
### 1.1 Install sonner via shadcn CLI
```bash
cd adiuvAI && npx shadcn@latest add sonner
```
This installs the `sonner` npm package and generates `src/renderer/components/ui/sonner.tsx`.
### 1.2 Fix theme import in generated `sonner.tsx`
The generated file imports `useTheme` from `next-themes` (doesn't exist in this app). Replace with:
```tsx
import { useTheme } from "@/components/theme-provider"
```
The app's `useTheme()` returns `{ theme: "dark" | "light" | "system" }` — same shape sonner expects.
Configure `position="bottom-right"` to avoid sidebar collision. Keep `richColors` enabled for variant-specific coloring.
Full target file:
```tsx
import { useTheme } from "@/components/theme-provider"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
position="bottom-right"
richColors
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
```
### 1.3 Place `<Toaster />` in `src/renderer/index.tsx`
Add `<Toaster />` as a sibling of `<RouterProvider />` inside `<ThemeProvider>`:
```tsx
import { Toaster } from '@/components/ui/sonner';
// ...
<ThemeProvider defaultTheme="system" storageKey="adiuvai-theme">
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<LanguageSync />
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</trpc.Provider>
</ThemeProvider>
```
**Why here?** The `<Toaster />` must render OUTSIDE all conditional rendering in AppShell.tsx (which gates LoginForm / OnboardingFlow / main app). Placing it in `index.tsx` ensures toasts work in all three states.
### 1.4 Create `useNotify()` hook
**New file:** `src/renderer/hooks/useNotify.ts`
```tsx
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
type ToastVariant = 'success' | 'error' | 'info' | 'warning';
interface NotifyOptions {
descriptionKey?: string;
values?: Record<string, string | number>;
duration?: number;
}
export function useNotify() {
const { t } = useTranslation();
function notify(variant: ToastVariant, messageKey: string, options?: NotifyOptions) {
const message = t(messageKey, options?.values);
const description = options?.descriptionKey
? t(options.descriptionKey, options?.values)
: undefined;
const duration = options?.duration;
switch (variant) {
case 'success': toast.success(message, { description, duration: duration ?? 3000 }); break;
case 'error': toast.error(message, { description, duration: duration ?? Infinity }); break;
case 'info': toast.info(message, { description, duration: duration ?? 3000 }); break;
case 'warning': toast.warning(message, { description, duration: duration ?? 4000 }); break;
}
}
function notifyError(messageKey: string, error?: { message?: string }) {
toast.error(t(messageKey), { description: error?.message, duration: Infinity });
}
function notifyPromise<T>(promise: Promise<T>, keys: { loading: string; success: string; error: string }) {
toast.promise(promise, { loading: t(keys.loading), success: t(keys.success), error: t(keys.error) });
}
return { notify, notifyError, notifyPromise };
}
```
**Design rationale:**
- Error toasts: `duration: Infinity` — persist until dismissed so users can read/copy errors
- Success: 3s auto-dismiss — brief confirmation
- Warning (destructive): 4s — slightly longer for delete confirmations
- `notifyError`: convenience for `onError` callbacks — title from i18n, description from raw error
- `notifyPromise`: wraps `toast.promise()` for long-running ops
- All text goes through `t()` for i18n
### 1.5 Add i18n toast keys
**Files:** `src/renderer/locales/{en,it,es,fr,de}/translation.json`
Add a `toast` top-level key. English:
```json
"toast": {
"profile": {
"updated": "Profile updated",
"updateError": "Failed to update profile"
},
"settings": {
"languageChanged": "Language changed",
"backendUrlSaved": "Server URL saved",
"backendUrlError": "Failed to save server URL",
"formatPrefsSaved": "Display preferences saved",
"formatPrefsError": "Failed to save display preferences",
"memorySaved": "Preferences saved",
"memoryError": "Failed to save preferences"
},
"auth": {
"loginError": "Sign-in failed",
"registerError": "Registration failed",
"oauthError": "Google sign-in failed",
"loggedOut": "Signed out"
},
"onboarding": {
"completed": "Onboarding complete",
"completedDescription": "Your workspace is personalized",
"error": "Failed to save onboarding",
"reset": "Onboarding reset",
"normalizing": "Personalizing your workspace...",
"normalized": "Personalization ready"
},
"task": {
"created": "Task created",
"createError": "Failed to create task",
"updated": "Task updated",
"updateError": "Failed to update task",
"deleted": "Task deleted",
"deleteError": "Failed to delete task"
},
"project": {
"created": "Project created",
"createError": "Failed to create project",
"updated": "Project updated",
"updateError": "Failed to update project",
"deleted": "Project deleted",
"deleteError": "Failed to delete project",
"archived": "Project archived",
"unarchived": "Project unarchived",
"archivedAll": "All projects archived",
"unarchivedAll": "All projects unarchived"
},
"client": {
"created": "Client created",
"createError": "Failed to create client",
"updated": "Client renamed",
"updateError": "Failed to rename client",
"deleted": "Client deleted",
"deleteError": "Failed to delete client"
},
"note": {
"created": "Note created",
"createError": "Failed to create note",
"deleted": "Note deleted",
"deleteError": "Failed to delete note"
},
"timeline": {
"created": "Event created",
"createError": "Failed to create event",
"updated": "Event updated",
"updateError": "Failed to update event",
"deleted": "Event deleted",
"deleteError": "Failed to delete event"
},
"comment": {
"created": "Comment added",
"createError": "Failed to add comment",
"deleted": "Comment deleted",
"deleteError": "Failed to delete comment"
},
"agent": {
"created": "Agent created",
"createError": "Failed to create agent",
"updated": "Agent configuration saved",
"updateError": "Failed to save agent configuration",
"deleted": "Agent deleted",
"deleteError": "Failed to delete agent",
"runStarted": "Agent run started",
"runError": "Failed to start agent"
}
}
```
**Italian translations:**
```json
"toast": {
"profile": { "updated": "Profilo aggiornato", "updateError": "Impossibile aggiornare il profilo" },
"settings": { "languageChanged": "Lingua cambiata", "backendUrlSaved": "URL del server salvato", "backendUrlError": "Impossibile salvare l'URL del server", "formatPrefsSaved": "Preferenze di visualizzazione salvate", "formatPrefsError": "Impossibile salvare le preferenze", "memorySaved": "Preferenze salvate", "memoryError": "Impossibile salvare le preferenze" },
"auth": { "loginError": "Accesso fallito", "registerError": "Registrazione fallita", "oauthError": "Accesso con Google fallito", "loggedOut": "Disconnesso" },
"onboarding": { "completed": "Onboarding completato", "completedDescription": "Il tuo workspace e' personalizzato", "error": "Impossibile salvare l'onboarding", "reset": "Onboarding ripristinato", "normalizing": "Personalizzazione in corso...", "normalized": "Personalizzazione pronta" },
"task": { "created": "Attivita' creata", "createError": "Impossibile creare l'attivita'", "updated": "Attivita' aggiornata", "updateError": "Impossibile aggiornare l'attivita'", "deleted": "Attivita' eliminata", "deleteError": "Impossibile eliminare l'attivita'" },
"project": { "created": "Progetto creato", "createError": "Impossibile creare il progetto", "updated": "Progetto aggiornato", "updateError": "Impossibile aggiornare il progetto", "deleted": "Progetto eliminato", "deleteError": "Impossibile eliminare il progetto", "archived": "Progetto archiviato", "unarchived": "Progetto ripristinato", "archivedAll": "Tutti i progetti archiviati", "unarchivedAll": "Tutti i progetti ripristinati" },
"client": { "created": "Cliente creato", "createError": "Impossibile creare il cliente", "updated": "Cliente rinominato", "updateError": "Impossibile rinominare il cliente", "deleted": "Cliente eliminato", "deleteError": "Impossibile eliminare il cliente" },
"note": { "created": "Nota creata", "createError": "Impossibile creare la nota", "deleted": "Nota eliminata", "deleteError": "Impossibile eliminare la nota" },
"timeline": { "created": "Evento creato", "createError": "Impossibile creare l'evento", "updated": "Evento aggiornato", "updateError": "Impossibile aggiornare l'evento", "deleted": "Evento eliminato", "deleteError": "Impossibile eliminare l'evento" },
"comment": { "created": "Commento aggiunto", "createError": "Impossibile aggiungere il commento", "deleted": "Commento eliminato", "deleteError": "Impossibile eliminare il commento" },
"agent": { "created": "Agente creato", "createError": "Impossibile creare l'agente", "updated": "Configurazione agente salvata", "updateError": "Impossibile salvare la configurazione", "deleted": "Agente eliminato", "deleteError": "Impossibile eliminare l'agente", "runStarted": "Esecuzione agente avviata", "runError": "Impossibile avviare l'agente" }
}
```
**Spanish translations:**
```json
"toast": {
"profile": { "updated": "Perfil actualizado", "updateError": "Error al actualizar el perfil" },
"settings": { "languageChanged": "Idioma cambiado", "backendUrlSaved": "URL del servidor guardada", "backendUrlError": "Error al guardar la URL del servidor", "formatPrefsSaved": "Preferencias de visualizacion guardadas", "formatPrefsError": "Error al guardar las preferencias", "memorySaved": "Preferencias guardadas", "memoryError": "Error al guardar las preferencias" },
"auth": { "loginError": "Error de acceso", "registerError": "Error de registro", "oauthError": "Error de acceso con Google", "loggedOut": "Sesion cerrada" },
"onboarding": { "completed": "Configuracion completada", "completedDescription": "Tu espacio de trabajo esta personalizado", "error": "Error al guardar la configuracion", "reset": "Configuracion reiniciada", "normalizing": "Personalizando tu espacio...", "normalized": "Personalizacion lista" },
"task": { "created": "Tarea creada", "createError": "Error al crear la tarea", "updated": "Tarea actualizada", "updateError": "Error al actualizar la tarea", "deleted": "Tarea eliminada", "deleteError": "Error al eliminar la tarea" },
"project": { "created": "Proyecto creado", "createError": "Error al crear el proyecto", "updated": "Proyecto actualizado", "updateError": "Error al actualizar el proyecto", "deleted": "Proyecto eliminado", "deleteError": "Error al eliminar el proyecto", "archived": "Proyecto archivado", "unarchived": "Proyecto restaurado", "archivedAll": "Todos los proyectos archivados", "unarchivedAll": "Todos los proyectos restaurados" },
"client": { "created": "Cliente creado", "createError": "Error al crear el cliente", "updated": "Cliente renombrado", "updateError": "Error al renombrar el cliente", "deleted": "Cliente eliminado", "deleteError": "Error al eliminar el cliente" },
"note": { "created": "Nota creada", "createError": "Error al crear la nota", "deleted": "Nota eliminada", "deleteError": "Error al eliminar la nota" },
"timeline": { "created": "Evento creado", "createError": "Error al crear el evento", "updated": "Evento actualizado", "updateError": "Error al actualizar el evento", "deleted": "Evento eliminado", "deleteError": "Error al eliminar el evento" },
"comment": { "created": "Comentario agregado", "createError": "Error al agregar el comentario", "deleted": "Comentario eliminado", "deleteError": "Error al eliminar el comentario" },
"agent": { "created": "Agente creado", "createError": "Error al crear el agente", "updated": "Configuracion del agente guardada", "updateError": "Error al guardar la configuracion", "deleted": "Agente eliminado", "deleteError": "Error al eliminar el agente", "runStarted": "Ejecucion del agente iniciada", "runError": "Error al iniciar el agente" }
}
```
**French translations:**
```json
"toast": {
"profile": { "updated": "Profil mis a jour", "updateError": "Impossible de mettre a jour le profil" },
"settings": { "languageChanged": "Langue modifiee", "backendUrlSaved": "URL du serveur enregistree", "backendUrlError": "Impossible d'enregistrer l'URL du serveur", "formatPrefsSaved": "Preferences d'affichage enregistrees", "formatPrefsError": "Impossible d'enregistrer les preferences", "memorySaved": "Preferences enregistrees", "memoryError": "Impossible d'enregistrer les preferences" },
"auth": { "loginError": "Echec de la connexion", "registerError": "Echec de l'inscription", "oauthError": "Echec de la connexion Google", "loggedOut": "Deconnecte" },
"onboarding": { "completed": "Configuration terminee", "completedDescription": "Votre espace de travail est personnalise", "error": "Impossible d'enregistrer la configuration", "reset": "Configuration reinitialisee", "normalizing": "Personnalisation en cours...", "normalized": "Personnalisation terminee" },
"task": { "created": "Tache creee", "createError": "Impossible de creer la tache", "updated": "Tache mise a jour", "updateError": "Impossible de mettre a jour la tache", "deleted": "Tache supprimee", "deleteError": "Impossible de supprimer la tache" },
"project": { "created": "Projet cree", "createError": "Impossible de creer le projet", "updated": "Projet mis a jour", "updateError": "Impossible de mettre a jour le projet", "deleted": "Projet supprime", "deleteError": "Impossible de supprimer le projet", "archived": "Projet archive", "unarchived": "Projet restaure", "archivedAll": "Tous les projets archives", "unarchivedAll": "Tous les projets restaures" },
"client": { "created": "Client cree", "createError": "Impossible de creer le client", "updated": "Client renomme", "updateError": "Impossible de renommer le client", "deleted": "Client supprime", "deleteError": "Impossible de supprimer le client" },
"note": { "created": "Note creee", "createError": "Impossible de creer la note", "deleted": "Note supprimee", "deleteError": "Impossible de supprimer la note" },
"timeline": { "created": "Evenement cree", "createError": "Impossible de creer l'evenement", "updated": "Evenement mis a jour", "updateError": "Impossible de mettre a jour l'evenement", "deleted": "Evenement supprime", "deleteError": "Impossible de supprimer l'evenement" },
"comment": { "created": "Commentaire ajoute", "createError": "Impossible d'ajouter le commentaire", "deleted": "Commentaire supprime", "deleteError": "Impossible de supprimer le commentaire" },
"agent": { "created": "Agent cree", "createError": "Impossible de creer l'agent", "updated": "Configuration de l'agent enregistree", "updateError": "Impossible d'enregistrer la configuration", "deleted": "Agent supprime", "deleteError": "Impossible de supprimer l'agent", "runStarted": "Execution de l'agent lancee", "runError": "Impossible de lancer l'agent" }
}
```
**German translations:**
```json
"toast": {
"profile": { "updated": "Profil aktualisiert", "updateError": "Profil konnte nicht aktualisiert werden" },
"settings": { "languageChanged": "Sprache geaendert", "backendUrlSaved": "Server-URL gespeichert", "backendUrlError": "Server-URL konnte nicht gespeichert werden", "formatPrefsSaved": "Anzeigeeinstellungen gespeichert", "formatPrefsError": "Einstellungen konnten nicht gespeichert werden", "memorySaved": "Einstellungen gespeichert", "memoryError": "Einstellungen konnten nicht gespeichert werden" },
"auth": { "loginError": "Anmeldung fehlgeschlagen", "registerError": "Registrierung fehlgeschlagen", "oauthError": "Google-Anmeldung fehlgeschlagen", "loggedOut": "Abgemeldet" },
"onboarding": { "completed": "Einrichtung abgeschlossen", "completedDescription": "Ihr Arbeitsbereich ist personalisiert", "error": "Einrichtung konnte nicht gespeichert werden", "reset": "Einrichtung zurueckgesetzt", "normalizing": "Personalisierung laeuft...", "normalized": "Personalisierung abgeschlossen" },
"task": { "created": "Aufgabe erstellt", "createError": "Aufgabe konnte nicht erstellt werden", "updated": "Aufgabe aktualisiert", "updateError": "Aufgabe konnte nicht aktualisiert werden", "deleted": "Aufgabe geloescht", "deleteError": "Aufgabe konnte nicht geloescht werden" },
"project": { "created": "Projekt erstellt", "createError": "Projekt konnte nicht erstellt werden", "updated": "Projekt aktualisiert", "updateError": "Projekt konnte nicht aktualisiert werden", "deleted": "Projekt geloescht", "deleteError": "Projekt konnte nicht geloescht werden", "archived": "Projekt archiviert", "unarchived": "Projekt wiederhergestellt", "archivedAll": "Alle Projekte archiviert", "unarchivedAll": "Alle Projekte wiederhergestellt" },
"client": { "created": "Kunde erstellt", "createError": "Kunde konnte nicht erstellt werden", "updated": "Kunde umbenannt", "updateError": "Kunde konnte nicht umbenannt werden", "deleted": "Kunde geloescht", "deleteError": "Kunde konnte nicht geloescht werden" },
"note": { "created": "Notiz erstellt", "createError": "Notiz konnte nicht erstellt werden", "deleted": "Notiz geloescht", "deleteError": "Notiz konnte nicht geloescht werden" },
"timeline": { "created": "Ereignis erstellt", "createError": "Ereignis konnte nicht erstellt werden", "updated": "Ereignis aktualisiert", "updateError": "Ereignis konnte nicht aktualisiert werden", "deleted": "Ereignis geloescht", "deleteError": "Ereignis konnte nicht geloescht werden" },
"comment": { "created": "Kommentar hinzugefuegt", "createError": "Kommentar konnte nicht hinzugefuegt werden", "deleted": "Kommentar geloescht", "deleteError": "Kommentar konnte nicht geloescht werden" },
"agent": { "created": "Agent erstellt", "createError": "Agent konnte nicht erstellt werden", "updated": "Agent-Konfiguration gespeichert", "updateError": "Konfiguration konnte nicht gespeichert werden", "deleted": "Agent geloescht", "deleteError": "Agent konnte nicht geloescht werden", "runStarted": "Agent-Ausfuehrung gestartet", "runError": "Agent konnte nicht gestartet werden" }
}
```
---
## Phase 2: Settings Mutations (replace existing `saved`/`setSaved` patterns)
These 5 components have existing feedback to **remove and replace**:
### 2.1 `src/renderer/components/settings/GeneralSection.tsx`
**Current:** `saved`/`setSaved` state (line 28), `error`/`setError` state (line 29), `setTimeout` (line 44), inline `<p>` error (line 93).
**Changes:**
1. Add `const { notify, notifyError } = useNotify();`
2. **Remove** `const [saved, setSaved] = useState(false);`
3. **Remove** `const [error, setError] = useState('');`
4. In `handleSave` `onSuccess`: remove `setSaved(true); setTimeout(...)` → add `notify('success', 'toast.profile.updated');`
5. In `handleSave` `onError`: replace `setError(err.message)``notifyError('toast.profile.updateError', err);`
6. **Remove** inline error `<p>` tag (line 93)
7. **Remove** `setSaved(false)` from `onChange` handlers (lines 83, 89)
8. Button text: `{saved ? t('settings.saved') : t('common.save')}``{t('common.save')}`
9. In `handleLanguageChange`: add `notify('info', 'toast.settings.languageChanged');`
### 2.2 `src/renderer/components/settings/ProfileSection.tsx`
**Current:** `profileSaved`/`displaySaved` states, both with `setTimeout`.
**Changes:**
1. Add `useNotify()`, remove both `saved` states and `setTimeout`s
2. Profile save `onSuccess``notify('success', 'toast.settings.memorySaved')`
3. Display save `onSuccess``notify('success', 'toast.settings.formatPrefsSaved')`
4. Reset onboarding `onSuccess``notify('info', 'toast.onboarding.reset')`
5. Both save buttons: replace ternary with `{t('common.save')}`
### 2.3 `src/renderer/components/settings/AccountSection.tsx`
**Current:** `urlSaved`/`setUrlSaved` state, `setTimeout`.
**Changes:**
1. Add `useNotify()`, remove `urlSaved` state and `setTimeout`
2. Backend URL save `onSuccess``notify('success', 'toast.settings.backendUrlSaved')`
3. Add `onError``notifyError('toast.settings.backendUrlError', err)`
4. Logout `onSuccess``notify('info', 'toast.auth.loggedOut')`
5. Button text: replace ternary with `{t('common.save')}`
### 2.4 `src/renderer/components/settings/LocalAgentConfigPanel.tsx`
**Current:** `saved`/`setSaved` state, `setTimeout`.
**Changes:**
1. Add `useNotify()`, remove `saved` state and `setTimeout`
2. Save `onSuccess``notify('success', 'toast.agent.updated')`
3. Add `onError``notifyError('toast.agent.updateError', err)`
4. Button text: `{t('common.save')}`
### 2.5 `src/renderer/components/settings/CloudAgentConfigPanel.tsx`
Identical pattern to 2.4.
---
## Phase 3: CRUD Operations
### Tasks
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/tasks/NewTaskDialog.tsx` | `tasks.create` | success | `toast.task.created` |
| `components/tasks/EditTaskDialog.tsx` | `tasks.update` | success | `toast.task.updated` |
| `components/tasks/TaskDetailDialog.tsx` | `taskComments.create` | success | `toast.comment.created` |
| `components/tasks/TaskDetailDialog.tsx` | `taskComments.delete` | warning | `toast.comment.deleted` |
| `routes/tasks.tsx` | `tasks.delete` | warning | `toast.task.deleted` |
| `components/projects/KanbanBoard.tsx` | `tasks.delete` | warning | `toast.task.deleted` |
| `components/projects/ProjectDetail.tsx` | `tasks.delete` | warning | `toast.task.deleted` |
| `components/ai/blocks/ChatEntityBlock.tsx` | `tasks.delete` | warning | `toast.task.deleted` |
**Error-only (no success toast):** Status toggle mutations in `routes/tasks.tsx`, `KanbanBoard.tsx`, `ProjectDetail.tsx`, `ChatEntityBlock.tsx` — visual feedback (badge/card move) IS the confirmation.
### Projects
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/projects/ProjectSidebar.tsx` | `projects.create` | success | `toast.project.created` |
| `components/projects/ProjectSidebar.tsx` | `projects.update` | success | `toast.project.updated` |
| `components/projects/ProjectSidebar.tsx` | `projects.delete` | warning | `toast.project.deleted` |
| `components/projects/ProjectSidebar.tsx` | `projects.archiveByClient` | warning | `toast.project.archivedAll` / `unarchivedAll` |
### Clients
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/projects/ProjectSidebar.tsx` | `clients.create` | success | `toast.client.created` |
| `components/projects/ProjectSidebar.tsx` | `clients.update` | success | `toast.client.updated` |
| `components/projects/ProjectSidebar.tsx` | `clients.deleteWithCascade` | warning | `toast.client.deleted` |
| `components/tasks/NewTaskDialog.tsx` | `clients.create` (inline) | success | `toast.client.created` |
### Notes
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/projects/ProjectDetail.tsx` | `notes.create` | success | `toast.note.created` |
| `routes/notes.$noteId.tsx` | `notes.delete` | warning | `toast.note.deleted` |
### Timeline Events
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/timeline/AddEventDialog.tsx` | `timelineEvents.create` | success | `toast.timeline.created` |
| `components/timeline/EditEventDialog.tsx` | `timelineEvents.update` | success | `toast.timeline.updated` |
| `routes/timeline.tsx` | `timelineEvents.delete` | warning | `toast.timeline.deleted` |
| `routes/timeline.tsx` | `timelineEvents.update` | success | `toast.timeline.updated` |
### Agents
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/settings/AgentsSection.tsx` | `agent.*.delete` | warning | `toast.agent.deleted` |
| `components/settings/AgentsSection.tsx` | `agent.runNow` | promise | `toast.agent.runStarted` / `runError` |
| `components/settings/InlineAgentCreationStepper.tsx` | `agent.*.create` | success | `toast.agent.created` |
---
## Phase 4: Auth + Onboarding
| File | Mutation | Toast | Key |
|------|----------|-------|-----|
| `components/auth/LoginForm.tsx` | `auth.login` error | error | `toast.auth.loginError` |
| `components/auth/LoginForm.tsx` | `auth.register` error | error | `toast.auth.registerError` |
| `components/auth/LoginForm.tsx` | `auth.loginWithOAuth` error | error | `toast.auth.oauthError` |
| `components/layout/AppShell.tsx` | `auth.logout` success | info | `toast.auth.loggedOut` |
| `components/onboarding/OnboardingFlow.tsx` | final save success | success | `toast.onboarding.completed` |
| `components/onboarding/OnboardingFlow.tsx` | normalize call | promise | `toast.onboarding.normalizing` / `normalized` |
**Auth note:** Keep inline form errors in LoginForm alongside toast — form-level positional context is valuable.
---
## Explicitly SILENT Mutations (no toast)
| Mutation | Reason |
|----------|--------|
| `notes.update` (auto-save debounced 2s) | Would spam a toast every 2s while typing |
| `tasks.update` status toggle (kanban drag, checkbox, status cycle) | Card movement / badge change IS the feedback |
| `settings.setSidebarCollapsed` | Sidebar animation IS the feedback |
| `ai.chat` / `ai.dailyBrief` | Has own streaming UI |
| `agent.journey.*` | Has own conversational UI |
For these: add `onError` callback only (no success toast).
---
## All Files Modified (Summary)
**New files (2):**
- `src/renderer/components/ui/sonner.tsx` — generated by CLI + theme fix
- `src/renderer/hooks/useNotify.ts` — custom hook
**Modified files (~25):**
- `package.json``sonner` dependency (automatic via CLI)
- `src/renderer/index.tsx` — add `<Toaster />`
- 5x `locales/{en,it,es,fr,de}/translation.json` — add `toast` keys
- 5x Settings components — replace saved state with toast
- ~14x CRUD/auth/onboarding components — add toast calls
---
## Verification
1. **All app states:** Trigger toasts during login (error), onboarding (completion), and normal usage (CRUD)
2. **Theme:** Switch dark/light/system — toast backgrounds should follow semantic tokens
3. **i18n:** Switch to Italian → trigger save → toast should read "Profilo aggiornato"
4. **Error persistence:** Error toasts stay until dismissed (test with invalid backend URL)
5. **Silent mutations:** Drag task on kanban, type in notes, toggle sidebar — NO toasts
6. **Removed patterns:** Settings Save buttons stay as "Save" (no "Saved" flash) + toast appears
7. **Position:** Bottom-right, not overlapping sidebar
8. **Lint:** `npm run lint` passes
9. **Build:** `npm run package` succeeds