From cecf85de02a0279ea180166d8b6b837de964472a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 13 Jun 2026 20:10:38 -0700 Subject: [PATCH] feat(wizard): AI interview store, WizardAIView chat UI, byokUnlocked in appConfig --- web/src/router/index.ts | 2 + web/src/stores/appConfig.ts | 6 +- .../wizard/__tests__/aiInterview.test.ts | 179 ++++++ web/src/stores/wizard/aiInterview.ts | 90 +++ web/src/views/wizard/WizardAIView.vue | 598 ++++++++++++++++++ 5 files changed, 873 insertions(+), 2 deletions(-) create mode 100644 web/src/stores/wizard/__tests__/aiInterview.test.ts create mode 100644 web/src/stores/wizard/aiInterview.ts create mode 100644 web/src/views/wizard/WizardAIView.vue diff --git a/web/src/router/index.ts b/web/src/router/index.ts index dab3efc..89576ca 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -37,6 +37,8 @@ export const router = createRouter({ { path: 'developer', component: () => import('../views/settings/DeveloperView.vue') }, ], }, + // AI profile wizard — standalone full-page chat + { path: '/wizard/ai-profile', component: () => import('../views/wizard/WizardAIView.vue') }, // Onboarding wizard — full-page layout, no AppNav { path: '/setup', diff --git a/web/src/stores/appConfig.ts b/web/src/stores/appConfig.ts index 474b448..0ae85bb 100644 --- a/web/src/stores/appConfig.ts +++ b/web/src/stores/appConfig.ts @@ -13,6 +13,7 @@ export const useAppConfigStore = defineStore('appConfig', () => { const inferenceProfile = ref('cpu') const isDemo = ref(false) const wizardComplete = ref(true) // optimistic default — guard corrects on load + const byokUnlocked = ref(false) const loaded = ref(false) const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '') @@ -20,7 +21,7 @@ export const useAppConfigStore = defineStore('appConfig', () => { const { data } = await useApiFetch<{ isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier contractedClient: boolean; inferenceProfile: InferenceProfile - wizardComplete: boolean + wizardComplete: boolean; byokUnlocked: boolean }>('/api/config/app') if (!data) return isCloud.value = data.isCloud @@ -30,6 +31,7 @@ export const useAppConfigStore = defineStore('appConfig', () => { contractedClient.value = data.contractedClient inferenceProfile.value = data.inferenceProfile wizardComplete.value = data.wizardComplete ?? true + byokUnlocked.value = data.byokUnlocked ?? false loaded.value = true } @@ -43,5 +45,5 @@ export const useAppConfigStore = defineStore('appConfig', () => { } } - return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride } + return { isCloud, isDemo, isDevMode, wizardComplete, byokUnlocked, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride } }) diff --git a/web/src/stores/wizard/__tests__/aiInterview.test.ts b/web/src/stores/wizard/__tests__/aiInterview.test.ts new file mode 100644 index 0000000..ae582f4 --- /dev/null +++ b/web/src/stores/wizard/__tests__/aiInterview.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAiInterviewStore } from '../aiInterview' + +vi.mock('../../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +const LS_KEY = 'peregrine:wizard-draft' + +describe('useAiInterviewStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + }) + + // ── restore() ────────────────────────────────────────────────────────────── + + it('restore() loads messages, fields, and complete from localStorage', () => { + const draft = { + messages: [{ role: 'assistant', content: 'Hello!' }], + fields: { name: 'Alice' }, + complete: true, + } + localStorage.setItem(LS_KEY, JSON.stringify(draft)) + + const store = useAiInterviewStore() + store.restore() + + expect(store.messages).toEqual(draft.messages) + expect(store.fields).toEqual(draft.fields) + expect(store.complete).toBe(true) + }) + + it('restore() is a no-op when localStorage is empty', () => { + const store = useAiInterviewStore() + store.restore() + expect(store.messages).toEqual([]) + expect(store.fields).toEqual({}) + expect(store.complete).toBe(false) + }) + + it('restore() ignores corrupted localStorage data without throwing', () => { + localStorage.setItem(LS_KEY, '{not valid json}}}') + const store = useAiInterviewStore() + expect(() => store.restore()).not.toThrow() + expect(store.messages).toEqual([]) + }) + + // ── send() ───────────────────────────────────────────────────────────────── + + it('send() appends user message and assistant reply on success', async () => { + mockFetch.mockResolvedValue({ + data: { reply: 'Nice to meet you!', extracted_fields: {}, complete: false }, + error: null, + }) + + const store = useAiInterviewStore() + await store.send('Hello') + + expect(store.messages).toEqual([ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Nice to meet you!' }, + ]) + expect(store.complete).toBe(false) + expect(store.error).toBeNull() + }) + + it('send() does not add a user bubble for empty string (intro trigger)', async () => { + mockFetch.mockResolvedValue({ + data: { reply: 'Welcome!', extracted_fields: {}, complete: false }, + error: null, + }) + + const store = useAiInterviewStore() + await store.send('') + + expect(store.messages).toEqual([ + { role: 'assistant', content: 'Welcome!' }, + ]) + }) + + it('send() merges extracted_fields into existing fields', async () => { + mockFetch.mockResolvedValueOnce({ + data: { reply: 'Got it.', extracted_fields: { name: 'Alice' }, complete: false }, + error: null, + }) + mockFetch.mockResolvedValueOnce({ + data: { reply: 'Thanks.', extracted_fields: { title: 'Engineer' }, complete: false }, + error: null, + }) + + const store = useAiInterviewStore() + await store.send('My name is Alice') + await store.send('I am an engineer') + + expect(store.fields).toEqual({ name: 'Alice', title: 'Engineer' }) + }) + + it('send() sets complete flag when backend signals done', async () => { + mockFetch.mockResolvedValue({ + data: { reply: 'All done!', extracted_fields: { name: 'Alice' }, complete: true }, + error: null, + }) + + const store = useAiInterviewStore() + await store.send('done') + + expect(store.complete).toBe(true) + }) + + it('send() sets error and rolls back loading on API failure', async () => { + mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } }) + + const store = useAiInterviewStore() + await store.send('Hello') + + expect(store.error).toBe('Could not reach the assistant. Please try again.') + expect(store.loading).toBe(false) + }) + + it('send() persists draft to localStorage on success', async () => { + mockFetch.mockResolvedValue({ + data: { reply: 'Hi!', extracted_fields: { name: 'Bob' }, complete: false }, + error: null, + }) + + const store = useAiInterviewStore() + await store.send('Hello') + + const stored = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}') + expect(stored.fields).toEqual({ name: 'Bob' }) + }) + + // ── finalize() ───────────────────────────────────────────────────────────── + + it('finalize() calls the finalize API and clears localStorage on success', async () => { + localStorage.setItem(LS_KEY, JSON.stringify({ messages: [], fields: { name: 'Alice' }, complete: true })) + mockFetch.mockResolvedValue({ data: {}, error: null }) + + const store = useAiInterviewStore() + const ok = await store.finalize() + + expect(ok).toBe(true) + expect(localStorage.getItem(LS_KEY)).toBeNull() + expect(store.saving).toBe(false) + }) + + it('finalize() returns false and sets error on API failure', async () => { + mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } }) + + const store = useAiInterviewStore() + const ok = await store.finalize() + + expect(ok).toBe(false) + expect(store.error).toBe('Failed to save profile. Please try again.') + }) + + // ── startOver() ──────────────────────────────────────────────────────────── + + it('startOver() resets all state and clears localStorage', async () => { + mockFetch.mockResolvedValue({ + data: { reply: 'Hi!', extracted_fields: { name: 'Alice' }, complete: true }, + error: null, + }) + + const store = useAiInterviewStore() + await store.send('test') // populates state and localStorage + + store.startOver() + + expect(store.messages).toEqual([]) + expect(store.fields).toEqual({}) + expect(store.complete).toBe(false) + expect(store.error).toBeNull() + expect(localStorage.getItem(LS_KEY)).toBeNull() + }) +}) diff --git a/web/src/stores/wizard/aiInterview.ts b/web/src/stores/wizard/aiInterview.ts new file mode 100644 index 0000000..a66fa59 --- /dev/null +++ b/web/src/stores/wizard/aiInterview.ts @@ -0,0 +1,90 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApiFetch } from '../../composables/useApi' + +const LS_KEY = 'peregrine:wizard-draft' + +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +export const useAiInterviewStore = defineStore('aiInterview', () => { + const messages = ref([]) + const fields = ref>({}) + const complete = ref(false) + const loading = ref(false) + const saving = ref(false) + const error = ref(null) + + function _persist() { + localStorage.setItem(LS_KEY, JSON.stringify({ + messages: messages.value, + fields: fields.value, + complete: complete.value, + })) + } + + function restore() { + try { + const raw = localStorage.getItem(LS_KEY) + if (!raw) return + const d = JSON.parse(raw) as { messages?: ChatMessage[]; fields?: Record; complete?: boolean } + messages.value = d.messages ?? [] + fields.value = d.fields ?? {} + complete.value = d.complete ?? false + } catch { /* ignore corrupted draft */ } + } + + async function send(userText: string) { + if (loading.value) return + if (userText !== '') { + messages.value = [...messages.value, { role: 'user', content: userText }] + } + loading.value = true + error.value = null + const { data, error: err } = await useApiFetch<{ + reply: string + extracted_fields: Record + complete: boolean + }>('/api/wizard/ai/interview', { + method: 'POST', + body: JSON.stringify({ history: messages.value, profile_so_far: fields.value }), + }) + loading.value = false + if (err || !data) { + error.value = 'Could not reach the assistant. Please try again.' + return + } + messages.value = [...messages.value, { role: 'assistant', content: data.reply }] + fields.value = { ...fields.value, ...data.extracted_fields } + complete.value = data.complete + _persist() + } + + async function finalize(): Promise { + saving.value = true + error.value = null + const { error: err } = await useApiFetch('/api/wizard/ai/finalize', { + method: 'POST', + body: JSON.stringify({ profile: fields.value }), + }) + saving.value = false + if (err) { + error.value = 'Failed to save profile. Please try again.' + return false + } + localStorage.removeItem(LS_KEY) + return true + } + + function startOver() { + messages.value = [] + fields.value = {} + complete.value = false + error.value = null + localStorage.removeItem(LS_KEY) + } + + return { messages, fields, complete, loading, saving, error, restore, send, finalize, startOver } +}) diff --git a/web/src/views/wizard/WizardAIView.vue b/web/src/views/wizard/WizardAIView.vue new file mode 100644 index 0000000..9b00916 --- /dev/null +++ b/web/src/views/wizard/WizardAIView.vue @@ -0,0 +1,598 @@ + + + + +