Skip to content

Commit 29be3e9

Browse files
2. Hackbot User Widget (#444)
* Add HackBot chat widget with event cards, markdown, and layout integration Chat widget with streaming responses, retry logic, and resize handle. Event cards (full for workshops/activities, compact for meals/general). Markdown text renderer, session-gated wrapper, cascading animations. Layout integration in (hackers) route group. * Hackbot user widget fixes+ Intent management * Link sanitization * Intent: Enforce workshop and event queries * split widget into hook * updated a tag to Link --------- Co-authored-by: michelleyeoh <michellew.yeoh@gmail.com> Co-authored-by: michelleyeoh <74385095+michelleyeoh@users.noreply.github.com>
1 parent 56b62b1 commit 29be3e9

15 files changed

Lines changed: 1504 additions & 26 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export function shouldDisableEventsToolForQuery(query: string): boolean {
2+
const q = query.trim().toLowerCase();
3+
if (!q) return false;
4+
5+
const factualIntent =
6+
/\b(judging|judged|rubric|criteria|score|scoring|weights?|points?)\b/.test(
7+
q
8+
) ||
9+
/\b(deadline|deadlines|due date|cutoff|submission deadline)\b/.test(q) ||
10+
/\b(rule|rules|policy|policies|code of conduct|eligib(?:le|ility)|requirements?)\b/.test(
11+
q
12+
) ||
13+
/\b(submit|submission|submissions|devpost|submission process|judging process)\b/.test(
14+
q
15+
) ||
16+
/\b(team number|table number|team size|max team|minimum team|min team)\b/.test(
17+
q
18+
) ||
19+
/\b(prize track|prize tracks|prizes?)\b/.test(q) ||
20+
/\b(check[- ]?in|checkin code|invite|registration)\b/.test(q);
21+
22+
const eventIntent =
23+
/\b(schedule|event|events|workshop|workshops|activit(?:y|ies)|meal|meals|breakfast|brunch|lunch|dinner|happening)\b/.test(
24+
q
25+
);
26+
27+
return factualIntent && !eventIntent;
28+
}
29+
30+
export function isResourcesQuery(query: string): boolean {
31+
const q = query.trim().toLowerCase();
32+
if (!q) return false;
33+
34+
const asksForResources =
35+
/\b(resource|resources|tools?|apis?|libraries|frameworks?|starter kit)\b/.test(
36+
q
37+
) || /\b(figma|ui\s*kit|design\s*kit|palette|templates?)\b/.test(q);
38+
39+
const asksForRoleScopedResources =
40+
/\b(developer|developers|dev)\b/.test(q) ||
41+
/\b(designer|designers|design|ui\/?ux)\b/.test(q);
42+
43+
return asksForResources || asksForRoleScopedResources;
44+
}
45+
46+
export function isExplicitEventQuery(query: string): boolean {
47+
const q = query.trim().toLowerCase();
48+
if (!q) return false;
49+
50+
return /\b(schedule|event|events|workshop|workshops|activit(?:y|ies)|meal|meals|breakfast|brunch|lunch|dinner|happening|attend|go to)\b/.test(
51+
q
52+
);
53+
}

app/(api)/_utils/hackbot/stream/linksTool.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,82 @@ export const PROVIDE_LINKS_INPUT_SCHEMA = z.object({
2323
),
2424
});
2525

