peregrine/web/src/stores/wizard.ts
pyr0ball e0828677a4 feat(wizard): Vue onboarding wizard — all 7 steps + router wiring
- WizardLayout.vue: full-page card, progress bar, crash-recovery via
  loadStatus(isCloud); auto-skips steps 1/2/5 in cloud mode
- wizard.css: shared step styles (headings, banners, radio cards,
  chip lists, form fields, expandables, nav buttons)
- Step 1 — Hardware: GPU detection, profile select, mismatch warning
- Step 2 — Tier: Free/Paid/Premium radio cards
- Step 3 — Resume: upload (PDF/DOCX/ODT) or manual experience builder;
  pre-fills identity fields from parsed resume data
- Step 4 — Identity: name/email/phone/LinkedIn/career summary;
  full validation before saveStep
- Step 5 — Inference: remote API keys vs local Ollama; advanced
  service-host/port expandable; soft-fail connection test
- Step 6 — Search: chip-style job title + location input with
  comma/Enter commit; remote-only checkbox
- Step 7 — Integrations: optional tile-grid (Notion/Calendar/Slack/
  Discord/Drive); paid-tier badge for gated items; calls
  wizard.complete() on Finish
- wizard.ts Pinia store: loadStatus, detectHardware, saveStep,
  testInference, complete; cloud auto-skip logic
- wizardGuard.ts: gates all routes behind /setup until
  wizard_complete; redirects complete users away from /setup
- router/index.ts: /setup nested route tree; unified beforeEach guard
  (wizard gate → setup branch → settings tier gate)
- App.vue: hide AppNav + strip sidebar margin on /setup routes
2026-04-02 18:11:57 -07:00

