diff --git a/app/(api)/_utils/hackbot/stream/intent.ts b/app/(api)/_utils/hackbot/stream/intent.ts new file mode 100644 index 00000000..088f878e --- /dev/null +++ b/app/(api)/_utils/hackbot/stream/intent.ts @@ -0,0 +1,53 @@ +export function shouldDisableEventsToolForQuery(query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return false; + + const factualIntent = + /\b(judging|judged|rubric|criteria|score|scoring|weights?|points?)\b/.test( + q + ) || + /\b(deadline|deadlines|due date|cutoff|submission deadline)\b/.test(q) || + /\b(rule|rules|policy|policies|code of conduct|eligib(?:le|ility)|requirements?)\b/.test( + q + ) || + /\b(submit|submission|submissions|devpost|submission process|judging process)\b/.test( + q + ) || + /\b(team number|table number|team size|max team|minimum team|min team)\b/.test( + q + ) || + /\b(prize track|prize tracks|prizes?)\b/.test(q) || + /\b(check[- ]?in|checkin code|invite|registration)\b/.test(q); + + const eventIntent = + /\b(schedule|event|events|workshop|workshops|activit(?:y|ies)|meal|meals|breakfast|brunch|lunch|dinner|happening)\b/.test( + q + ); + + return factualIntent && !eventIntent; +} + +export function isResourcesQuery(query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return false; + + const asksForResources = + /\b(resource|resources|tools?|apis?|libraries|frameworks?|starter kit)\b/.test( + q + ) || /\b(figma|ui\s*kit|design\s*kit|palette|templates?)\b/.test(q); + + const asksForRoleScopedResources = + /\b(developer|developers|dev)\b/.test(q) || + /\b(designer|designers|design|ui\/?ux)\b/.test(q); + + return asksForResources || asksForRoleScopedResources; +} + +export function isExplicitEventQuery(query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return false; + + return /\b(schedule|event|events|workshop|workshops|activit(?:y|ies)|meal|meals|breakfast|brunch|lunch|dinner|happening|attend|go to)\b/.test( + q + ); +} diff --git a/app/(api)/_utils/hackbot/stream/linksTool.ts b/app/(api)/_utils/hackbot/stream/linksTool.ts index f731abdf..b7c99a11 100644 --- a/app/(api)/_utils/hackbot/stream/linksTool.ts +++ b/app/(api)/_utils/hackbot/stream/linksTool.ts @@ -23,10 +23,82 @@ export const PROVIDE_LINKS_INPUT_SCHEMA = z.object({ ), }); +function getAllowedHosts(): Set { + const hosts = new Set([ + 'hackdavis.io', + 'hub.hackdavis.io', + 'staging-hub.hackdavis.io', + ]); + const baseUrl = process.env.BASE_URL; + + if (baseUrl) { + try { + hosts.add(new URL(baseUrl).hostname.toLowerCase()); + } catch { + // Ignore invalid BASE_URL; fall back to static allow-list. + } + } + + return hosts; +} + +function normalizeToRelativeHubPath(url: string): string | null { + const raw = url.trim(); + if (!raw) return null; + + if (raw.startsWith('//')) { + return null; + } + + if (raw.startsWith('/')) { + return raw.replace(/^\/+/, '/'); + } + + if (raw.startsWith('#')) { + return `/${raw}`; + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + + const hostname = parsed.hostname.toLowerCase(); + if (!getAllowedHosts().has(hostname)) { + return null; + } + + const path = parsed.pathname || '/'; + const search = parsed.search || ''; + const hash = parsed.hash || ''; + return `${path}${search}${hash}`; +} + export async function executeProvideLinks({ links, }: { links: Array<{ label: string; url: string }>; }) { - return { links }; + const seen = new Set(); + const sanitized = links + .map((link) => { + const relativeUrl = normalizeToRelativeHubPath(link.url); + if (!relativeUrl) return null; + + const label = link.label.trim().slice(0, 80); + if (!label) return null; + + return { label, url: relativeUrl }; + }) + .filter((link): link is { label: string; url: string } => Boolean(link)) + .filter((link) => { + if (seen.has(link.url)) return false; + seen.add(link.url); + return true; + }) + .slice(0, 3); + + return { links: sanitized }; } diff --git a/app/(api)/_utils/hackbot/stream/model.ts b/app/(api)/_utils/hackbot/stream/model.ts index 7ccf4772..ae12e00a 100644 --- a/app/(api)/_utils/hackbot/stream/model.ts +++ b/app/(api)/_utils/hackbot/stream/model.ts @@ -18,8 +18,14 @@ export function getModelConfig() { return { model, maxOutputTokens, isReasoningModel }; } -export function shouldStopStreaming(state: any): boolean { +export function shouldStopStreaming( + state: any, + opts?: { allowProvideLinksShortCircuit?: boolean } +): boolean { const { steps } = state as { steps: any[] }; + const allowProvideLinksShortCircuit = + opts?.allowProvideLinksShortCircuit ?? true; + if (stepCountIs(5)({ steps })) return true; if (!steps.length) return false; @@ -28,6 +34,7 @@ export function shouldStopStreaming(state: any): boolean { toolCalls.length > 0 && toolCalls.every((t: any) => t.toolName === 'provide_links'); + if (!allowProvideLinksShortCircuit) return false; if (!onlyProvideLinks) return false; return steps.some((s: any) => (s.text ?? '').trim().length > 0); } diff --git a/app/(api)/_utils/hackbot/stream/request.ts b/app/(api)/_utils/hackbot/stream/request.ts index bd9cb4c6..cc1d678f 100644 --- a/app/(api)/_utils/hackbot/stream/request.ts +++ b/app/(api)/_utils/hackbot/stream/request.ts @@ -6,7 +6,9 @@ import type { const MAX_USER_MESSAGE_CHARS = 200; const MAX_HISTORY_MESSAGES = 30; const MAX_MESSAGE_CHARS = 2000; -const MAX_TOTAL_MESSAGE_CHARS = 12000; +export const MAX_CONTEXT_HISTORY_MESSAGES = 6; +const MAX_TOTAL_MESSAGE_CHARS = + MAX_CONTEXT_HISTORY_MESSAGES * MAX_MESSAGE_CHARS; const ALLOWED_MESSAGE_ROLES = new Set(['user', 'assistant']); export function validateRequestBody( @@ -32,11 +34,19 @@ export function validateRequestBody( for (const message of messages) { const role = message?.role; const content = message?.content; - if ( - !ALLOWED_MESSAGE_ROLES.has(role) || - typeof content !== 'string' || - !content.trim() - ) { + if (!ALLOWED_MESSAGE_ROLES.has(role) || typeof content !== 'string') { + return Response.json( + { error: 'Invalid message history format.' }, + { status: 400 } + ); + } + + const trimmedContent = content.trim(); + if (!trimmedContent) { + // Tool-only replies can persist as empty assistant text in localStorage. + // Drop them from request history instead of failing the whole request. + if (role === 'assistant') continue; + return Response.json( { error: 'Invalid message history format.' }, { status: 400 } @@ -68,6 +78,10 @@ export function validateRequestBody( }); } + if (sanitizedMessages.length === 0) { + return Response.json({ error: 'Invalid request' }, { status: 400 }); + } + const lastMessage = sanitizedMessages[sanitizedMessages.length - 1]; if (lastMessage.role !== 'user') { diff --git a/app/(api)/_utils/hackbot/stream/responseStream.ts b/app/(api)/_utils/hackbot/stream/responseStream.ts index b6dd9a2b..ff9dbb15 100644 --- a/app/(api)/_utils/hackbot/stream/responseStream.ts +++ b/app/(api)/_utils/hackbot/stream/responseStream.ts @@ -16,12 +16,14 @@ export function createResponseStream( const enq = (line: string) => controller.enqueue(enc.encode(line)); let suppressText = false; + let hasEmittedText = false; try { for await (const part of result.fullStream) { if (part?.type === 'text-delta') { if (!suppressText) { enq(`0:${JSON.stringify(part.text ?? '')}\n`); + if (part.text) hasEmittedText = true; } } else if (part?.type === 'tool-call') { enq( @@ -34,7 +36,9 @@ export function createResponseStream( ])}\n` ); } else if (part?.type === 'tool-result') { - suppressText = true; + // Keep allowing text if no assistant text has been emitted yet. + // This preserves a tool-first "intro sentence + cards" UX. + if (hasEmittedText) suppressText = true; enq( `a:${JSON.stringify([ { diff --git a/app/(api)/api/hackbot/stream/route.ts b/app/(api)/api/hackbot/stream/route.ts index 36ff965a..f91480cb 100644 --- a/app/(api)/api/hackbot/stream/route.ts +++ b/app/(api)/api/hackbot/stream/route.ts @@ -4,6 +4,7 @@ import { FEW_SHOT_EXAMPLES } from '@utils/hackbot/stream/fewShots'; import { validateRequestBody, isSimpleGreetingMessage, + MAX_CONTEXT_HISTORY_MESSAGES, } from '@utils/hackbot/stream/request'; import { fetchSessionAndDocs, @@ -14,6 +15,11 @@ import { getModelConfig, shouldStopStreaming, } from '@utils/hackbot/stream/model'; +import { + shouldDisableEventsToolForQuery, + isResourcesQuery, + isExplicitEventQuery, +} from '@utils/hackbot/stream/intent'; import { GET_EVENTS_INPUT_SCHEMA, executeGetEvents, @@ -26,7 +32,20 @@ import { import { createResponseStream } from '@utils/hackbot/stream/responseStream'; import { getPageContext, buildSystemPrompt } from '@utils/hackbot/systemPrompt'; -const MAX_HISTORY_MESSAGES = 6; +function normalizeGetEventsInputForQuery(input: any, query: string): any { + const q = query.trim().toLowerCase(); + if (!q) return input; + + const asksForWorkshops = /\bworkshops?\b/.test(q); + if (!asksForWorkshops) return input; + + // If the user explicitly asks for workshops, enforce WORKSHOPS so + // generic schedule items (e.g. "Hacking Ends") are not returned. + return { + ...input, + type: 'WORKSHOPS', + }; +} export async function POST(request: Request) { try { @@ -60,34 +79,59 @@ export async function POST(request: Request) { role: 'system', content: `Knowledge context about HackDavis (rules, submission, judging, tracks, general info):\n\n${contextSummary}`, }, - ...sanitizedMessages.slice(-MAX_HISTORY_MESSAGES), + ...sanitizedMessages.slice(-MAX_CONTEXT_HISTORY_MESSAGES), ]; const { model, maxOutputTokens } = getModelConfig(); + const disableEventsTool = shouldDisableEventsToolForQuery( + lastMessage.content + ); + const resourcesQuery = isResourcesQuery(lastMessage.content); + const explicitEventQuery = isExplicitEventQuery(lastMessage.content); + const requireEventsTool = explicitEventQuery && !disableEventsTool; + + const tools = { + ...(requireEventsTool + ? {} + : { + provide_links: tool({ + description: PROVIDE_LINKS_DESCRIPTION, + inputSchema: PROVIDE_LINKS_INPUT_SCHEMA, + execute: executeProvideLinks, + }), + }), + ...(disableEventsTool + ? {} + : { + get_events: tool({ + description: + 'Fetch the live HackDavis event schedule from the database. Use this for ANY question about event times, locations, schedule, or what is happening when.', + inputSchema: GET_EVENTS_INPUT_SCHEMA, + execute: (input) => + executeGetEvents( + normalizeGetEventsInputForQuery(input, lastMessage.content), + profile, + lastMessage.content + ), + }), + }), + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = streamText({ model: openai(model) as any, + temperature: 0, messages: chatMessages.map((m: any) => ({ role: m.role as 'system' | 'user' | 'assistant', content: m.content, })), maxOutputTokens, - stopWhen: shouldStopStreaming, - tools: { - get_events: tool({ - description: - 'Fetch the live HackDavis event schedule from the database. Use this for ANY question about event times, locations, schedule, or what is happening when.', - inputSchema: GET_EVENTS_INPUT_SCHEMA, - execute: (input) => - executeGetEvents(input, profile, lastMessage.content), + ...(requireEventsTool ? { toolChoice: 'required' as const } : {}), + stopWhen: (state) => + shouldStopStreaming(state, { + allowProvideLinksShortCircuit: !resourcesQuery, }), - provide_links: tool({ - description: PROVIDE_LINKS_DESCRIPTION, - inputSchema: PROVIDE_LINKS_INPUT_SCHEMA, - execute: executeProvideLinks, - }), - }, + tools, }); const stream = createResponseStream(result, model); diff --git a/app/(pages)/(hackers)/(hub)/layout.tsx b/app/(pages)/(hackers)/(hub)/layout.tsx index 602aadd8..52a72640 100644 --- a/app/(pages)/(hackers)/(hub)/layout.tsx +++ b/app/(pages)/(hackers)/(hub)/layout.tsx @@ -1,7 +1,24 @@ +import { auth } from '@/auth'; import ProtectedDisplay from '@components/ProtectedDisplay/ProtectedDisplay'; import Navbar from '@components/Navbar/Navbar'; +import HackbotWidgetWrapper from '../_components/Hackbot/HackbotWidgetWrapper'; +import type { HackerProfile } from '@typeDefs/hackbot'; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + const u = session?.user as any; + const profile: HackerProfile | null = u + ? { + name: u.name ?? undefined, + position: u.position ?? undefined, + is_beginner: u.is_beginner ?? undefined, + } + : null; -export default function Layout({ children }: { children: React.ReactNode }) { return ( {children} + ); } diff --git a/app/(pages)/(hackers)/_components/Hackbot/HackbotEventCard.tsx b/app/(pages)/(hackers)/_components/Hackbot/HackbotEventCard.tsx new file mode 100644 index 00000000..84d9699e --- /dev/null +++ b/app/(pages)/(hackers)/_components/Hackbot/HackbotEventCard.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useState } from 'react'; +import { HiLocationMarker } from 'react-icons/hi'; +import { PiStarFourFill } from 'react-icons/pi'; +import { createUserToEvent } from '@actions/userToEvents/createUserToEvent'; +import { deleteUserToEvent } from '@actions/userToEvents/deleteUserToEvent'; +import type { HackbotEvent } from '@typeDefs/hackbot'; + +// Matches scheduleEventStyles.ts +const TYPE_STYLE: Record = + { + WORKSHOPS: { bg: '#E9FBBA', text: '#1A3819', label: 'Workshop' }, + GENERAL: { bg: '#CCFFFE', text: '#003D3D', label: 'General' }, + ACTIVITIES: { bg: '#FFE2D5', text: '#52230C', label: 'Activity' }, + MEALS: { bg: '#FFE7B2', text: '#572700', label: 'Meal' }, + }; + +const TAG_LABEL: Record = { + developer: 'Dev', + designer: 'Design', + beginner: 'Beginner', + pm: 'PM', + other: 'Other', +}; + +// Only show Add button for event types that make sense to save +const ADDABLE_TYPES = new Set(['WORKSHOPS', 'ACTIVITIES']); + +function maskIconStyle(src: string): React.CSSProperties { + return { + backgroundColor: 'currentColor', + WebkitMaskImage: `url('${src}')`, + WebkitMaskRepeat: 'no-repeat', + WebkitMaskPosition: 'center', + WebkitMaskSize: 'contain', + maskImage: `url('${src}')`, + maskRepeat: 'no-repeat', + maskPosition: 'center', + maskSize: 'contain', + }; +} + +export default function HackbotEventCard({ + event, + userId, +}: { + event: HackbotEvent; + userId: string; +}) { + const [added, setAdded] = useState(false); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(false); + + const handleAdd = async () => { + if (!userId || adding) return; + setAdding(true); + setAddError(false); + try { + if (added) { + const result = await deleteUserToEvent({ + user_id: userId, + event_id: event.id, + }); + if (!result.ok) throw new Error('Failed to remove'); + setAdded(false); + } else { + const result = await createUserToEvent(userId, event.id); + if (!result.ok) throw new Error(result.error ?? 'Failed'); + setAdded(true); + } + } catch { + setAddError(true); + } + setAdding(false); + }; + + const style = TYPE_STYLE[event.type ?? '']; + const canAdd = userId && ADDABLE_TYPES.has(event.type ?? ''); + + return ( +
+ {/* Event name — full width */} +
+

{event.name}

+
+ + {/* Two-column body */} +
+ {/* Left: datetime, location, tags */} +
+ {event.start && ( +

+ {event.start} + {event.end ? ` - ${event.end}` : ''} +

+ )} + {event.location && ( +

+ + {event.location} +

+ )} + {event.tags.length > 0 && ( +
+ {event.tags.map((tag) => ( + + {TAG_LABEL[tag] ?? tag} + + ))} +
+ )} +
+ + {/* Right: type badge, hosted by, recommended */} +
+ {style && ( + + {style.label} + + )} + {event.host && ( +

+ {event.host} +

+ )} + {event.isRecommended && ( +

+ + Recommended +

+ )} + {canAdd && ( + + )} +
+
+
+ ); +} diff --git a/app/(pages)/(hackers)/_components/Hackbot/HackbotHeader.tsx b/app/(pages)/(hackers)/_components/Hackbot/HackbotHeader.tsx new file mode 100644 index 00000000..57934394 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Hackbot/HackbotHeader.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { RxCross1 } from 'react-icons/rx'; + +export default function HackbotHeader({ + firstName, + onClose, +}: { + firstName: string | undefined; + onClose: () => void; +}) { + return ( +
+
+

HackDavis Helper

+

+ {firstName + ? `Hi ${firstName}! Ask me anything about HackDavis.` + : 'Ask me anything about HackDavis!'} +

+
+ +
+ ); +} diff --git a/app/(pages)/(hackers)/_components/Hackbot/HackbotInputForm.tsx b/app/(pages)/(hackers)/_components/Hackbot/HackbotInputForm.tsx new file mode 100644 index 00000000..1de672a5 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Hackbot/HackbotInputForm.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useState } from 'react'; + +export default function HackbotInputForm({ + input, + setInput, + canSend, + loading, + error, + maxChars, + onSubmit, + onSend, + onDismissError, + suggestionChips, + onChipSend, +}: { + input: string; + setInput: (val: string) => void; + canSend: boolean; + loading: boolean; + error: string | null; + maxChars: number; + onSubmit: (e: React.FormEvent) => void; + onSend: () => void; + onDismissError: () => void; + suggestionChips: string[]; + onChipSend: (text: string) => void; +}) { + const [showSuggestions, setShowSuggestions] = useState(false); + + return ( +
+ {/* Suggestion chips accordion — one per line */} + {showSuggestions && ( +
+ {suggestionChips.map((q) => ( + + ))} +
+ )} + +
+