26+
function getAllowedHosts(): Set<string> {
27+
const hosts = new Set<string>([
28+
'hackdavis.io',
29+
'hub.hackdavis.io',
30+
'staging-hub.hackdavis.io',
31+
]);
32+
const baseUrl = process.env.BASE_URL;
33+
34+
if (baseUrl) {
35+
try {
36+
hosts.add(new URL(baseUrl).hostname.toLowerCase());
37+
} catch {
38+
// Ignore invalid BASE_URL; fall back to static allow-list.
39+
}
40+
}
41+
42+
return hosts;
43+
}
44+
45+
function normalizeToRelativeHubPath(url: string): string | null {
46+
const raw = url.trim();
47+
if (!raw) return null;
48+
49+
if (raw.startsWith('//')) {
50+
return null;
51+
}
52+
53+
if (raw.startsWith('/')) {
54+
return raw.replace(/^\/+/, '/');
55+
}
56+
57+
if (raw.startsWith('#')) {
58+
return `/${raw}`;
59+
}
60+
61+
let parsed: URL;
62+
try {
63+
parsed = new URL(raw);
64+
} catch {
65+
return null;
66+
}
67+
68+
const hostname = parsed.hostname.toLowerCase();
69+
if (!getAllowedHosts().has(hostname)) {
70+
return null;
71+
}
72+
73+
const path = parsed.pathname || '/';
74+
const search = parsed.search || '';
75+
const hash = parsed.hash || '';
76+
return `${path}${search}${hash}`;
77+
}
78+
2679
export async function executeProvideLinks({
2780
links,
2881
}: {
2982
links: Array<{ label: string; url: string }>;
3083
}) {
31-
return { links };
84+
const seen = new Set<string>();
85+
const sanitized = links
86+
.map((link) => {
87+
const relativeUrl = normalizeToRelativeHubPath(link.url);
88+
if (!relativeUrl) return null;
89+
90+
const label = link.label.trim().slice(0, 80);
91+
if (!label) return null;
92+
93+
return { label, url: relativeUrl };
94+
})
95+
.filter((link): link is { label: string; url: string } => Boolean(link))
96+
.filter((link) => {
97+
if (seen.has(link.url)) return false;
98+
seen.add(link.url);
99+
return true;
100+
})
101+
.slice(0, 3);
102+
103+
return { links: sanitized };
32104
}

app/(api)/_utils/hackbot/stream/model.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@ export function getModelConfig() {
1818
return { model, maxOutputTokens, isReasoningModel };
1919
}
2020

