Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/(api)/_utils/hackbot/stream/intent.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
74 changes: 73 additions & 1 deletion app/(api)/_utils/hackbot/stream/linksTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,82 @@ export const PROVIDE_LINKS_INPUT_SCHEMA = z.object({
),
});

function getAllowedHosts(): Set<string> {
const hosts = new Set<string>([
'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}`;
}
Comment thread
michelleyeoh marked this conversation as resolved.

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<string>();
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 };
}
9 changes: 8 additions & 1 deletion app/(api)/_utils/hackbot/stream/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
26 changes: 20 additions & 6 deletions app/(api)/_utils/hackbot/stream/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Comment thread
michelleyeoh marked this conversation as resolved.

export function validateRequestBody(
Expand All @@ -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 }
Expand Down Expand Up @@ -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') {
Expand Down
6 changes: 5 additions & 1 deletion app/(api)/_utils/hackbot/stream/responseStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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([
{
Expand Down
76 changes: 60 additions & 16 deletions app/(api)/api/hackbot/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 19 additions & 1 deletion app/(pages)/(hackers)/(hub)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
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 (
<ProtectedDisplay
allowedRoles={['hacker', 'admin']}
failRedirectRoute="/login"
>
<Navbar />
{children}
<HackbotWidgetWrapper initialProfile={profile} />
</ProtectedDisplay>
);
}
Loading
Loading