feat(onboarding): implement first-run user onboarding wizard for profile setup
- Added a new onboarding wizard that runs on the first app launch post-login. - Collects user personalization data (job role, industry, primary use case, tone preference, language) and stores it in encrypted core memory. - Auto-detects and saves formatting preferences (timezone, time format, date format) in local electron-store. - Normalizes user free-text inputs via a backend LLM call before persisting. - Introduced new backend routes for memory updates and normalization. - Updated frontend components to support the onboarding flow with a chat-bubble aesthetic. - Added settings section for profile editing and re-running the onboarding process. - Ensured that the onboarding process is skippable and editable in the settings. - Implemented verification steps to ensure proper functionality and data handling. chore: update submodules for waitlist and website
This commit is contained in:
713
docs/PROMPT-onboarding.md
Normal file
713
docs/PROMPT-onboarding.md
Normal file
@@ -0,0 +1,713 @@
|
||||
# RALPH LOOP PROMPT — First-Run Onboarding Wizard
|
||||
|
||||
> **How to run:**
|
||||
> ```
|
||||
> /ralph-loop "Implement the onboarding wizard exactly as specified in docs/PROMPT-onboarding.md. Output <promise>ONBOARDING COMPLETE</promise> when all phases pass lint." --max-iterations 25 --completion-promise "ONBOARDING COMPLETE"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## INSTRUCTIONS FOR CLAUDE
|
||||
|
||||
You are implementing a first-run onboarding wizard for the adiuvAI Electron app. This is a **multi-file, multi-iteration** task. On each iteration:
|
||||
|
||||
1. **Read this file** in full.
|
||||
2. **Inspect which tasks are already done** by checking if the target files exist and contain the expected code.
|
||||
3. **Pick the next incomplete task** (always in phase order: Phase 1 → 2 → 3 → 4).
|
||||
4. **Implement it**, then **run the relevant lint command** before exiting.
|
||||
5. When ALL phases are complete AND both lint commands pass, output `<promise>ONBOARDING COMPLETE</promise>`.
|
||||
|
||||
**DO NOT** skip phases. **DO NOT** implement out of order — backend must exist before the FE can call it.
|
||||
|
||||
**LINT COMMANDS** (run after each phase):
|
||||
- Backend: `cd api && ruff check . --fix`
|
||||
- Frontend: `cd adiuvAI && npx eslint . --fix`
|
||||
|
||||
---
|
||||
|
||||
## WHAT THIS FEATURE DOES
|
||||
|
||||
After login, new users see a chat-styled wizard that collects 5 fields:
|
||||
- `job_role`, `industry`, `primary_use_case`, `tone_preference`, `language`
|
||||
|
||||
These are stored encrypted in `MemoryCore` (backend) so the AI agents personalize responses. Three formatting prefs (`timezone`, `date_format`, `time_format`) are auto-detected from the OS and stored in electron-store (FE only) — the LLM never sees them. The FE formats all timestamp columns in tool-result rows before sending them back to the backend.
|
||||
|
||||
**Storage split:**
|
||||
| Field | Where | Why |
|
||||
|-------|-------|-----|
|
||||
| job_role, industry, primary_use_case, tone_preference, language | `MemoryCore` (backend, encrypted) | LLM needs these for personalization |
|
||||
| timezone, date_format, time_format | electron-store (FE) | FE formatter only — LLM must never see raw timestamps |
|
||||
|
||||
**Key architectural fact:** `memory_middleware.py` `enrich_context()` already injects `core_memory` into every orchestrator call. Writing to `MemoryCore` is sufficient — no system-prompt changes needed.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Backend (api/)
|
||||
|
||||
### TASK 1.1: Alembic migration — `onboarding_completed_at` column
|
||||
|
||||
**File:** `api/alembic/versions/XXX_add_onboarding_completed_at.py` (new)
|
||||
|
||||
Create a new Alembic migration that adds:
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN onboarding_completed_at TIMESTAMPTZ NULL;
|
||||
```
|
||||
|
||||
Use the existing migrations in `api/alembic/versions/` as a pattern reference. The revision ID should be sequential (check the latest existing migration number and increment).
|
||||
|
||||
**Done signal:** File exists in `api/alembic/versions/` with the column add.
|
||||
|
||||
---
|
||||
|
||||
### TASK 1.2: Add column to User model
|
||||
|
||||
**File:** `api/app/models.py`
|
||||
|
||||
Find the `User` class (around line 63-94). Add:
|
||||
```python
|
||||
onboarding_completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
```
|
||||
|
||||
Import `DateTime` from sqlalchemy if not already imported.
|
||||
|
||||
**Done signal:** `User` model has `onboarding_completed_at` field.
|
||||
|
||||
---
|
||||
|
||||
### TASK 1.3: Extend UserProfile schema
|
||||
|
||||
**File:** `api/app/schemas.py`
|
||||
|
||||
Find `UserProfile` (around line 27-33). Add two fields:
|
||||
```python
|
||||
onboarding_completed_at: int | None = None # epoch ms, null = not onboarded
|
||||
memory: dict[str, str] = Field(default_factory=dict) # decrypted core memory k/v
|
||||
```
|
||||
|
||||
**Done signal:** `UserProfile` has both new fields.
|
||||
|
||||
---
|
||||
|
||||
### TASK 1.4: Extend `get_current_user` to return memory + onboarding flag
|
||||
|
||||
**File:** `api/app/api/middleware/auth.py`
|
||||
|
||||
In `get_current_user()`, after fetching the user row and resolving the tier:
|
||||
1. Read `user.onboarding_completed_at` — convert to epoch ms (int) or None.
|
||||
2. Use `MemoryMiddleware(db).enrich_context(user.id)` to load decrypted core memory. Extract the `core` dict → `{label: value}` pairs.
|
||||
3. Return `UserProfile(..., onboarding_completed_at=..., memory=...)`.
|
||||
|
||||
This requires `get_current_user` to also receive the `db: AsyncSession` dependency. Check if it already does — if not, add `Depends(get_session)`.
|
||||
|
||||
**Done signal:** `GET /api/v1/auth/me` returns `onboarding_completed_at` and `memory` fields.
|
||||
|
||||
---
|
||||
|
||||
### TASK 1.5: New route — `PUT /auth/me/memory`
|
||||
|
||||
**File:** `api/app/api/routes/auth.py`
|
||||
|
||||
Add a new route (do NOT modify `_UpdateProfileRequest`):
|
||||
|
||||
```python
|
||||
class _UpdateMemoryRequest(BaseModel):
|
||||
memory: dict[str, str] = Field(default_factory=dict)
|
||||
mark_onboarded: bool = False
|
||||
|
||||
@router.put("/me/memory", response_model=UserProfile)
|
||||
async def update_memory(
|
||||
body: _UpdateMemoryRequest,
|
||||
current_user: UserProfile = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserProfile:
|
||||
mw = MemoryMiddleware(db)
|
||||
for key, value in body.memory.items():
|
||||
await mw.update_core(current_user.id, key, value)
|
||||
if body.mark_onboarded:
|
||||
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||
user = result.scalar_one()
|
||||
user.onboarding_completed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
# Re-fetch profile and return
|
||||
return await get_current_user(...) # use same logic as GET /me
|
||||
```
|
||||
|
||||
Also add a companion route to reset onboarding (for "Re-run onboarding" in Settings):
|
||||
```python
|
||||
@router.post("/me/onboarding/reset")
|
||||
async def reset_onboarding(
|
||||
current_user: UserProfile = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||
user = result.scalar_one()
|
||||
user.onboarding_completed_at = None
|
||||
await db.commit()
|
||||
return {"status": "reset"}
|
||||
```
|
||||
|
||||
**Done signal:** Both routes exist and are syntactically correct.
|
||||
|
||||
---
|
||||
|
||||
### TASK 1.6: New route — `POST /auth/onboarding/normalize`
|
||||
|
||||
**File:** `api/app/api/routes/auth.py`
|
||||
|
||||
```python
|
||||
class _NormalizeRequest(BaseModel):
|
||||
inputs: dict[str, str] # {"job_role": "i build websites"}
|
||||
|
||||
class _NormalizeResponse(BaseModel):
|
||||
normalized: dict[str, str]
|
||||
|
||||
@router.post("/onboarding/normalize", response_model=_NormalizeResponse)
|
||||
async def normalize_onboarding(
|
||||
body: _NormalizeRequest,
|
||||
current_user: UserProfile = Depends(get_current_user),
|
||||
) -> _NormalizeResponse:
|
||||
"""One-shot LLM normalization for free-text onboarding answers."""
|
||||
if not body.inputs:
|
||||
return _NormalizeResponse(normalized={})
|
||||
try:
|
||||
llm = get_llm("gpt-4o-mini", temperature=0)
|
||||
prompt = (
|
||||
"You normalize user onboarding answers into clean, ≤3-word canonical labels.\n"
|
||||
"Return a JSON object with the same keys and normalized values.\n"
|
||||
"Examples: 'i build websites' → 'Web Developer', 'tech-ish stuff' → 'Technology'\n"
|
||||
f"Input: {json.dumps(body.inputs)}"
|
||||
)
|
||||
response = await llm.ainvoke(
|
||||
[{"role": "system", "content": "You normalize user inputs. Return JSON only."},
|
||||
{"role": "user", "content": prompt}],
|
||||
)
|
||||
normalized = json.loads(response.content)
|
||||
return _NormalizeResponse(normalized=normalized)
|
||||
except Exception:
|
||||
# LLM failure must never block onboarding — return inputs unchanged
|
||||
return _NormalizeResponse(normalized=body.inputs)
|
||||
```
|
||||
|
||||
Use `get_llm` from `app.core.llm`. Use `json` stdlib. The `try/except` is critical — flaky LLM must never block the wizard.
|
||||
|
||||
**Done signal:** Route exists, has the safety try/except, returns inputs on failure.
|
||||
|
||||
---
|
||||
|
||||
### TASK 1.7: Backend lint check
|
||||
|
||||
Run: `cd api && ruff check . --fix`
|
||||
|
||||
Fix any issues before proceeding to Phase 2.
|
||||
|
||||
**Done signal:** `ruff check .` exits 0.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Electron Main Process (adiuvAI/src/main/)
|
||||
|
||||
### TASK 2.1: Extend `UserProfileSchema`
|
||||
|
||||
**File:** `adiuvAI/src/shared/api-types.ts`
|
||||
|
||||
Find `UserProfileSchema` (Zod schema). Add:
|
||||
```ts
|
||||
onboardingCompletedAt: z.number().int().nullable().optional(),
|
||||
memory: z.record(z.string(), z.string()).default({}),
|
||||
```
|
||||
|
||||
**Done signal:** Schema has both fields.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.2: Add formatPrefs to electron-store
|
||||
|
||||
**File:** `adiuvAI/src/main/store.ts`
|
||||
|
||||
Extend the `AppSettings` interface:
|
||||
```ts
|
||||
formatPrefs: {
|
||||
timezone: string;
|
||||
dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
|
||||
timeFormat: '12h' | '24h';
|
||||
} | null;
|
||||
```
|
||||
|
||||
Default to `null` in the store defaults.
|
||||
|
||||
Add helpers:
|
||||
```ts
|
||||
export function getFormatPrefs(): FormatPrefs | null {
|
||||
return getStore().get('formatPrefs', null);
|
||||
}
|
||||
|
||||
export function setFormatPrefs(prefs: FormatPrefs): void {
|
||||
getStore().set('formatPrefs', prefs);
|
||||
}
|
||||
```
|
||||
|
||||
Export `FormatPrefs` as a type.
|
||||
|
||||
**Done signal:** `getFormatPrefs()` and `setFormatPrefs()` exported from store.ts.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.3: Create locale-defaults helper
|
||||
|
||||
**File:** `adiuvAI/src/main/auth/locale-defaults.ts` (new)
|
||||
|
||||
```ts
|
||||
import { app } from 'electron';
|
||||
|
||||
export interface FormatPrefs {
|
||||
timezone: string;
|
||||
dateFormat: string;
|
||||
timeFormat: '12h' | '24h';
|
||||
}
|
||||
|
||||
export function detectFormatPrefs(): FormatPrefs {
|
||||
const locale = app.getLocale();
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
|
||||
const timeFormat = hour12 ? '12h' : '24h';
|
||||
const dateFormat = inferDateFormat(locale);
|
||||
return { timezone, timeFormat, dateFormat };
|
||||
}
|
||||
|
||||
export function detectLanguage(): string {
|
||||
return app.getLocale(); // e.g. 'it-IT', 'en-US'
|
||||
}
|
||||
|
||||
function inferDateFormat(locale: string): string {
|
||||
// MDY locales
|
||||
const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
|
||||
if (mdyLocales.some(l => locale.startsWith(l))) return 'MM/dd/yyyy';
|
||||
// YMD locales (CJK, ISO-oriented)
|
||||
const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
|
||||
if (ymdPrefixes.some(p => locale.startsWith(p))) return 'yyyy-MM-dd';
|
||||
// Default: DMY (most of the world)
|
||||
return 'dd/MM/yyyy';
|
||||
}
|
||||
```
|
||||
|
||||
**Done signal:** File exists with `detectFormatPrefs()` and `detectLanguage()` exported.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.4: Create format-row helper
|
||||
|
||||
**File:** `adiuvAI/src/main/api/format-row.ts` (new)
|
||||
|
||||
```ts
|
||||
import type { FormatPrefs } from '../auth/locale-defaults';
|
||||
|
||||
const TIMESTAMP_COLUMNS = new Set([
|
||||
'createdAt', 'updatedAt', 'dueDate', 'date', 'endDate',
|
||||
'lastRunAt', 'startedAt', 'completedAt',
|
||||
]);
|
||||
|
||||
export function formatRows<T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
prefs: FormatPrefs,
|
||||
): T[] {
|
||||
return rows.map(row => formatRow(row, prefs));
|
||||
}
|
||||
|
||||
export function formatRow<T extends Record<string, unknown>>(
|
||||
row: T,
|
||||
prefs: FormatPrefs,
|
||||
): T {
|
||||
const result = { ...row };
|
||||
for (const col of TIMESTAMP_COLUMNS) {
|
||||
if (col in result && typeof result[col] === 'number') {
|
||||
(result as Record<string, unknown>)[col] = formatTimestamp(
|
||||
result[col] as number,
|
||||
prefs,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatTimestamp(epochMs: number, prefs: FormatPrefs): string {
|
||||
const date = new Date(epochMs);
|
||||
const hour12 = prefs.timeFormat === '12h';
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
timeZone: prefs.timezone,
|
||||
hour12,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
const timePart = date.toLocaleTimeString('en-US', opts);
|
||||
|
||||
const day = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, day: '2-digit' }).slice(-2);
|
||||
const month = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, month: '2-digit' }).slice(-2);
|
||||
const year = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, year: 'numeric' }).slice(0, 4);
|
||||
|
||||
let datePart: string;
|
||||
switch (prefs.dateFormat) {
|
||||
case 'MM/dd/yyyy': datePart = `${month}/${day}/${year}`; break;
|
||||
case 'yyyy-MM-dd': datePart = `${year}-${month}-${day}`; break;
|
||||
default: datePart = `${day}/${month}/${year}`; break;
|
||||
}
|
||||
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Done signal:** File exists with `formatRow` and `formatRows` exported.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.5: Wire format-row into drizzle-executor
|
||||
|
||||
**File:** `adiuvAI/src/main/api/drizzle-executor.ts`
|
||||
|
||||
Import `formatRows`, `formatRow` from `./format-row` and `getFormatPrefs` from `../store` and `detectFormatPrefs` from `../auth/locale-defaults`.
|
||||
|
||||
Find every place that returns `{ rows }` or `{ row }` results. Wrap them:
|
||||
- `rows` → `formatRows(rows, getFormatPrefs() ?? detectFormatPrefs())`
|
||||
- `row` → `formatRow(row, getFormatPrefs() ?? detectFormatPrefs())`
|
||||
|
||||
The `?? detectFormatPrefs()` fallback handles the edge case where executor runs before first auth.status seed.
|
||||
|
||||
**Important:** Only format on `handleList`, `handleGet`, `handleInsert`, `handleUpdate` return paths — NOT on delete. Do not mutate the original rows — `formatRow` returns a new object.
|
||||
|
||||
**Done signal:** All select/get/insert/update returns pass through formatRow(s).
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.6: Add auth methods to auth-manager
|
||||
|
||||
**File:** `adiuvAI/src/main/auth/auth-manager.ts`
|
||||
|
||||
Add two methods to the `AuthManager` class:
|
||||
|
||||
```ts
|
||||
async updateMemory(
|
||||
memory: Record<string, string>,
|
||||
markOnboarded = false,
|
||||
): Promise<UserProfile> {
|
||||
return this.put('/api/v1/auth/me/memory', {
|
||||
memory,
|
||||
mark_onboarded: markOnboarded,
|
||||
});
|
||||
}
|
||||
|
||||
async normalizeOnboarding(
|
||||
inputs: Record<string, string>,
|
||||
): Promise<Record<string, string>> {
|
||||
const res = await this.post('/api/v1/auth/onboarding/normalize', { inputs });
|
||||
return res.normalized;
|
||||
}
|
||||
|
||||
async resetOnboarding(): Promise<void> {
|
||||
await this.post('/api/v1/auth/me/onboarding/reset', {});
|
||||
}
|
||||
```
|
||||
|
||||
Use the existing `this.put()` / `this.post()` helpers (they handle auth headers and camelCase/snakeCase conversion).
|
||||
|
||||
**Done signal:** All three methods exist on AuthManager.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.7: Extend tRPC authRouter
|
||||
|
||||
**File:** `adiuvAI/src/main/router/index.ts`
|
||||
|
||||
In the `authRouter`, add these procedures:
|
||||
|
||||
```ts
|
||||
updateMemory: t.procedure
|
||||
.input(z.object({
|
||||
memory: z.record(z.string(), z.string()),
|
||||
markOnboarded: z.boolean().optional().default(false),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
return authManager.updateMemory(input.memory, input.markOnboarded);
|
||||
}),
|
||||
|
||||
normalizeOnboarding: t.procedure
|
||||
.input(z.object({
|
||||
inputs: z.record(z.string(), z.string()),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
return authManager.normalizeOnboarding(input.inputs);
|
||||
}),
|
||||
|
||||
resetOnboarding: t.procedure
|
||||
.mutation(async () => {
|
||||
return authManager.resetOnboarding();
|
||||
}),
|
||||
```
|
||||
|
||||
Also, in the existing `auth.status` procedure, add **silent auto-seeding** logic:
|
||||
- After fetching the profile, if `getFormatPrefs()` returns null → call `setFormatPrefs(detectFormatPrefs())`.
|
||||
- If `profile.memory.language` is missing/empty → call `authManager.updateMemory({ language: detectLanguage() })` silently (fire-and-forget, don't block the return).
|
||||
|
||||
**Done signal:** Three new procedures exist. Status procedure auto-seeds format prefs and language.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.8: Add settings routes for formatPrefs
|
||||
|
||||
**File:** `adiuvAI/src/main/router/index.ts`
|
||||
|
||||
In `settingsRouter` (or create one if it doesn't exist), add:
|
||||
|
||||
```ts
|
||||
getFormatPrefs: t.procedure.query(() => {
|
||||
return getFormatPrefs();
|
||||
}),
|
||||
|
||||
setFormatPrefs: t.procedure
|
||||
.input(z.object({
|
||||
timezone: z.string(),
|
||||
dateFormat: z.string(),
|
||||
timeFormat: z.enum(['12h', '24h']),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
setFormatPrefs(input);
|
||||
return input;
|
||||
}),
|
||||
```
|
||||
|
||||
**Done signal:** Both procedures exist.
|
||||
|
||||
---
|
||||
|
||||
### TASK 2.9: Frontend lint check
|
||||
|
||||
Run: `cd adiuvAI && npx eslint . --fix`
|
||||
|
||||
Fix any TypeScript/ESLint issues before proceeding to Phase 3.
|
||||
|
||||
**Done signal:** ESLint exits 0.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — Electron Renderer (adiuvAI/src/renderer/)
|
||||
|
||||
### TASK 3.1: Create onboarding chip options
|
||||
|
||||
**File:** `adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts` (new)
|
||||
|
||||
```ts
|
||||
export const JOB_ROLES = ['Developer', 'Designer', 'Consultant', 'Founder', 'Project Manager'] as const;
|
||||
export const INDUSTRIES = ['Tech', 'Design', 'Consulting', 'Legal', 'Marketing', 'Education'] as const;
|
||||
export const USE_CASES = ['Solo freelancer', 'Client manager', 'Team lead', 'Personal productivity'] as const;
|
||||
export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;
|
||||
```
|
||||
|
||||
**Done signal:** File exists with all four arrays exported.
|
||||
|
||||
---
|
||||
|
||||
### TASK 3.2: Create OnboardingFlow component
|
||||
|
||||
**File:** `adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx` (new)
|
||||
|
||||
This is the most complex file. Key requirements:
|
||||
|
||||
**State machine:**
|
||||
```ts
|
||||
type Step = 'welcome' | 'jobRole' | 'industry' | 'useCase' | 'tone' | 'language' | 'reviewing' | 'done';
|
||||
```
|
||||
|
||||
**Props:**
|
||||
```ts
|
||||
interface OnboardingFlowProps {
|
||||
profile: UserProfile;
|
||||
}
|
||||
```
|
||||
|
||||
**Visual style — must match AIChatPanel:**
|
||||
- Chat bubble layout: AI messages in `rounded-2xl` bubbles with a Sparkles icon (from lucide-react).
|
||||
- Use glassmorphism: `bg-white/5 backdrop-blur-md border border-white/10`.
|
||||
- Spring transitions (framer-motion) for each step entering.
|
||||
- Use shadcn components: `Button`, `Input`, `Card`.
|
||||
|
||||
**Each wizard step shows:**
|
||||
1. An "AI bubble" with the question text.
|
||||
2. 3–6 preset chip buttons (from onboardingOptions.ts).
|
||||
3. An optional "Type your own" text input (for `job_role` and `industry` only).
|
||||
4. A "Skip" link at the bottom.
|
||||
5. Previous answers appear above as "user bubbles" (right-aligned).
|
||||
|
||||
**Step details:**
|
||||
| Step | Question | Chips | Free text? |
|
||||
|------|----------|-------|-----------|
|
||||
| welcome | "Hi {name}! I'm your AI assistant. Let me learn a few things about you so I can help better." | Just a "Let's go" button | No |
|
||||
| jobRole | "What's your role?" | JOB_ROLES | Yes |
|
||||
| industry | "What industry do you work in?" | INDUSTRIES | Yes |
|
||||
| useCase | "How will you mainly use adiuvAI?" | USE_CASES | No |
|
||||
| tone | "How should I talk to you?" | TONES | No |
|
||||
| language | "I'll respond in {detected}. Want to change it?" | Show detected language pre-selected; allow typing a different one | Yes |
|
||||
| reviewing | Review screen (see below) | — | — |
|
||||
| done | Redirect (never renders) | — | — |
|
||||
|
||||
**Reviewing step logic:**
|
||||
1. Partition answers: chip selections (already clean) vs free-text answers (need normalization).
|
||||
2. If free-text map is non-empty → call `trpc.auth.normalizeOnboarding.useMutation`. Show "Tidying up…" spinner on those fields only.
|
||||
3. Show a Card titled "Here's what I'll save" with all 5 fields as rows.
|
||||
4. Each row has an Edit pencil icon → converts to inline input → Enter saves → back to read-only.
|
||||
5. If LLM changed a value, show grey hint: `auto-tidied from "original text"`.
|
||||
6. Primary button: "Looks good — save" → calls `trpc.auth.updateMemory.useMutation({ memory: finalMap, markOnboarded: true })` → `utils.auth.status.invalidate()`.
|
||||
7. Secondary link: "Back to wizard" → resets to `jobRole` step with values pre-filled.
|
||||
8. **Failure modes:**
|
||||
- Normalization fails → show raw values + banner "Couldn't auto-tidy — review and save". Save still works.
|
||||
- Save fails → toast error, stay on review screen.
|
||||
|
||||
**Skip behavior:** Clicking Skip on any step → calls `updateMemory({}, markOnboarded: true)` with empty map → wizard closes. Language was already auto-seeded by `auth.status`.
|
||||
|
||||
**Done signal:** Component file exists, renders a multi-step wizard, handles reviewing + save.
|
||||
|
||||
---
|
||||
|
||||
### TASK 3.3: Gate OnboardingFlow in AppShell
|
||||
|
||||
**File:** `adiuvAI/src/renderer/components/layout/AppShell.tsx`
|
||||
|
||||
After the `authenticated === false` → `LoginForm` branch, add:
|
||||
```tsx
|
||||
if (
|
||||
authStatusQuery.data?.profile &&
|
||||
authStatusQuery.data.profile.onboardingCompletedAt == null
|
||||
) {
|
||||
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
|
||||
}
|
||||
```
|
||||
|
||||
Import `OnboardingFlow` from `../onboarding/OnboardingFlow`.
|
||||
|
||||
**Done signal:** AppShell conditionally renders OnboardingFlow when onboardingCompletedAt is null.
|
||||
|
||||
---
|
||||
|
||||
### TASK 3.4: Add 'profile' to settings sections
|
||||
|
||||
**File:** `adiuvAI/src/renderer/components/settings/types.ts`
|
||||
|
||||
Add `'profile'` to the `SectionId` type and `{ id: 'profile', label: 'Profile' }` to `SECTIONS` array — insert it before `'account'`.
|
||||
|
||||
**Done signal:** SECTIONS includes 'profile' as the first or second entry.
|
||||
|
||||
---
|
||||
|
||||
### TASK 3.5: Create ProfileSection component
|
||||
|
||||
**File:** `adiuvAI/src/renderer/components/settings/ProfileSection.tsx` (new)
|
||||
|
||||
Plain form (no chat aesthetic — this is Settings, not the wizard). Two cards:
|
||||
|
||||
**Card 1 — "About you"** (writes to MemoryCore via `auth.updateMemory`):
|
||||
- Fields: job_role, industry, primary_use_case (select from USE_CASES), tone_preference (select from TONES), language (text input).
|
||||
- Pre-populate from `authStatusQuery.data.profile.memory`.
|
||||
- Save button → `trpc.auth.updateMemory.useMutation`.
|
||||
- "Re-run onboarding" button → `trpc.auth.resetOnboarding.useMutation` → `utils.auth.status.invalidate()` (triggers wizard via AppShell gate).
|
||||
|
||||
**Card 2 — "Display preferences"** (writes to electron-store via `trpc.settings.setFormatPrefs`):
|
||||
- Timezone: searchable select, populated from `Intl.supportedValuesOf('timeZone')`.
|
||||
- Date format: select with options: `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`.
|
||||
- Time format: radio group — 12h / 24h.
|
||||
- Pre-populate from `trpc.settings.getFormatPrefs` query.
|
||||
- Save button → `trpc.settings.setFormatPrefs.useMutation`.
|
||||
|
||||
Use shadcn `Card`, `Input`, `Select`, `Button`, `Label`, `RadioGroup` components.
|
||||
|
||||
**Done signal:** Component exists with both cards, save functionality wired.
|
||||
|
||||
---
|
||||
|
||||
### TASK 3.6: Wire ProfileSection into settings route
|
||||
|
||||
**File:** `adiuvAI/src/renderer/routes/settings.tsx`
|
||||
|
||||
Add the import and the conditional render:
|
||||
```tsx
|
||||
{section === 'profile' && <ProfileSection />}
|
||||
```
|
||||
|
||||
**Done signal:** ProfileSection renders when section=profile.
|
||||
|
||||
---
|
||||
|
||||
### TASK 3.7: Final lint check
|
||||
|
||||
Run both:
|
||||
```bash
|
||||
cd api && ruff check . --fix
|
||||
cd adiuvAI && npx eslint . --fix
|
||||
```
|
||||
|
||||
**Done signal:** Both exit 0.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4 — Completion
|
||||
|
||||
### TASK 4.1: Verify all files exist
|
||||
|
||||
Check that these files exist:
|
||||
- [ ] `api/alembic/versions/*_add_onboarding_completed_at.py`
|
||||
- [ ] `adiuvAI/src/main/auth/locale-defaults.ts`
|
||||
- [ ] `adiuvAI/src/main/api/format-row.ts`
|
||||
- [ ] `adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts`
|
||||
- [ ] `adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx`
|
||||
- [ ] `adiuvAI/src/renderer/components/settings/ProfileSection.tsx`
|
||||
|
||||
Check that these files were modified:
|
||||
- [ ] `api/app/models.py` (has `onboarding_completed_at`)
|
||||
- [ ] `api/app/schemas.py` (UserProfile has `memory` + `onboarding_completed_at`)
|
||||
- [ ] `api/app/api/middleware/auth.py` (get_current_user returns memory)
|
||||
- [ ] `api/app/api/routes/auth.py` (3 new routes)
|
||||
- [ ] `adiuvAI/src/shared/api-types.ts` (UserProfileSchema extended)
|
||||
- [ ] `adiuvAI/src/main/store.ts` (formatPrefs + helpers)
|
||||
- [ ] `adiuvAI/src/main/auth/auth-manager.ts` (3 new methods)
|
||||
- [ ] `adiuvAI/src/main/router/index.ts` (5 new procedures + auto-seed in status)
|
||||
- [ ] `adiuvAI/src/main/api/drizzle-executor.ts` (formatRow wiring)
|
||||
- [ ] `adiuvAI/src/renderer/components/layout/AppShell.tsx` (onboarding gate)
|
||||
- [ ] `adiuvAI/src/renderer/components/settings/types.ts` (profile section)
|
||||
- [ ] `adiuvAI/src/renderer/routes/settings.tsx` (ProfileSection render)
|
||||
|
||||
### TASK 4.2: Output completion promise
|
||||
|
||||
If everything above is done and lint passes:
|
||||
|
||||
```
|
||||
<promise>ONBOARDING COMPLETE</promise>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REFERENCE — Existing patterns to reuse
|
||||
|
||||
**DO NOT reinvent these. Copy their patterns:**
|
||||
|
||||
| Pattern | Source file | Reuse for |
|
||||
|---------|-----------|-----------|
|
||||
| Chat bubble + Sparkles + glass | `src/renderer/components/ai/AIChatPanel.tsx` | OnboardingFlow bubbles |
|
||||
| Stepper state machine | `InlineAgentCreationStepper` in renderer | Wizard step transitions |
|
||||
| MemoryMiddleware.update_core | `api/app/core/memory_middleware.py:137-173` | PUT /me/memory route |
|
||||
| get_llm() | `api/app/core/llm.py` | Normalize route |
|
||||
| electron-store helpers | `src/main/store.ts` (getDeviceId pattern) | getFormatPrefs/setFormatPrefs |
|
||||
| tRPC procedure pattern | `src/main/router/index.ts` (auth.status) | New procedures |
|
||||
| shadcn form components | Existing settings sections | ProfileSection |
|
||||
| toCamelCase / toSnakeCase | `auth-manager.ts` proxy helpers | Automatic key conversion |
|
||||
|
||||
---
|
||||
|
||||
## DO NOT
|
||||
|
||||
- Add features not described here (no avatar upload, no i18n framework, no animation library beyond framer-motion if already installed).
|
||||
- Modify the orchestrator or system prompts — MemoryCore injection is already handled.
|
||||
- Add foreign key constraints to the migration.
|
||||
- Store formatting prefs in MemoryCore.
|
||||
- Let the LLM normalization route throw on failure — it MUST return inputs unchanged.
|
||||
- Skip the reviewing step in the wizard.
|
||||
- Run both lint checks and fix issues before claiming completion.
|
||||
Reference in New Issue
Block a user