diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a87f43f..9c715fd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 `` 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 `` 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=` 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=` 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 \ No newline at end of file diff --git a/.claude/CLAUDE.original.md b/.claude/CLAUDE.original.md new file mode 100644 index 0000000..a676813 --- /dev/null +++ b/.claude/CLAUDE.original.md @@ -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 `` 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=` 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=` 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 diff --git a/.claude/settings.json b/.claude/settings.json index fceb05d..86ca5cf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,6 +5,7 @@ ] }, "enabledPlugins": { - "ralph-loop@claude-plugins-official": true + "ralph-loop@claude-plugins-official": true, + "caveman@caveman": true } } diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 23011cb..00f02e4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aece012 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.vscode/mcp.json b/.vscode/mcp.json index ea41438..016b0dc 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,9 +1,4 @@ { - "servers": { - "langfuse-docs": { - "url": "https://langfuse.com/api/mcp", - "type": "http" - } - }, - "inputs": [] + "inputs": [], + "servers": {} } \ No newline at end of file diff --git a/adiuvAI b/adiuvAI index 0371a46..333b6cb 160000 --- a/adiuvAI +++ b/adiuvAI @@ -1 +1 @@ -Subproject commit 0371a46731e771c8358231a531870d7d812d5f52 +Subproject commit 333b6cb769f47c1e666b7637523c0b3c9f7cb2ff diff --git a/docs/PROMPT-sonner-notifications.md b/docs/PROMPT-sonner-notifications.md new file mode 100644 index 0000000..3af71a9 --- /dev/null +++ b/docs/PROMPT-sonner-notifications.md @@ -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 `` component. + +### Step 3: Add `` to `src/renderer/index.tsx` + +Import `Toaster` from `@/components/ui/sonner` and render it as the last child inside ``, AFTER ``. 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 `

` 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`) +- `` renders in `index.tsx` inside `` +- `useNotify.ts` exists in `src/renderer/hooks/` + +If everything passes: + +SONNER NOTIFICATIONS COMPLETE diff --git a/docs/adiuvAI.pptx b/docs/adiuvAI.pptx new file mode 100644 index 0000000..34194a8 Binary files /dev/null and b/docs/adiuvAI.pptx differ diff --git a/docs/build-deck-commercialista.js b/docs/build-deck-commercialista.js new file mode 100644 index 0000000..ab7c25a --- /dev/null +++ b/docs/build-deck-commercialista.js @@ -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)); diff --git a/docs/memory-evolution-strategy.md b/docs/memory-evolution-strategy.md new file mode 100644 index 0000000..a11c33f --- /dev/null +++ b/docs/memory-evolution-strategy.md @@ -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 (1–2 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) (1–2 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. diff --git a/docs/multi-region-guide.md b/docs/multi-region-guide.md new file mode 100644 index 0000000..83cda50 --- /dev/null +++ b/docs/multi-region-guide.md @@ -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 ''; + ``` +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 /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 -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:@:5432/adiuvai +DATABASE_READ_URL=postgresql+asyncpg://postgres:postgres@db:5432/adiuvai +REGION=us-east +PRIMARY_DB_HOST= +REPLICATOR_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** | diff --git a/docs/plan-sonner-notifications.md b/docs/plan-sonner-notifications.md new file mode 100644 index 0000000..f01d3ec --- /dev/null +++ b/docs/plan-sonner-notifications.md @@ -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 + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + return ( + + ) +} + +export { Toaster } +``` + +### 1.3 Place `` in `src/renderer/index.tsx` + +Add `` as a sibling of `` inside ``: + +```tsx +import { Toaster } from '@/components/ui/sonner'; +// ... + + + + + + + + + +``` + +**Why here?** The `` 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; + 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(promise: Promise, 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 `

` 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 `

` 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 `` +- 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