update skill config

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

View File

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

BIN
docs/adiuvAI.pptx Normal file

Binary file not shown.

View File

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

View File

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

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

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

View File

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