omarsol commited on
Commit
36275f0
·
1 Parent(s): c0acfad

Add enabled tools management to ChatShell and SourceSidebar components. Update API to handle enabled tools in chat requests and adjust prompts accordingly. Enhance tool toggling functionality and improve UI for tool selection.

Browse files
frontend/components/chat-shell.tsx CHANGED
@@ -40,6 +40,7 @@ export function ChatShell() {
40
  const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
41
  const [selectedModel, setSelectedModel] = useState<string>("");
42
  const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
 
43
  const [sourceError, setSourceError] = useState<string | null>(null);
44
  const [input, setInput] = useState("");
45
  const [threadId, setThreadId] = useState("");
@@ -99,6 +100,11 @@ export function ChatShell() {
99
  .map((source) => source.key),
100
  );
101
  }
 
 
 
 
 
102
  setSourceError(null);
103
  } catch (loadError) {
104
  if (controller.signal.aborted) {
@@ -133,6 +139,11 @@ export function ChatShell() {
133
  await fetchTools(controller.signal, selectedModel);
134
  setTools(loadedTools);
135
  setAvailableModels(models ?? []);
 
 
 
 
 
136
  } catch (loadError) {
137
  if (controller.signal.aborted) {
138
  return;
@@ -254,6 +265,7 @@ export function ChatShell() {
254
  {
255
  body: {
256
  sourceKeys: selectedSourceKeys,
 
257
  includeReasoning: true,
258
  threadId,
259
  model: selectedModel,
@@ -331,6 +343,7 @@ export function ChatShell() {
331
  {
332
  body: {
333
  sourceKeys: selectedSourceKeys,
 
334
  includeReasoning: true,
335
  threadId,
336
  model: selectedModel,
@@ -360,13 +373,23 @@ export function ChatShell() {
360
  );
361
  }
362
 
 
 
 
 
 
 
 
 
363
  return (
364
  <main className="min-h-screen p-2 lg:h-screen lg:overflow-hidden">
365
  <div className="flex min-h-[calc(100vh-1rem)] w-full flex-col gap-2 lg:h-[calc(100vh-1rem)] lg:min-h-0 lg:grid lg:grid-cols-[248px_minmax(0,1fr)]">
366
  <SourceSidebar
367
  onNewChat={handleNewChat}
368
  onToggleSource={toggleSource}
 
369
  selectedSourceKeys={selectedSourceKeys}
 
370
  sourceError={sourceError}
371
  tools={tools}
372
  />
 
40
  const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
41
  const [selectedModel, setSelectedModel] = useState<string>("");
42
  const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
43
+ const [enabledToolKeys, setEnabledToolKeys] = useState<string[]>([]);
44
  const [sourceError, setSourceError] = useState<string | null>(null);
45
  const [input, setInput] = useState("");
46
  const [threadId, setThreadId] = useState("");
 
100
  .map((source) => source.key),
101
  );
102
  }
103
+ setEnabledToolKeys(
104
+ loadedTools
105
+ .filter((tool) => tool.kind === "toggle" && tool.active)
106
+ .map((tool) => tool.key),
107
+ );
108
  setSourceError(null);
109
  } catch (loadError) {
110
  if (controller.signal.aborted) {
 
139
  await fetchTools(controller.signal, selectedModel);
140
  setTools(loadedTools);
141
  setAvailableModels(models ?? []);
142
+ setEnabledToolKeys(
143
+ loadedTools
144
+ .filter((tool) => tool.kind === "toggle" && tool.active)
145
+ .map((tool) => tool.key),
146
+ );
147
  } catch (loadError) {
148
  if (controller.signal.aborted) {
149
  return;
 
265
  {
266
  body: {
267
  sourceKeys: selectedSourceKeys,
268
+ enabledTools: enabledToolKeys,
269
  includeReasoning: true,
270
  threadId,
271
  model: selectedModel,
 
343
  {
344
  body: {
345
  sourceKeys: selectedSourceKeys,
346
+ enabledTools: enabledToolKeys,
347
  includeReasoning: true,
348
  threadId,
349
  model: selectedModel,
 
373
  );
374
  }
375
 
376
+ function toggleTool(toolKey: string) {
377
+ setEnabledToolKeys((current) =>
378
+ current.includes(toolKey)
379
+ ? current.filter((key) => key !== toolKey)
380
+ : [...current, toolKey],
381
+ );
382
+ }
383
+
384
  return (
385
  <main className="min-h-screen p-2 lg:h-screen lg:overflow-hidden">
386
  <div className="flex min-h-[calc(100vh-1rem)] w-full flex-col gap-2 lg:h-[calc(100vh-1rem)] lg:min-h-0 lg:grid lg:grid-cols-[248px_minmax(0,1fr)]">
387
  <SourceSidebar
388
  onNewChat={handleNewChat}
389
  onToggleSource={toggleSource}
390
+ onToggleTool={toggleTool}
391
  selectedSourceKeys={selectedSourceKeys}
392
+ enabledToolKeys={enabledToolKeys}
393
  sourceError={sourceError}
394
  tools={tools}
395
  />
frontend/components/source-sidebar.tsx CHANGED
@@ -15,11 +15,13 @@ import {
15
  } from "lucide-react";
16
  import {
17
  useEffect,
 
18
  useRef,
19
  useState,
20
  type ComponentType,
21
  type SVGProps,
22
  } from "react";
 
23
  import type { TutorSource, TutorTool } from "@/lib/api";
24
  import { COURSE_METADATA } from "@/lib/course-metadata";
25
  import { DOC_METADATA } from "@/lib/doc-metadata";
@@ -27,25 +29,42 @@ import { DOC_METADATA } from "@/lib/doc-metadata";
27
  type SourceSidebarProps = {
28
  onNewChat: () => void;
29
  onToggleSource: (sourceKey: string) => void;
 
30
  selectedSourceKeys: string[];
 
31
  sourceError: string | null;
32
  tools: TutorTool[];
33
  };
34
 
35
  type ToggleToolMeta = {
36
  icon: ComponentType<SVGProps<SVGSVGElement>>;
 
37
  };
38
 
39
  const TOGGLE_TOOL_META: Record<string, ToggleToolMeta> = {
40
- web_search: { icon: Globe },
41
- url_context: { icon: LinkIcon },
42
- web_fetch: { icon: LinkIcon },
 
 
 
 
 
 
 
 
 
 
 
 
43
  };
44
 
45
  export function SourceSidebar({
46
  onNewChat,
47
  onToggleSource,
 
48
  selectedSourceKeys,
 
49
  sourceError,
50
  tools,
51
  }: SourceSidebarProps) {
@@ -59,8 +78,31 @@ export function SourceSidebar({
59
  );
60
  const activeCount =
61
  (retrievalTool && selectedSourceKeys.length > 0 ? 1 : 0) +
62
- toggleTools.filter((tool) => tool.active).length;
63
  const totalCount = (retrievalTool ? 1 : 0) + toggleTools.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  return (
66
  <aside className="glass-panel relative overflow-hidden rounded-[1.5rem] p-2.5 lg:flex lg:min-h-0 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)] lg:flex-col">
@@ -106,7 +148,7 @@ export function SourceSidebar({
106
  </span>
107
  </div>
108
  <p className="text-[11px] leading-[1.4] text-[var(--muted)]">
109
- Choose which sources the tutor searches.
110
  </p>
111
  </div>
112
 
@@ -118,8 +160,21 @@ export function SourceSidebar({
118
  onToggleSource={onToggleSource}
119
  />
120
  ) : null}
 
 
121
  {toggleTools.map((tool) => (
122
- <ToggleToolRow key={tool.key} tool={tool} />
 
 
 
 
 
 
 
 
 
 
 
123
  ))}
124
  </div>
125
 
@@ -160,6 +215,7 @@ function RetrievalTool({
160
  const [isOpen, setIsOpen] = useState(true);
161
  const courseSources = tool.sources.filter((source) => source.group === "courses");
162
  const docSources = tool.sources.filter((source) => source.group === "docs");
 
163
 
164
  return (
165
  <section className="space-y-1">
@@ -167,12 +223,34 @@ function RetrievalTool({
167
  type="button"
168
  onClick={() => setIsOpen((current) => !current)}
169
  aria-expanded={isOpen}
170
- className="flex w-full items-center gap-2 rounded-[0.75rem] px-2 py-1.5 text-left transition hover:bg-[var(--surface-soft)]"
 
 
 
 
 
 
171
  >
172
- <Library className="h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
173
- <span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold tracking-[-0.01em] text-[var(--ink)]">
 
 
 
 
 
174
  {tool.label}
175
  </span>
 
 
 
 
 
 
 
 
 
 
 
176
  <ChevronDown
177
  className={clsx(
178
  "h-3.5 w-3.5 shrink-0 text-[var(--muted)] transition-transform",
@@ -226,10 +304,13 @@ function SourceGroup({
226
  useEffect(() => {
227
  if (openPopoverKey === null) return;
228
  function onDocMouseDown(event: MouseEvent) {
229
- if (
230
- groupRef.current &&
231
- !groupRef.current.contains(event.target as Node)
232
- ) {
 
 
 
233
  setOpenPopoverKey(null);
234
  }
235
  }
@@ -325,9 +406,35 @@ function SourceRow({
325
  }) {
326
  const isCourse = source.group === "courses";
327
  const popover = getPopoverInfo(source);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
  return (
330
- <div className="relative">
331
  <div
332
  className={clsx(
333
  "flex items-center gap-1 rounded-[0.75rem] border py-1.5 pl-2 pr-1 transition",
@@ -380,61 +487,128 @@ function SourceRow({
380
  <span className="h-5 w-5 shrink-0" aria-hidden />
381
  )}
382
  </div>
383
- {popover && popoverOpen ? (
384
- <div
385
- role="dialog"
386
- className="absolute left-0 right-0 top-full z-20 mt-1 rounded-[0.9rem] border border-[var(--line-strong)] bg-[var(--surface-strong)] p-3 shadow-[0_12px_32px_rgba(0,0,0,0.18)] backdrop-blur-md"
387
- >
388
- <p className="text-[12px] leading-[1.45] text-[var(--ink)]">
389
- {popover.description}
390
- </p>
391
- {popover.meta ? (
392
- <p className="mt-1.5 text-[11px] font-medium tracking-[-0.005em] text-[var(--muted)]">
393
- {popover.meta}
394
- </p>
395
- ) : null}
396
- <a
397
- href={popover.linkUrl}
398
- target="_blank"
399
- rel="noreferrer"
400
- className="mt-2 inline-flex items-center gap-1 text-[11.5px] font-semibold text-[var(--accent)] hover:underline"
401
- >
402
- {popover.linkLabel}
403
- <ExternalLink className="h-3 w-3" />
404
- </a>
405
- </div>
406
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
407
  </div>
408
  );
409
  }
410
 
411
  function ToggleToolRow({
412
  tool,
 
 
 
 
413
  }: {
414
  tool: Extract<TutorTool, { kind: "toggle" }>;
 
 
 
 
415
  }) {
416
  const meta = TOGGLE_TOOL_META[tool.key];
417
  const Icon = meta?.icon ?? Globe;
 
418
  return (
419
- <div
420
- className="flex items-center gap-2 rounded-[0.9rem] border border-[var(--line)] bg-[var(--surface-subtle)] px-2 py-2"
421
- title={tool.active ? "Always on for this model" : "Unavailable"}
422
- >
423
- <Icon className="h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
424
- <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
425
- {tool.label}
426
- </span>
427
- <span
428
- aria-label={tool.active ? "on" : "off"}
429
  className={clsx(
430
- "inline-flex items-center rounded-full px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.1em]",
431
- tool.active
432
- ? "bg-[var(--accent-faint)] text-[var(--accent)]"
433
- : "bg-[var(--muted)]/15 text-[var(--muted)]",
434
  )}
435
  >
436
- {tool.active ? "on" : "off"}
437
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  </div>
439
  );
440
  }
 
15
  } from "lucide-react";
16
  import {
17
  useEffect,
18
+ useLayoutEffect,
19
  useRef,
20
  useState,
21
  type ComponentType,
22
  type SVGProps,
23
  } from "react";
24
+ import { createPortal } from "react-dom";
25
  import type { TutorSource, TutorTool } from "@/lib/api";
26
  import { COURSE_METADATA } from "@/lib/course-metadata";
27
  import { DOC_METADATA } from "@/lib/doc-metadata";
 
29
  type SourceSidebarProps = {
30
  onNewChat: () => void;
31
  onToggleSource: (sourceKey: string) => void;
32
+ onToggleTool: (toolKey: string) => void;
33
  selectedSourceKeys: string[];
34
+ enabledToolKeys: string[];
35
  sourceError: string | null;
36
  tools: TutorTool[];
37
  };
38
 
39
  type ToggleToolMeta = {
40
  icon: ComponentType<SVGProps<SVGSVGElement>>;
41
+ description?: string;
42
  };
43
 
44
  const TOGGLE_TOOL_META: Record<string, ToggleToolMeta> = {
45
+ web_search: {
46
+ icon: Globe,
47
+ description:
48
+ "Live web search for recent events or facts outside the course corpus.",
49
+ },
50
+ url_context: {
51
+ icon: LinkIcon,
52
+ description:
53
+ "Reads a specific URL you paste in the chat so the tutor can answer from its content.",
54
+ },
55
+ web_fetch: {
56
+ icon: LinkIcon,
57
+ description:
58
+ "Reads a specific URL you paste in the chat so the tutor can answer from its content.",
59
+ },
60
  };
61
 
62
  export function SourceSidebar({
63
  onNewChat,
64
  onToggleSource,
65
+ onToggleTool,
66
  selectedSourceKeys,
67
+ enabledToolKeys,
68
  sourceError,
69
  tools,
70
  }: SourceSidebarProps) {
 
78
  );
79
  const activeCount =
80
  (retrievalTool && selectedSourceKeys.length > 0 ? 1 : 0) +
81
+ toggleTools.filter((tool) => enabledToolKeys.includes(tool.key)).length;
82
  const totalCount = (retrievalTool ? 1 : 0) + toggleTools.length;
83
+ const [openToolInfoKey, setOpenToolInfoKey] = useState<string | null>(null);
84
+ const toggleToolsRef = useRef<HTMLDivElement>(null);
85
+
86
+ useEffect(() => {
87
+ if (openToolInfoKey === null) return;
88
+ function onDocMouseDown(event: MouseEvent) {
89
+ if (
90
+ toggleToolsRef.current &&
91
+ !toggleToolsRef.current.contains(event.target as Node)
92
+ ) {
93
+ setOpenToolInfoKey(null);
94
+ }
95
+ }
96
+ function onKey(event: KeyboardEvent) {
97
+ if (event.key === "Escape") setOpenToolInfoKey(null);
98
+ }
99
+ document.addEventListener("mousedown", onDocMouseDown);
100
+ document.addEventListener("keydown", onKey);
101
+ return () => {
102
+ document.removeEventListener("mousedown", onDocMouseDown);
103
+ document.removeEventListener("keydown", onKey);
104
+ };
105
+ }, [openToolInfoKey]);
106
 
107
  return (
108
  <aside className="glass-panel relative overflow-hidden rounded-[1.5rem] p-2.5 lg:flex lg:min-h-0 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)] lg:flex-col">
 
148
  </span>
149
  </div>
150
  <p className="text-[11px] leading-[1.4] text-[var(--muted)]">
151
+ Toggle tools, pick sources.
152
  </p>
153
  </div>
154
 
 
160
  onToggleSource={onToggleSource}
161
  />
162
  ) : null}
163
+ </div>
164
+ <div ref={toggleToolsRef} className="relative z-10 space-y-2 pr-0.5">
165
  {toggleTools.map((tool) => (
166
+ <ToggleToolRow
167
+ key={tool.key}
168
+ tool={tool}
169
+ enabled={enabledToolKeys.includes(tool.key)}
170
+ onToggle={() => onToggleTool(tool.key)}
171
+ infoOpen={openToolInfoKey === tool.key}
172
+ onInfoToggle={() =>
173
+ setOpenToolInfoKey((current) =>
174
+ current === tool.key ? null : tool.key,
175
+ )
176
+ }
177
+ />
178
  ))}
179
  </div>
180
 
 
215
  const [isOpen, setIsOpen] = useState(true);
216
  const courseSources = tool.sources.filter((source) => source.group === "courses");
217
  const docSources = tool.sources.filter((source) => source.group === "docs");
218
+ const enabled = selectedSourceKeys.length > 0;
219
 
220
  return (
221
  <section className="space-y-1">
 
223
  type="button"
224
  onClick={() => setIsOpen((current) => !current)}
225
  aria-expanded={isOpen}
226
+ title="Expand to pick sources. On when at least one source is selected."
227
+ className={clsx(
228
+ "flex w-full items-center gap-2 rounded-[0.9rem] border px-2 py-2 text-left transition",
229
+ enabled
230
+ ? "border-[var(--line-strong)] bg-[var(--accent-faint)] hover:border-[var(--accent)]"
231
+ : "border-[var(--line)] bg-[var(--surface-subtle)] hover:border-[var(--line-strong)]",
232
+ )}
233
  >
234
+ <Library
235
+ className={clsx(
236
+ "h-3.5 w-3.5 shrink-0",
237
+ enabled ? "text-[var(--accent)]" : "text-[var(--muted)]",
238
+ )}
239
+ />
240
+ <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
241
  {tool.label}
242
  </span>
243
+ <span
244
+ aria-hidden
245
+ className={clsx(
246
+ "inline-flex items-center rounded-full px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.1em]",
247
+ enabled
248
+ ? "bg-[var(--accent-faint)] text-[var(--accent)]"
249
+ : "bg-[var(--muted)]/15 text-[var(--muted)]",
250
+ )}
251
+ >
252
+ {enabled ? "on" : "off"}
253
+ </span>
254
  <ChevronDown
255
  className={clsx(
256
  "h-3.5 w-3.5 shrink-0 text-[var(--muted)] transition-transform",
 
304
  useEffect(() => {
305
  if (openPopoverKey === null) return;
306
  function onDocMouseDown(event: MouseEvent) {
307
+ const target = event.target as Node | null;
308
+ const insideGroup = !!groupRef.current && !!target && groupRef.current.contains(target);
309
+ const insidePortaledDialog =
310
+ !!target &&
311
+ target instanceof Element &&
312
+ !!target.closest('[data-source-popover="true"]');
313
+ if (!insideGroup && !insidePortaledDialog) {
314
  setOpenPopoverKey(null);
315
  }
316
  }
 
406
  }) {
407
  const isCourse = source.group === "courses";
408
  const popover = getPopoverInfo(source);
409
+ const rowRef = useRef<HTMLDivElement>(null);
410
+ const [dialogPos, setDialogPos] = useState<
411
+ { top: number; left: number; width: number } | null
412
+ >(null);
413
+
414
+ useLayoutEffect(() => {
415
+ if (!popoverOpen || !rowRef.current) {
416
+ setDialogPos(null);
417
+ return;
418
+ }
419
+ function recompute() {
420
+ if (!rowRef.current) return;
421
+ const row = rowRef.current.getBoundingClientRect();
422
+ const estimatedHeight = 140;
423
+ const spaceBelow = window.innerHeight - row.bottom;
424
+ const openAbove = spaceBelow < estimatedHeight + 16 && row.top > spaceBelow;
425
+ setDialogPos({
426
+ top: openAbove ? row.top - estimatedHeight - 4 : row.bottom + 4,
427
+ left: row.left,
428
+ width: row.width,
429
+ });
430
+ }
431
+ recompute();
432
+ window.addEventListener("resize", recompute);
433
+ return () => window.removeEventListener("resize", recompute);
434
+ }, [popoverOpen]);
435
 
436
  return (
437
+ <div ref={rowRef} className="relative">
438
  <div
439
  className={clsx(
440
  "flex items-center gap-1 rounded-[0.75rem] border py-1.5 pl-2 pr-1 transition",
 
487
  <span className="h-5 w-5 shrink-0" aria-hidden />
488
  )}
489
  </div>
490
+ {popover && popoverOpen && dialogPos && typeof document !== "undefined"
491
+ ? createPortal(
492
+ <div
493
+ role="dialog"
494
+ data-source-popover="true"
495
+ style={{
496
+ position: "fixed",
497
+ top: dialogPos.top,
498
+ left: dialogPos.left,
499
+ width: dialogPos.width,
500
+ zIndex: 50,
501
+ }}
502
+ className="rounded-[0.9rem] border border-[var(--line-strong)] bg-[var(--surface-strong)] p-3 shadow-[0_12px_32px_rgba(0,0,0,0.18)] backdrop-blur-md"
503
+ >
504
+ <p className="text-[12px] leading-[1.45] text-[var(--ink)]">
505
+ {popover.description}
506
+ </p>
507
+ {popover.meta ? (
508
+ <p className="mt-1.5 text-[11px] font-medium tracking-[-0.005em] text-[var(--muted)]">
509
+ {popover.meta}
510
+ </p>
511
+ ) : null}
512
+ <a
513
+ href={popover.linkUrl}
514
+ target="_blank"
515
+ rel="noreferrer"
516
+ className="mt-2 inline-flex items-center gap-1 text-[11.5px] font-semibold text-[var(--accent)] hover:underline"
517
+ >
518
+ {popover.linkLabel}
519
+ <ExternalLink className="h-3 w-3" />
520
+ </a>
521
+ </div>,
522
+ document.body,
523
+ )
524
+ : null}
525
  </div>
526
  );
527
  }
528
 
529
  function ToggleToolRow({
530
  tool,
531
+ enabled,
532
+ onToggle,
533
+ infoOpen,
534
+ onInfoToggle,
535
  }: {
536
  tool: Extract<TutorTool, { kind: "toggle" }>;
537
+ enabled: boolean;
538
+ onToggle: () => void;
539
+ infoOpen: boolean;
540
+ onInfoToggle: () => void;
541
  }) {
542
  const meta = TOGGLE_TOOL_META[tool.key];
543
  const Icon = meta?.icon ?? Globe;
544
+ const description = meta?.description;
545
  return (
546
+ <div className="relative">
547
+ <div
 
 
 
 
 
 
 
 
548
  className={clsx(
549
+ "flex items-center gap-1 rounded-[0.9rem] border pl-2 pr-1 py-2 transition",
550
+ enabled
551
+ ? "border-[var(--line-strong)] bg-[var(--accent-faint)]"
552
+ : "border-[var(--line)] bg-[var(--surface-subtle)]",
553
  )}
554
  >
555
+ <button
556
+ type="button"
557
+ onClick={onToggle}
558
+ aria-pressed={enabled}
559
+ title={enabled ? "Click to disable" : "Click to enable"}
560
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
561
+ >
562
+ <Icon
563
+ className={clsx(
564
+ "h-3.5 w-3.5 shrink-0",
565
+ enabled ? "text-[var(--accent)]" : "text-[var(--muted)]",
566
+ )}
567
+ />
568
+ <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
569
+ {tool.label}
570
+ </span>
571
+ <span
572
+ aria-hidden
573
+ className={clsx(
574
+ "inline-flex items-center rounded-full px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.1em]",
575
+ enabled
576
+ ? "bg-[var(--accent-faint)] text-[var(--accent)]"
577
+ : "bg-[var(--muted)]/15 text-[var(--muted)]",
578
+ )}
579
+ >
580
+ {enabled ? "on" : "off"}
581
+ </span>
582
+ </button>
583
+ {description ? (
584
+ <button
585
+ type="button"
586
+ onClick={onInfoToggle}
587
+ aria-label={`About ${tool.label}`}
588
+ aria-expanded={infoOpen}
589
+ className={clsx(
590
+ "flex h-5 w-5 shrink-0 items-center justify-center rounded-full transition",
591
+ enabled
592
+ ? "text-[var(--accent)] hover:bg-[var(--accent-soft)]"
593
+ : "text-[var(--muted)] hover:bg-[var(--surface-hover)] hover:text-[var(--ink)]",
594
+ )}
595
+ >
596
+ <Info className="h-3.5 w-3.5" />
597
+ </button>
598
+ ) : (
599
+ <span className="h-5 w-5 shrink-0" aria-hidden />
600
+ )}
601
+ </div>
602
+ {description && infoOpen ? (
603
+ <div
604
+ role="dialog"
605
+ className="absolute left-0 right-0 bottom-full z-20 mb-1 rounded-[0.9rem] border border-[var(--line-strong)] bg-[var(--surface-strong)] p-3 shadow-[0_12px_32px_rgba(0,0,0,0.18)] backdrop-blur-md"
606
+ >
607
+ <p className="text-[12px] leading-[1.45] text-[var(--ink)]">
608
+ {description}
609
+ </p>
610
+ </div>
611
+ ) : null}
612
  </div>
613
  );
614
  }
scripts/api.py CHANGED
@@ -45,6 +45,7 @@ class ApiChatRequest(BaseModel):
45
  history: list[ApiChatTurn] = Field(default_factory=list)
46
  messages: list[dict[str, Any]] | None = None
47
  sourceKeys: list[str] | None = None
 
48
  model: str | None = None
49
  includeReasoning: bool = True
50
  threadId: str = ""
@@ -147,13 +148,32 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
147
  key for key in requested_source_keys if key in allowed_source_keys
148
  )
149
  ) or DEFAULT_SELECTED_SOURCE_KEYS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return ChatRequest(
151
  query=query,
152
  history=history,
153
  source_keys=source_keys,
154
- model_name=(payload.model or DEFAULT_MODEL_NAME).strip(),
155
  include_reasoning=bool(payload.includeReasoning),
156
  thread_id=(payload.threadId or "").strip(),
 
157
  )
158
 
159
 
@@ -378,7 +398,7 @@ def _source_entries() -> list[dict[str, Any]]:
378
  def _tool_catalog(model_name: str) -> list[dict[str, Any]]:
379
  retrieval_tool: dict[str, Any] = {
380
  "key": "retrieval",
381
- "label": "Retrieval",
382
  "kind": "configurable",
383
  "active": True,
384
  "sources": _source_entries(),
 
45
  history: list[ApiChatTurn] = Field(default_factory=list)
46
  messages: list[dict[str, Any]] | None = None
47
  sourceKeys: list[str] | None = None
48
+ enabledTools: list[str] | None = None
49
  model: str | None = None
50
  includeReasoning: bool = True
51
  threadId: str = ""
 
148
  key for key in requested_source_keys if key in allowed_source_keys
149
  )
150
  ) or DEFAULT_SELECTED_SOURCE_KEYS
151
+ model_name = (payload.model or DEFAULT_MODEL_NAME).strip()
152
+ if payload.enabledTools is None:
153
+ enabled_tools = tuple(
154
+ tool["key"]
155
+ for tool in _tool_catalog(model_name)
156
+ if tool["kind"] == "toggle"
157
+ )
158
+ else:
159
+ allowed_tool_keys = {
160
+ tool["key"]
161
+ for tool in _tool_catalog(model_name)
162
+ if tool["kind"] == "toggle"
163
+ }
164
+ enabled_tools = tuple(
165
+ dict.fromkeys(
166
+ key for key in payload.enabledTools if key in allowed_tool_keys
167
+ )
168
+ )
169
  return ChatRequest(
170
  query=query,
171
  history=history,
172
  source_keys=source_keys,
173
+ model_name=model_name,
174
  include_reasoning=bool(payload.includeReasoning),
175
  thread_id=(payload.threadId or "").strip(),
176
+ enabled_tools=enabled_tools,
177
  )
178
 
179
 
 
398
  def _tool_catalog(model_name: str) -> list[dict[str, Any]]:
399
  retrieval_tool: dict[str, Any] = {
400
  "key": "retrieval",
401
+ "label": "Knowledge base",
402
  "kind": "configurable",
403
  "active": True,
404
  "sources": _source_entries(),
scripts/chat_service.py CHANGED
@@ -17,7 +17,7 @@ from langgraph.checkpoint.memory import InMemorySaver
17
 
18
  from .chat_types import ChatEvent, ChatRequest, ChatTurn, SourceMatch
19
  from .chroma_rag import LocalChromaRetriever, format_tool_payload, parse_tool_payload
20
- from .prompts import system_message_openai_agent
21
  from .setup import (
22
  DOCUMENT_DICT_PATH,
23
  SOURCE_KEY_TO_LABEL,
@@ -383,34 +383,44 @@ class GeminiServerSideToolsMiddleware(AgentMiddleware):
383
  return await handler(self._inject(request))
384
 
385
 
386
- @lru_cache(maxsize=8)
387
- def build_agent(model_name: str, include_thoughts: bool = False):
 
 
 
 
388
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
389
  tools: list[Any] = [retrieve_tutor_context]
390
  middleware: list[AgentMiddleware] = []
 
391
  if is_google_genai_model(model_name):
392
- tools.append({"google_search": {}})
393
- tools.append({"url_context": {}})
394
- middleware.append(GeminiServerSideToolsMiddleware())
 
 
 
395
  elif is_anthropic_model(model_name):
396
- tools.append(
397
- {
398
- "type": "web_search_20250305",
399
- "name": "web_search",
400
- "allowed_callers": ["direct"],
401
- }
402
- )
403
- tools.append(
404
- {
405
- "type": "web_fetch_20260209",
406
- "name": "web_fetch",
407
- "allowed_callers": ["direct"],
408
- }
409
- )
 
 
410
  return create_agent(
411
  model=model,
412
  tools=tools,
413
- system_prompt=system_message_openai_agent,
414
  context_schema=AppContext,
415
  checkpointer=CHECKPOINTER,
416
  middleware=middleware,
@@ -604,7 +614,11 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
604
  google_search_match_count = 0
605
 
606
  logfire.info("Running query", query=request.query)
607
- agent = build_agent(request.model_name, include_thoughts=include_reasoning)
 
 
 
 
608
  active_thread_id = sync_thread_with_history(
609
  agent,
610
  request.thread_id.strip() or new_thread_id(),
 
17
 
18
  from .chat_types import ChatEvent, ChatRequest, ChatTurn, SourceMatch
19
  from .chroma_rag import LocalChromaRetriever, format_tool_payload, parse_tool_payload
20
+ from .prompts import build_system_prompt
21
  from .setup import (
22
  DOCUMENT_DICT_PATH,
23
  SOURCE_KEY_TO_LABEL,
 
383
  return await handler(self._inject(request))
384
 
385
 
386
+ @lru_cache(maxsize=32)
387
+ def build_agent(
388
+ model_name: str,
389
+ enabled_tools: tuple[str, ...] = (),
390
+ include_thoughts: bool = False,
391
+ ):
392
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
393
  tools: list[Any] = [retrieve_tutor_context]
394
  middleware: list[AgentMiddleware] = []
395
+ enabled = set(enabled_tools)
396
  if is_google_genai_model(model_name):
397
+ if "web_search" in enabled:
398
+ tools.append({"google_search": {}})
399
+ if "url_context" in enabled:
400
+ tools.append({"url_context": {}})
401
+ if enabled & {"web_search", "url_context"}:
402
+ middleware.append(GeminiServerSideToolsMiddleware())
403
  elif is_anthropic_model(model_name):
404
+ if "web_search" in enabled:
405
+ tools.append(
406
+ {
407
+ "type": "web_search_20250305",
408
+ "name": "web_search",
409
+ "allowed_callers": ["direct"],
410
+ }
411
+ )
412
+ if "web_fetch" in enabled:
413
+ tools.append(
414
+ {
415
+ "type": "web_fetch_20260209",
416
+ "name": "web_fetch",
417
+ "allowed_callers": ["direct"],
418
+ }
419
+ )
420
  return create_agent(
421
  model=model,
422
  tools=tools,
423
+ system_prompt=build_system_prompt(model_name, enabled_tools),
424
  context_schema=AppContext,
425
  checkpointer=CHECKPOINTER,
426
  middleware=middleware,
 
614
  google_search_match_count = 0
615
 
616
  logfire.info("Running query", query=request.query)
617
+ agent = build_agent(
618
+ request.model_name,
619
+ enabled_tools=tuple(request.enabled_tools),
620
+ include_thoughts=include_reasoning,
621
+ )
622
  active_thread_id = sync_thread_with_history(
623
  agent,
624
  request.thread_id.strip() or new_thread_id(),
scripts/chat_types.py CHANGED
@@ -28,6 +28,7 @@ class ChatRequest:
28
  model_name: str = ""
29
  include_reasoning: bool = False
30
  thread_id: str = ""
 
31
 
32
 
33
  @dataclass(frozen=True, slots=True)
 
28
  model_name: str = ""
29
  include_reasoning: bool = False
30
  thread_id: str = ""
31
+ enabled_tools: tuple[str, ...] = ()
32
 
33
 
34
  @dataclass(frozen=True, slots=True)
scripts/prompts.py CHANGED
@@ -1,17 +1,69 @@
1
- system_message_openai_agent = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
2
-
3
- Your job is to answer student questions clearly and accurately. You have
4
- three tools available:
5
-
6
- - `retrieve_tutor_context` — retrieval over the course and documentation
7
- corpus. Use this for anything that depends on course content.
8
- - `google_search` (Gemini built-in, when available) — live web search for
9
- current events, recent library releases, or facts outside the corpus.
10
- - `url_context` (Gemini built-in, when available) — read a specific URL
11
- in depth when the user provides one or when a search result needs to be
12
- inspected closely.
13
-
14
- ## When to use the retrieval tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  USE retrieval for:
17
  - Questions about course content, concepts, code examples, or documentation.
@@ -20,24 +72,9 @@ USE retrieval for:
20
  DO NOT use retrieval for:
21
  - Greetings or small talk.
22
  - Questions about your own role or capabilities in this app (answer directly).
23
- - Questions you can answer fully from general knowledge with no corpus dependency.
24
-
25
- ## When to use web search / URL reading
26
-
27
- USE `google_search` for:
28
- - Questions about recent events, releases, or API changes that may post-date
29
- the corpus.
30
- - Facts the corpus likely does not cover (product pricing, news, etc.).
31
-
32
- USE `url_context` for:
33
- - Any URL the user pastes in their question.
34
- - A search result you need to read in detail to answer accurately.
35
-
36
- Prefer `retrieve_tutor_context` first when the question is clearly about
37
- course material. Combine tools when it helps (e.g. retrieve corpus context,
38
- then search the web for the latest update).
39
 
40
- ## How to call the retrieval tool
41
 
42
  You have two strategies. Pick one per turn:
43
 
@@ -55,9 +92,9 @@ You have two strategies. Pick one per turn:
55
  Start with one focused query. If the results are off-topic, too sparse,
56
  or miss a key aspect of the question, call the tool again with a
57
  refined query (different keywords, more specific, or targeting the gap).
58
- Stop after at most 2 sequential calls.
59
 
60
- ## Answering rules
61
 
62
  - Ground factual claims about the corpus in the retrieved results.
63
  - If retrieval results are weak or missing, say the topic is not well
@@ -69,5 +106,51 @@ You have two strategies. Pick one per turn:
69
  - Include complete, runnable code blocks when code is relevant.
70
  - End with a short invitation for a follow-up question.
71
 
72
- The retrieval tool returns JSON with matched passages and source metadata.
73
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ BASE_PROMPT_HEADER = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
5
+
6
+ Your job is to answer student questions clearly and accurately."""
7
+
8
+ RETRIEVAL_TOOL_LINE = (
9
+ "- `retrieve_tutor_context` — retrieval over the course and documentation\n"
10
+ " corpus. Use this for anything that depends on course content."
11
+ )
12
+
13
+ WEB_TOOL_LINES = {
14
+ "google-genai": {
15
+ "web_search": (
16
+ "- `google_search` (Gemini built-in) — live web search for current\n"
17
+ " events, recent library releases, or facts outside the corpus."
18
+ ),
19
+ "url_context": (
20
+ "- `url_context` (Gemini built-in) — read a specific URL in depth when\n"
21
+ " the user provides one or when a search result needs to be inspected\n"
22
+ " closely."
23
+ ),
24
+ },
25
+ "anthropic": {
26
+ "web_search": (
27
+ "- `web_search` (Claude built-in) — live web search for current events,\n"
28
+ " recent library releases, or facts outside the corpus."
29
+ ),
30
+ "web_fetch": (
31
+ "- `web_fetch` (Claude built-in) — read a specific URL in depth when the\n"
32
+ " user provides one or when a search result needs to be inspected closely."
33
+ ),
34
+ },
35
+ }
36
+
37
+ WEB_USAGE_SECTIONS = {
38
+ "google-genai": {
39
+ "web_search": (
40
+ "USE `google_search` for:\n"
41
+ "- Questions about recent events, releases, or API changes that may\n"
42
+ " post-date the corpus.\n"
43
+ "- Facts the corpus likely does not cover (product pricing, news, etc.)."
44
+ ),
45
+ "url_context": (
46
+ "USE `url_context` for:\n"
47
+ "- Any URL the user pastes in their question.\n"
48
+ "- A search result you need to read in detail to answer accurately."
49
+ ),
50
+ },
51
+ "anthropic": {
52
+ "web_search": (
53
+ "USE `web_search` for:\n"
54
+ "- Questions about recent events, releases, or API changes that may\n"
55
+ " post-date the corpus.\n"
56
+ "- Facts the corpus likely does not cover (product pricing, news, etc.)."
57
+ ),
58
+ "web_fetch": (
59
+ "USE `web_fetch` for:\n"
60
+ "- Any URL the user pastes in their question.\n"
61
+ "- A search result you need to read in detail to answer accurately."
62
+ ),
63
+ },
64
+ }
65
+
66
+ RETRIEVAL_USAGE_SECTION = """## When to use the retrieval tool
67
 
68
  USE retrieval for:
69
  - Questions about course content, concepts, code examples, or documentation.
 
72
  DO NOT use retrieval for:
73
  - Greetings or small talk.
74
  - Questions about your own role or capabilities in this app (answer directly).
75
+ - Questions you can answer fully from general knowledge with no corpus dependency."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
+ RETRIEVAL_CALL_STRATEGY = """## How to call the retrieval tool
78
 
79
  You have two strategies. Pick one per turn:
80
 
 
92
  Start with one focused query. If the results are off-topic, too sparse,
93
  or miss a key aspect of the question, call the tool again with a
94
  refined query (different keywords, more specific, or targeting the gap).
95
+ Stop after at most 2 sequential calls."""
96
 
97
+ ANSWERING_RULES = """## Answering rules
98
 
99
  - Ground factual claims about the corpus in the retrieved results.
100
  - If retrieval results are weak or missing, say the topic is not well
 
106
  - Include complete, runnable code blocks when code is relevant.
107
  - End with a short invitation for a follow-up question.
108
 
109
+ The retrieval tool returns JSON with matched passages and source metadata."""
110
+
111
+
112
+ def _provider_key(model_name: str) -> str:
113
+ normalized = (model_name or "").strip()
114
+ if ":" in normalized:
115
+ return normalized.split(":", 1)[0]
116
+ if normalized.startswith("gpt-"):
117
+ return "openai"
118
+ if normalized.startswith("claude"):
119
+ return "anthropic"
120
+ if normalized.startswith("gemini"):
121
+ return "google-genai"
122
+ return ""
123
+
124
+
125
+ def build_system_prompt(model_name: str, enabled_tools: tuple[str, ...]) -> str:
126
+ provider = _provider_key(model_name)
127
+ enabled = set(enabled_tools)
128
+ tool_lines = [RETRIEVAL_TOOL_LINE]
129
+ usage_sections: list[str] = []
130
+ provider_web_tools = WEB_TOOL_LINES.get(provider, {})
131
+ provider_web_usage = WEB_USAGE_SECTIONS.get(provider, {})
132
+ for key in ("web_search", "url_context", "web_fetch"):
133
+ if key in enabled and key in provider_web_tools:
134
+ tool_lines.append(provider_web_tools[key])
135
+ usage_sections.append(provider_web_usage[key])
136
+
137
+ if len(tool_lines) == 1:
138
+ intro = "You have one tool available:"
139
+ else:
140
+ intro = f"You have {len(tool_lines)} tools available:"
141
+
142
+ parts = [
143
+ BASE_PROMPT_HEADER,
144
+ f"{intro}\n\n" + "\n".join(tool_lines),
145
+ RETRIEVAL_USAGE_SECTION,
146
+ ]
147
+ if usage_sections:
148
+ parts.append("## When to use web search / URL reading\n\n" + "\n\n".join(usage_sections))
149
+ parts.append(
150
+ "Prefer `retrieve_tutor_context` first when the question is clearly about\n"
151
+ "course material. Combine tools when it helps (e.g. retrieve corpus\n"
152
+ "context, then search the web for the latest update)."
153
+ )
154
+ parts.append(RETRIEVAL_CALL_STRATEGY)
155
+ parts.append(ANSWERING_RULES)
156
+ return "\n\n".join(parts) + "\n"
tests/test_api.py CHANGED
@@ -48,7 +48,7 @@ class ApiTestCase(unittest.TestCase):
48
  self.assertIn("web_search", tool_keys)
49
  self.assertIn("url_context", tool_keys)
50
 
51
- def test_list_tools_for_non_gemini_model(self) -> None:
52
  with TestClient(app) as client:
53
  response = client.get(
54
  "/api/tools", params={"model": "anthropic:claude-sonnet-4-6"}
@@ -57,7 +57,8 @@ class ApiTestCase(unittest.TestCase):
57
  self.assertEqual(response.status_code, 200)
58
  tool_keys = {tool["key"] for tool in response.json()["tools"]}
59
  self.assertIn("retrieval", tool_keys)
60
- self.assertNotIn("web_search", tool_keys)
 
61
  self.assertNotIn("url_context", tool_keys)
62
 
63
  def test_chat_stream_returns_ai_sdk_parts(self) -> None:
@@ -66,6 +67,7 @@ class ApiTestCase(unittest.TestCase):
66
  self.assertEqual(request.history[0].role, "assistant")
67
  self.assertEqual(request.history[0].content, "Previous answer")
68
  self.assertEqual(request.source_keys, ("langchain", "transformers"))
 
69
  self.assertEqual(request.thread_id, "thread_0")
70
  yield ChatEvent("thread_started", {"thread_id": "thread_1"})
71
  yield ChatEvent("message_started", {"message_id": "message_1"})
@@ -127,6 +129,7 @@ class ApiTestCase(unittest.TestCase):
127
  },
128
  ],
129
  "sourceKeys": ["langchain", "transformers"],
 
130
  "threadId": "thread_0",
131
  }
132
 
 
48
  self.assertIn("web_search", tool_keys)
49
  self.assertIn("url_context", tool_keys)
50
 
51
+ def test_list_tools_for_anthropic_model(self) -> None:
52
  with TestClient(app) as client:
53
  response = client.get(
54
  "/api/tools", params={"model": "anthropic:claude-sonnet-4-6"}
 
57
  self.assertEqual(response.status_code, 200)
58
  tool_keys = {tool["key"] for tool in response.json()["tools"]}
59
  self.assertIn("retrieval", tool_keys)
60
+ self.assertIn("web_search", tool_keys)
61
+ self.assertIn("web_fetch", tool_keys)
62
  self.assertNotIn("url_context", tool_keys)
63
 
64
  def test_chat_stream_returns_ai_sdk_parts(self) -> None:
 
67
  self.assertEqual(request.history[0].role, "assistant")
68
  self.assertEqual(request.history[0].content, "Previous answer")
69
  self.assertEqual(request.source_keys, ("langchain", "transformers"))
70
+ self.assertEqual(request.enabled_tools, ("web_search",))
71
  self.assertEqual(request.thread_id, "thread_0")
72
  yield ChatEvent("thread_started", {"thread_id": "thread_1"})
73
  yield ChatEvent("message_started", {"message_id": "message_1"})
 
129
  },
130
  ],
131
  "sourceKeys": ["langchain", "transformers"],
132
+ "enabledTools": ["web_search", "not_a_real_tool"],
133
  "threadId": "thread_0",
134
  }
135