279 lines
8.2 KiB
TypeScript

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export type WizardTier = 'free' | 'paid' | 'premium'
export interface WorkExperience {
title: string
company: string
start_date: string
end_date: string
bullets: string[]
}
export interface WizardHardwareData {
gpus: string[]
suggestedProfile: WizardProfile
selectedProfile: WizardProfile
}
export interface WizardSearchData {
titles: string[]
locations: string[]
}
export interface WizardIdentityData {
name: string
email: string
phone: string
linkedin: string
careerSummary: string
}
export interface WizardInferenceData {
anthropicKey: string
openaiUrl: string
openaiKey: string
ollamaHost: string
ollamaPort: number
services: Record<string, string | number>
confirmed: boolean
testMessage: string
}
// Total mandatory steps (integrations step 7 is optional/skip-able)
export const WIZARD_STEPS = 6
export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Identity', 'Inference', 'Search', 'Integrations']
export const STEP_ROUTES = [
'/setup/hardware',
'/setup/tier',
'/setup/resume',
'/setup/identity',
'/setup/inference',
'/setup/search',
'/setup/integrations',
]
export const useWizardStore = defineStore('wizard', () => {
// ── Navigation state ──────────────────────────────────────────────────────
const currentStep = ref(1) // 1-based; 7 = integrations (optional)
const loading = ref(false)
const saving = ref(false)
const errors = ref<string[]>([])
// ── Step data ─────────────────────────────────────────────────────────────
const hardware = ref<WizardHardwareData>({
gpus: [],
suggestedProfile: 'remote',
selectedProfile: 'remote',
})
const tier = ref<WizardTier>('free')
const resume = ref<{ experience: WorkExperience[]; parsedData: Record<string, unknown> | null }>({
experience: [],
parsedData: null,
})
const identity = ref<WizardIdentityData>({
name: '',
email: '',
phone: '',
linkedin: '',
careerSummary: '',
})
const inference = ref<WizardInferenceData>({
anthropicKey: '',
openaiUrl: '',
openaiKey: '',
ollamaHost: 'localhost',
ollamaPort: 11434,
services: {},
confirmed: false,
testMessage: '',
})
const search = ref<WizardSearchData>({
titles: [],
locations: [],
})
// ── Computed ──────────────────────────────────────────────────────────────
const progressFraction = computed(() =>
Math.min((currentStep.value - 1) / WIZARD_STEPS, 1),
)
const stepLabel = computed(() =>
currentStep.value <= WIZARD_STEPS
? `Step ${currentStep.value} of ${WIZARD_STEPS}`
: 'Almost done!',
)
const routeForStep = (step: number) => STEP_ROUTES[step - 1] ?? '/setup/hardware'
// ── Actions ───────────────────────────────────────────────────────────────
/** Load wizard status from server and hydrate store. Returns the route to navigate to. */
async function loadStatus(isCloud: boolean): Promise<string> {
loading.value = true
errors.value = []
try {
const { data } = await useApiFetch<{
wizard_complete: boolean
wizard_step: number
saved_data: {
inference_profile?: string
tier?: string
name?: string
email?: string
phone?: string
linkedin?: string
career_summary?: string
services?: Record<string, string | number>
}
}>('/api/wizard/status')
if (!data) return '/setup/hardware'
const saved = data.saved_data
if (saved.inference_profile)
hardware.value.selectedProfile = saved.inference_profile as WizardProfile
if (saved.tier)
tier.value = saved.tier as WizardTier
if (saved.name) identity.value.name = saved.name
if (saved.email) identity.value.email = saved.email
if (saved.phone) identity.value.phone = saved.phone
if (saved.linkedin) identity.value.linkedin = saved.linkedin
if (saved.career_summary) identity.value.careerSummary = saved.career_summary
if (saved.services) inference.value.services = saved.services
// Cloud: auto-skip steps 1 (hardware), 2 (tier), 5 (inference)
if (isCloud) {
const cloudStep = data.wizard_step
if (cloudStep < 1) {
await saveStep(1, { inference_profile: 'single-gpu' })
await saveStep(2, { tier: tier.value })
currentStep.value = 3
return '/setup/resume'
}
}
// Resume at next step after last completed
const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 7))
currentStep.value = resumeAt
return routeForStep(resumeAt)
} finally {
loading.value = false
}
}
/** Detect GPUs and populate hardware step. */
async function detectHardware(): Promise<void> {
loading.value = true
try {
const { data } = await useApiFetch<{
gpus: string[]
suggested_profile: string
profiles: string[]
}>('/api/wizard/hardware')
if (!data) return
hardware.value.gpus = data.gpus
hardware.value.suggestedProfile = data.suggested_profile as WizardProfile
// Only set selectedProfile if not already chosen by user
if (!hardware.value.selectedProfile || hardware.value.selectedProfile === 'remote') {
hardware.value.selectedProfile = data.suggested_profile as WizardProfile
}
} finally {
loading.value = false
}
}
/** Persist a step's data to the server. */
async function saveStep(step: number, data: Record<string, unknown>): Promise<boolean> {
saving.value = true
errors.value = []
try {
const { data: result, error } = await useApiFetch('/api/wizard/step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step, data }),
})
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
currentStep.value = step
return true
} finally {
saving.value = false
}
}
/** Test LLM / Ollama connectivity. */
async function testInference(): Promise<{ ok: boolean; message: string }> {
const payload = {
profile: hardware.value.selectedProfile,
anthropic_key: inference.value.anthropicKey,
openai_url: inference.value.openaiUrl,
openai_key: inference.value.openaiKey,
ollama_host: inference.value.ollamaHost,
ollama_port: inference.value.ollamaPort,
}
const { data } = await useApiFetch<{ ok: boolean; message: string }>(
'/api/wizard/inference/test',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const result = data ?? { ok: false, message: 'No response from server.' }
inference.value.testMessage = result.message
inference.value.confirmed = true // always soft-confirm so user isn't blocked
return result
}
/** Finalise the wizard. */
async function complete(): Promise<boolean> {
saving.value = true
try {
const { error } = await useApiFetch('/api/wizard/complete', { method: 'POST' })
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
return true
} finally {
saving.value = false
}
}
return {
// state
currentStep,
loading,
saving,
errors,
hardware,
tier,
resume,
identity,
inference,
search,
// computed
progressFraction,
stepLabel,
// actions
loadStatus,
detectHardware,
saveStep,
testInference,
complete,
routeForStep,
}
})