From 76412b08b4ea25bbd17cc4682316fb95ba094d0f Mon Sep 17 00:00:00 2001 From: Aravind Date: Mon, 10 Aug 2026 13:19:10 +0530 Subject: [PATCH] fix Owliver ai chat --- src/components/ai-assistant/KrowAssistant.jsx | 9 +- .../ai-assistant/capabilities/talent.js | 204 ------------------ src/components/ai-assistant/contexts.js | 12 +- src/components/ai-assistant/dynamic.js | 21 -- src/components/ai-assistant/placement.js | 8 +- src/components/ai-assistant/useAssistant.js | 35 ++- src/layouts/Layout.jsx | 36 ++-- src/pages/EmployeeDashboard.jsx | 4 - 8 files changed, 57 insertions(+), 272 deletions(-) delete mode 100644 src/components/ai-assistant/capabilities/talent.js diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index ccb7c28..a9bcb8c 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -5,7 +5,7 @@ import { Surface } from '@/components/ds/Surface'; import { IconButton } from '@/components/ds/IconButton'; import { Alert } from '@/components/ds/Alert'; import { KROW_LOGO_URL } from '@/assets/brand'; -import { useAssistantFacts, useConversation } from './useAssistant'; +import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant'; import { buildIntro, buildPrompts } from './dynamic'; import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage'; import { PromptInput } from './PromptInput'; @@ -43,7 +43,8 @@ export default function KrowAssistant({ onExpand, onRestore, }) { - const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile }); + const facts = useAssistantFacts(); + const userName = useCurrentUserName(); const { messages, pending, error, busy, send, stop, reset } = useConversation({ contextId: context.id, facts, @@ -56,8 +57,8 @@ export default function KrowAssistant({ /* Greeting and suggestions come from live data, so they recompute only when the data or the page actually changes. */ const intro = React.useMemo( - () => buildIntro(context.id, facts, facts.profile?.full_name), - [context.id, facts] + () => buildIntro(context.id, facts, userName), + [context.id, facts, userName] ); const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]); diff --git a/src/components/ai-assistant/capabilities/talent.js b/src/components/ai-assistant/capabilities/talent.js deleted file mode 100644 index 7f47032..0000000 --- a/src/components/ai-assistant/capabilities/talent.js +++ /dev/null @@ -1,204 +0,0 @@ -import { BookOpen, Compass, FileText } from 'lucide-react'; -import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks'; -import { plural } from '../insights'; -import { getScoreBand, toFICO } from '@/lib/talentHome'; - -/** - * Talent capabilities — My Portal. - * - * The register shifts here: an employer wants a read on a pipeline, a worker - * wants to know what to do next to earn more. Answers name the specific course - * or step, because "improve your skills" helps nobody. - */ - -const scoreOf = (p) => Number(p?.krow_score) || 0; - -const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give you specifics instead of generalities.'; - -const resumeReview = (f) => { - const p = f.profile; - if (!p) return answer(NO_PROFILE); - - const fields = [ - { label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' }, - { label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' }, - { label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' }, - { label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' }, - { label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' }, - { label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' }, - { label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' }, - ]; - - const complete = fields.filter((x) => x.ok).length; - - return doc( - heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`), - status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))), - (p.certifications || []).length && [ - heading('Credentials on file'), - badges(p.certifications.map((label) => ({ label, tone: 'success' }))), - ], - text( - complete === fields.length - ? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.' - : 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.' - ), - note( - (p.capabilities || []).length - ? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.' - : 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.' - ) - ); -}; - -const careerGuidance = (f) => { - const p = f.profile; - if (!p) return answer(NO_PROFILE); - - const score = scoreOf(p); - const band = getScoreBand(score); - const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60; - - return doc( - heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'), - kpis([ - { label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' }, - { label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined }, - { label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined }, - { label: 'Verified skills', value: (p.capabilities || []).length }, - ]), - heading("What's driving it"), - meters([ - { label: 'Reliability', value: p.reliability_score || 0, weight: 25 }, - { label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 }, - { label: 'Communication', value: p.performance_score || 0, weight: 15 }, - { label: 'Leadership', value: p.leadership_potential || 0, weight: 15 }, - { label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 }, - { label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 }, - ]), - insights([ - target - ? { - tone: 'info', - title: `To reach ${target}`, - body: 'Verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.', - } - : { - tone: 'success', - title: 'You are in the top band', - body: 'From here it is about holding it — the score decays if shifts stop.', - }, - (p.shifts_completed || 0) === 0 && { - tone: 'warning', - title: 'Work one shift', - body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.', - }, - p.desired_position - ? { - tone: score >= 75 ? 'success' : 'info', - title: `Aiming for ${p.desired_position}`, - body: score >= 75 - ? 'You are already scoring in the range employers shortlist for that.' - : 'The gap to that role is verification, not ambition.', - } - : { - tone: 'info', - title: 'No target role set', - body: 'Setting one lets me tell you which courses matter and which are noise.', - }, - ]) - ); -}; - -const learningRecommendations = (f) => { - const p = f.profile; - if (!p) return answer(NO_PROFILE); - - const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id)); - const done = (p.completed_courses || []).length; - const shifts = p.shifts_completed || 0; - const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id)); - - if (!available.length) { - return doc( - heading('You have completed everything available'), - kpis([ - { label: 'Courses', value: done, tone: 'success' }, - { label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' }, - { label: 'XP', value: p.xp || 0 }, - { label: 'Verified', value: (p.capabilities || []).length }, - ]), - text('The next lever is verified shifts — that is what employers weight most.') - ); - } - - const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0)); - - return doc( - heading('What to learn next', 'Ranked by what moves your score most'), - table( - [ - { key: 'title', label: 'Course' }, - { key: 'minutes', label: 'Time', align: 'right' }, - { key: 'xp', label: 'XP', align: 'right' }, - ], - ranked.slice(0, 4).map((c) => { - const gate = c.unlock_requirements?.min_shifts || 0; - const locked = gate > shifts; - return { - title: c.title, - minutes: `${c.estimated_minutes}m`, - xp: locked - ? { badge: `${gate} shifts`, tone: 'warning' } - : { value: `+${c.xp}`, tone: 'success' }, - }; - }) - ), - heading('Where you stand'), - kpis([ - { label: 'Courses done', value: done }, - { label: 'Badges', value: (p.earned_badges || []).length }, - { label: 'XP', value: p.xp || 0 }, - { label: 'Verified skills', value: (p.capabilities || []).length }, - ]), - actions([ - { - title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`, - body: done === 0 - ? 'The point of the first course is not the XP — it is getting one verified capability on the board.' - : done < 3 - ? `Three completions is where the score starts moving noticeably. You are at ${done}.` - : 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.', - }, - shifts < 5 && { - title: `Log ${plural(5 - shifts, 'more shift')}`, - body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`, - }, - ]), - note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.') - ); -}; - -/* ── Free-text responder ────────────────────────────────────────────────── */ - -const has = (q, ...words) => words.some((w) => q.includes(w)); - -export const respondTalent = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f); - if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f); - if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f); - - const p = f.profile; - return answer( - p - ? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?` - : NO_PROFILE - ); -}; - -export const TALENT_CAPABILITIES = [ - { id: 'resume-review', label: 'Resume Review', icon: FileText, run: resumeReview }, - { id: 'career-guidance', label: 'Career Guidance', icon: Compass, run: careerGuidance }, - { id: 'learning-recommendations', label: 'Learning Recommendations', icon: BookOpen, run: learningRecommendations }, -]; diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js index c5ced82..7ba7164 100644 --- a/src/components/ai-assistant/contexts.js +++ b/src/components/ai-assistant/contexts.js @@ -6,7 +6,6 @@ import { ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, respondActivity, respondAdminCandidates, respondControlCenter, } from './capabilities/admin'; -import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent'; /** * Page contexts for the Krow AI Assistant. @@ -16,8 +15,8 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent'; * live data in `dynamic.js`, not written here. A context only declares what this * page's assistant can *do*: its capabilities and its free-text responder. * - * `needsWorkerProfile` gates the one query with a side effect, so employer and - * admin sessions never touch worker-profile records. + * Krow AI serves Employer and Admin. The talent portal has Owliver, a separate + * product with its own UI and workflow — nothing here targets it. */ export const ASSISTANT_CONTEXTS = { 'employer.overview': { @@ -56,13 +55,6 @@ export const ASSISTANT_CONTEXTS = { capabilities: ACTIVITY_CAPABILITIES, respond: respondActivity, }, - 'talent.portal': { - id: 'talent.portal', - page: 'My Portal', - capabilities: TALENT_CAPABILITIES, - respond: respondTalent, - needsWorkerProfile: true, - }, }; export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null; diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js index 9773c37..fd13011 100644 --- a/src/components/ai-assistant/dynamic.js +++ b/src/components/ai-assistant/dynamic.js @@ -98,14 +98,6 @@ const GREETINGS = { return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`; }, - 'talent.portal': (f) => { - const p = f.profile; - if (!p) return 'Let us get your profile started.'; - const completion = p.profile_completion || 0; - if (completion >= 90) return 'Your profile is nearly complete — nice work.'; - if (completion > 0) return `You are ${completion}% of the way through your profile.`; - return 'Your profile is empty. A few fields will change what employers see.'; - }, }; /** Title lines. First names read better than a formal greeting in a small panel. */ @@ -116,7 +108,6 @@ const TITLES = { 'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`, 'admin.candidates': () => 'Talent analysis', 'admin.activity': () => 'Activity review', - 'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`, }; /** Descriptions state what this page's assistant can actually do with the data. */ @@ -128,7 +119,6 @@ const DESCRIPTIONS = { 'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.', 'admin.candidates': () => 'Talent supply and risk across the candidate pool.', 'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`, - 'talent.portal': () => 'Your score, your profile, and what to learn next.', }; /** Placeholders phrased as things this page's user would actually type. */ @@ -151,7 +141,6 @@ const PLACEHOLDERS = { 'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'], 'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'], 'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'], - 'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'], }; /* ── Suggested prompts ──────────────────────────────────────────────────── */ @@ -229,16 +218,6 @@ const PROMPTS = { : null, ].filter(Boolean), - 'talent.portal': (f) => { - const p = f.profile; - return [ - { label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' }, - { label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' }, - p && (p.krow_score || 0) > 0 - ? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' } - : { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }, - ]; - }, }; /* ── Public API ─────────────────────────────────────────────────────────── */ diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js index 47b1857..cd97284 100644 --- a/src/components/ai-assistant/placement.js +++ b/src/components/ai-assistant/placement.js @@ -22,9 +22,6 @@ const PLACEMENT = { '/candidates': 'admin.candidates', // Candidates Analysis '/tracking': 'admin.activity', // Activity }, - talent: { - '/employee': 'talent.portal', // My Portal - }, }; /** @@ -34,13 +31,16 @@ const PLACEMENT = { * Employer: Positions, Position Detail, Create Position, Hired History, * Talent Pool, Profile. * Admin: everything except the three above. - * Talent: everything except My Portal. + * Talent: every page. The talent portal has Owliver — a separate product with + * its own voice experience, branding and workflow. Krow AI does not + * appear there and must not replace it. */ export const EXCLUDED_ROUTES = [ '/positions', '/positions/new', '/positions/:id', '/hired', '/talent-pool', '/profile', '/apply', '/university', '/university/:id', '/me', '/identity', '/owliver', '/design-system', + '/employee', // Talent portal — Owliver's, not Krow AI's. ]; /** Returns the context for a role and path, or `null` to render no assistant. */ diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js index 38bc6b6..0b51c47 100644 --- a/src/components/ai-assistant/useAssistant.js +++ b/src/components/ai-assistant/useAssistant.js @@ -1,7 +1,9 @@ import * as React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { base44 } from '@/api/base44Client'; import { useApplications, useCourses, useInterviews, useJobPostings, useStaff, - useUserActivity, useWorkerProfile, useWorkerProfiles, + useUserActivity, useWorkerProfiles, } from '@/lib/krowHooks'; import { buildFacts } from './insights'; import { createAssistantProvider } from './provider'; @@ -14,7 +16,7 @@ const provider = createAssistantProvider(); * costs no extra requests and cannot be looking at a different snapshot than the * card beside it. */ -export function useAssistantFacts({ needsWorkerProfile = false } = {}) { +export function useAssistantFacts() { const { data: applications = [] } = useApplications(); const { data: postings = [] } = useJobPostings(); const { data: interviews = [] } = useInterviews(); @@ -23,19 +25,30 @@ export function useAssistantFacts({ needsWorkerProfile = false } = {}) { const { data: activity = [] } = useUserActivity(); const { data: courses = [] } = useCourses(); - // Gated: this query creates the record if absent, so it must not run for - // employer or admin sessions. - const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile }); - + // Krow AI serves Employer and Admin only, so it never reads the signed-in + // worker's own profile — that query also creates the record if absent, and it + // belongs to the talent portal, not here. return React.useMemo( - () => buildFacts({ - applications, postings, interviews, staff, profiles, activity, courses, - profile: needsWorkerProfile ? profile : null, - }), - [applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile] + () => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }), + [applications, postings, interviews, staff, profiles, activity, courses] ); } +/** + * The signed-in user's first name, for personalizing the greeting. + * + * Shares the `['user']` query key with the rest of the app, so this is a cache + * read rather than another request. + */ +export function useCurrentUserName() { + const { data } = useQuery({ + queryKey: ['user'], + queryFn: () => base44.auth.me().catch(() => null), + staleTime: Infinity, + }); + return data?.full_name || ''; +} + /** * Conversation state for one page context. * diff --git a/src/layouts/Layout.jsx b/src/layouts/Layout.jsx index c7c3285..4b9f95f 100644 --- a/src/layouts/Layout.jsx +++ b/src/layouts/Layout.jsx @@ -10,6 +10,7 @@ import { logActivity } from '@/lib/userTracking'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant'; +import OwliverChatBubble from '@/components/krow/OwliverChatBubble'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'; import KrowIdPreview from '@/components/krow/KrowIdPreview'; @@ -52,6 +53,9 @@ export default function Layout() { const [menuOpen, setMenuOpen] = useState(false); const [user, setUser] = useState(null); const [activeRole, setActiveRole] = useState(() => localStorage.getItem('activeRole') || 'employer'); + // Owliver's own open state. Owliver is a separate product with its own UI and + // voice workflow; it is not routed through the Krow AI panel. + const [owliverOpen, setOwliverOpen] = useState(false); useEffect(() => { base44.auth.me().then(setUser).catch(() => {}); @@ -89,13 +93,13 @@ export default function Layout() { navigate(ROLE_HOME[role]); }; - // Talent can be sent to the Owliver profile-builder conversation from - // anywhere via this event (the portal's own CTA uses it). + // Anything in the talent portal can open Owliver through this event — the + // portal hero's own CTA uses it. useEffect(() => { - const openBuilder = () => navigate('/owliver'); - window.addEventListener('krow:open-owliver', openBuilder); - return () => window.removeEventListener('krow:open-owliver', openBuilder); - }, [navigate]); + const openOwliver = () => setOwliverOpen(true); + window.addEventListener('krow:open-owliver', openOwliver); + return () => window.removeEventListener('krow:open-owliver', openOwliver); + }, []); const navItems = ROLE_NAV[activeRole]; const isTalent = activeRole === 'talent'; @@ -190,7 +194,7 @@ export default function Layout() { navigate(isTalent ? '/owliver' : '/identity')} + onClick={() => (isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'))} className="cursor-pointer" > @@ -314,7 +318,7 @@ export default function Layout() { ))}