21-
export function shouldStopStreaming(state: any): boolean {
21+
export function shouldStopStreaming(
22+
state: any,
23+
opts?: { allowProvideLinksShortCircuit?: boolean }
24+
): boolean {
2225
const { steps } = state as { steps: any[] };
26+
const allowProvideLinksShortCircuit =
27+
opts?.allowProvideLinksShortCircuit ?? true;
28+
2329
if (stepCountIs(5)({ steps })) return true;
2430
if (!steps.length) return false;
2531

@@ -28,6 +34,7 @@ export function shouldStopStreaming(state: any): boolean {
2834
toolCalls.length > 0 &&
2935
toolCalls.every((t: any) => t.toolName === 'provide_links');
3036

37+
if (!allowProvideLinksShortCircuit) return false;
3138
if (!onlyProvideLinks) return false;
3239
return steps.some((s: any) => (s.text ?? '').trim().length > 0);
3340
}

app/(api)/_utils/hackbot/stream/request.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import type {
66
const MAX_USER_MESSAGE_CHARS = 200;
77
const MAX_HISTORY_MESSAGES = 30;
88
const MAX_MESSAGE_CHARS = 2000;
9-
const MAX_TOTAL_MESSAGE_CHARS = 12000;
9+
export const MAX_CONTEXT_HISTORY_MESSAGES = 6;
10+
const MAX_TOTAL_MESSAGE_CHARS =
11+
MAX_CONTEXT_HISTORY_MESSAGES * MAX_MESSAGE_CHARS;
1012
const ALLOWED_MESSAGE_ROLES = new Set(['user', 'assistant']);
1113

1214
export function validateRequestBody(
@@ -32,11 +34,19 @@ export function validateRequestBody(
3234
for (const message of messages) {
3335
const role = message?.role;
3436
const content = message?.content;
35-
if (
36-
!ALLOWED_MESSAGE_ROLES.has(role) ||
37-
typeof content !== 'string' ||
38-
!content.trim()
39-
) {
37+
if (!ALLOWED_MESSAGE_ROLES.has(role) || typeof content !== 'string') {
38+
return Response.json(
39+
{ error: 'Invalid message history format.' },
40+
{ status: 400 }
41+
);
42+
}
43+
44+
const trimmedContent = content.trim();
45+
if (!trimmedContent) {
46+
// Tool-only replies can persist as empty assistant text in localStorage.
47+
// Drop them from request history instead of failing the whole request.
48+
if (role === 'assistant') continue;
49+
4050
return Response.json(
4151
{ error: 'Invalid message history format.' },
4252
{ status: 400 }
@@ -68,6 +78,10 @@ export function validateRequestBody(
6878
});
6979
}
7080

81+
if (sanitizedMessages.length === 0) {
82+
return Response.json({ error: 'Invalid request' }, { status: 400 });
83+
}
84+
7185
const lastMessage = sanitizedMessages[sanitizedMessages.length - 1];
7286

7387
if (lastMessage.role !== 'user') {

app/(api)/_utils/hackbot/stream/responseStream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ export function createResponseStream(
1616
const enq = (line: string) => controller.enqueue(enc.encode(line));
1717

1818
let suppressText = false;
19+
let hasEmittedText = false;
1920

2021
try {
2122
for await (const part of result.fullStream) {
2223
if (part?.type === 'text-delta') {
2324
if (!suppressText) {
2425
enq(`0:${JSON.stringify(part.text ?? '')}\n`);
26+
if (part.text) hasEmittedText = true;
2527
}
2628
} else if (part?.type === 'tool-call') {
2729
enq(
@@ -34,7 +36,9 @@ export function createResponseStream(
3436
])}\n`
3537
);
3638
} else if (part?.type === 'tool-result') {
37-
suppressText = true;
39+
// Keep allowing text if no assistant text has been emitted yet.
40+
// This preserves a tool-first "intro sentence + cards" UX.
41+
if (hasEmittedText) suppressText = true;
3842
enq(
3943
`a:${JSON.stringify([
4044
{

app/(api)/api/hackbot/stream/route.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FEW_SHOT_EXAMPLES } from '@utils/hackbot/stream/fewShots';
44
import {
55
validateRequestBody,
66
isSimpleGreetingMessage,
7+
MAX_CONTEXT_HISTORY_MESSAGES,
78
} from '@utils/hackbot/stream/request';
89
import {
910
fetchSessionAndDocs,
@@ -14,6 +15,11 @@ import {
1415
getModelConfig,
1516
shouldStopStreaming,
1617
} from '@utils/hackbot/stream/model';
18+
import {
19+
shouldDisableEventsToolForQuery,
20+
isResourcesQuery,
21+
isExplicitEventQuery,
22+
} from '@utils/hackbot/stream/intent';
1723
import {
1824
GET_EVENTS_INPUT_SCHEMA,
1925
executeGetEvents,
@@ -26,7 +32,20 @@ import {
2632
import { createResponseStream } from '@utils/hackbot/stream/responseStream';
2733
import { getPageContext, buildSystemPrompt } from '@utils/hackbot/systemPrompt';
2834

29-
const MAX_HISTORY_MESSAGES = 6;
35+
function normalizeGetEventsInputForQuery(input: any, query: string): any {
36+
const q = query.trim().toLowerCase();
37+
if (!q) return input;
38+
39+
const asksForWorkshops = /\bworkshops?\b/.test(q);
40+
if (!asksForWorkshops) return input;
41+
42+
// If the user explicitly asks for workshops, enforce WORKSHOPS so
43+
// generic schedule items (e.g. "Hacking Ends") are not returned.
44+
return {
45+
...input,
46+
type: 'WORKSHOPS',
47+
};
48+
}
3049

3150
export async function POST(request: Request) {
3251
try {
@@ -60,34 +79,59 @@ export async function POST(request: Request) {
6079
role: 'system',
6180
content: `Knowledge context about HackDavis (rules, submission, judging, tracks, general info):\n\n${contextSummary}`,
6281
},
63-
...sanitizedMessages.slice(-MAX_HISTORY_MESSAGES),
82+
...sanitizedMessages.slice(-MAX_CONTEXT_HISTORY_MESSAGES),
6483
];
6584

6685
const { model, maxOutputTokens } = getModelConfig();
86+
const disableEventsTool = shouldDisableEventsToolForQuery(
87+
lastMessage.content
88+
);
89+
const resourcesQuery = isResourcesQuery(lastMessage.content);
90+
const explicitEventQuery = isExplicitEventQuery(lastMessage.content);
91+
const requireEventsTool = explicitEventQuery && !disableEventsTool;
92+
93+
const tools = {
94+
...(requireEventsTool
95+
? {}
96+
: {
97+
provide_links: tool({
98+
description: PROVIDE_LINKS_DESCRIPTION,
99+
inputSchema: PROVIDE_LINKS_INPUT_SCHEMA,
100+
execute: executeProvideLinks,
101+
}),
102+
}),
103+
...(disableEventsTool
104+
? {}
105+
: {
106+
get_events: tool({
107+
description:
108+
'Fetch the live HackDavis event schedule from the database. Use this for ANY question about event times, locations, schedule, or what is happening when.',
109+
inputSchema: GET_EVENTS_INPUT_SCHEMA,
110+
execute: (input) =>
111+
executeGetEvents(
112+
normalizeGetEventsInputForQuery(input, lastMessage.content),
113+
profile,
114+
lastMessage.content
115+
),
116+
}),
117+
}),
118+
};
67119

68120
// eslint-disable-next-line @typescript-eslint/no-explicit-any
69121
const result = streamText({
70122
model: openai(model) as any,
123+
temperature: 0,
71124
messages: chatMessages.map((m: any) => ({
72125
role: m.role as 'system' | 'user' | 'assistant',
73126
content: m.content,
74127
})),
75128
maxOutputTokens,
76-
stopWhen: shouldStopStreaming,
77-
tools: {
78-
get_events: tool({
79-
description:
80-
'Fetch the live HackDavis event schedule from the database. Use this for ANY question about event times, locations, schedule, or what is happening when.',
81-
inputSchema: GET_EVENTS_INPUT_SCHEMA,
82-
execute: (input) =>
83-
executeGetEvents(input, profile, lastMessage.content),
129+
...(requireEventsTool ? { toolChoice: 'required' as const } : {}),
130+
stopWhen: (state) =>
131+
shouldStopStreaming(state, {
132+
allowProvideLinksShortCircuit: !resourcesQuery,
84133
}),
85-
provide_links: tool({
86-
description: PROVIDE_LINKS_DESCRIPTION,
87-
inputSchema: PROVIDE_LINKS_INPUT_SCHEMA,
88-
execute: executeProvideLinks,
89-
}),
90-
},
134+
tools,
91135
});
92136

93137
const stream = createResponseStream(result, model);
Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
1+
import { auth } from '@/auth';
12
import ProtectedDisplay from '@components/ProtectedDisplay/ProtectedDisplay';
23
import Navbar from '@components/Navbar/Navbar';
4+
import HackbotWidgetWrapper from '../_components/Hackbot/HackbotWidgetWrapper';
5+
import type { HackerProfile } from '@typeDefs/hackbot';
6+
7+
export default async function Layout({
8+
children,
9+
}: {
10+
children: React.ReactNode;
11+
}) {
12+
const session = await auth();
13+
const u = session?.user as any;
14+
const profile: HackerProfile | null = u
15+
? {
16+
name: u.name ?? undefined,
17+
position: u.position ?? undefined,
18+
is_beginner: u.is_beginner ?? undefined,
19+
}
20+
: null;
321

4-
export default function Layout({ children }: { children: React.ReactNode }) {
522
return (
623
<ProtectedDisplay
724
allowedRoles={['hacker', 'admin']}
825
failRedirectRoute="/login"
926
>
1027
<Navbar />
1128
{children}
29+
<HackbotWidgetWrapper initialProfile={profile} />
1230
</ProtectedDisplay>
1331
);
1432
}

0 commit comments

Comments
 (0)