omarsol commited on
Commit
839314b
·
1 Parent(s): f2e8e50

Enhance ESLint configuration and chat components

Browse files

- Updated ESLint configuration to allow unused variable patterns prefixed with "_" for better code clarity.
- Refactored ChatMessage component to integrate citation resolutions, improving citation handling in messages.
- Enhanced MarkdownBlock to resolve citation URLs dynamically, ensuring accurate linking in rendered content.
- Updated SourceSidebar to streamline source information display, removing deprecated metadata references.
- Improved API types to include short labels and descriptions for sources, enhancing data representation.

frontend/components/chat-message.tsx CHANGED
@@ -38,9 +38,11 @@ import type {
38
  import {
39
  buildActivityItems,
40
  buildCitationNumbers,
 
41
  citationNumberFor,
42
  getMessageCitations,
43
  getOrderedMessageBlocks,
 
44
  prettifyToolName,
45
  toolInputSummary,
46
  } from "@/lib/chat-ui";
@@ -79,7 +81,12 @@ export function ChatMessage({
79
  const isAssistant = message.role === "assistant";
80
  const contentBlocks = getOrderedMessageBlocks(message);
81
  const citations = isAssistant && !isStreaming ? getMessageCitations(message) : [];
82
- const citationNumbers = isAssistant ? buildCitationNumbers(message) : undefined;
 
 
 
 
 
83
  const editTextareaRef = useRef<HTMLTextAreaElement>(null);
84
 
85
  useEffect(() => {
@@ -149,6 +156,7 @@ export function ChatMessage({
149
  block={block}
150
  isActive={isActive}
151
  citationNumbers={citationNumbers}
 
152
  />
153
  );
154
  })}
@@ -290,15 +298,23 @@ function ContentBlock({
290
  block,
291
  isActive = false,
292
  citationNumbers,
 
293
  }: {
294
  block: TutorMessageBlock;
295
  isActive?: boolean;
296
  citationNumbers?: Map<string, number>;
 
297
  }) {
298
  if (block.kind === "activity") {
299
  return <ActivityPanel parts={block.parts} isActive={isActive} />;
300
  }
301
- return <TextBlock parts={block.parts} citationNumbers={citationNumbers} />;
 
 
 
 
 
 
302
  }
303
 
304
  function ActivityPanel({
@@ -323,9 +339,11 @@ function ActivityPanel({
323
  );
324
  const sourceCount = getToolPartSourceCount(parts);
325
 
 
 
 
326
  const liveTool = isActive
327
  ? toolItems.find((item) => item.part.state !== "output-available")
328
- ?? toolItems[toolItems.length - 1]
329
  : undefined;
330
 
331
  const summary = buildActivitySummary({
@@ -615,9 +633,11 @@ function formatToolResultSummary({
615
  function TextBlock({
616
  parts,
617
  citationNumbers,
 
618
  }: {
619
  parts: TutorMessagePart[];
620
  citationNumbers?: Map<string, number>;
 
621
  }) {
622
  return (
623
  <div className="space-y-3 text-[15px] leading-[1.72] tracking-[-0.012em] text-[var(--ink)]">
@@ -626,6 +646,7 @@ function TextBlock({
626
  key={`text-${index}`}
627
  className="text-[15px] leading-[1.72] tracking-[-0.012em] text-[var(--ink)]"
628
  citationNumbers={citationNumbers}
 
629
  >
630
  {part.text ?? ""}
631
  </MarkdownBlock>
@@ -690,15 +711,12 @@ function CitationRow({
690
  const meta = CITATION_KIND_META[citation.kind];
691
  const Icon = meta.icon;
692
  const number = citationNumberFor(citationNumbers, citation.url);
693
- return (
694
- <a
695
- key={`${citation.kind}-${citation.url}-${index}`}
696
- href={citation.url}
697
- target="_blank"
698
- rel="noreferrer"
699
- title={`${meta.label}${citation.sublabel ? ` · ${citation.sublabel}` : ""}`}
700
- className="group inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--line-strong)] bg-[var(--accent-faint)] px-2.5 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:border-[var(--accent)] hover:bg-[var(--surface-strong)]"
701
- >
702
  {number !== undefined ? (
703
  <span className="shrink-0 rounded-full bg-[var(--surface)] px-1.5 text-[10px] font-semibold text-[var(--accent)]">
704
  {number}
@@ -706,7 +724,32 @@ function CitationRow({
706
  ) : null}
707
  <Icon className="h-3.5 w-3.5 shrink-0" />
708
  <span className="truncate">{citation.label}</span>
709
- <ExternalLink className="h-3 w-3 shrink-0 opacity-60 transition group-hover:opacity-100" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  </a>
711
  );
712
  })}
 
38
  import {
39
  buildActivityItems,
40
  buildCitationNumbers,
41
+ buildCitationResolutions,
42
  citationNumberFor,
43
  getMessageCitations,
44
  getOrderedMessageBlocks,
45
+ isHttpUrl,
46
  prettifyToolName,
47
  toolInputSummary,
48
  } from "@/lib/chat-ui";
 
81
  const isAssistant = message.role === "assistant";
82
  const contentBlocks = getOrderedMessageBlocks(message);
83
  const citations = isAssistant && !isStreaming ? getMessageCitations(message) : [];
84
+ const citationResolutions = isAssistant
85
+ ? buildCitationResolutions(message)
86
+ : undefined;
87
+ const citationNumbers = isAssistant
88
+ ? buildCitationNumbers(message, citationResolutions)
89
+ : undefined;
90
  const editTextareaRef = useRef<HTMLTextAreaElement>(null);
91
 
92
  useEffect(() => {
 
156
  block={block}
157
  isActive={isActive}
158
  citationNumbers={citationNumbers}
159
+ citationResolutions={citationResolutions}
160
  />
161
  );
162
  })}
 
298
  block,
299
  isActive = false,
300
  citationNumbers,
301
+ citationResolutions,
302
  }: {
303
  block: TutorMessageBlock;
304
  isActive?: boolean;
305
  citationNumbers?: Map<string, number>;
306
+ citationResolutions?: Map<string, string>;
307
  }) {
308
  if (block.kind === "activity") {
309
  return <ActivityPanel parts={block.parts} isActive={isActive} />;
310
  }
311
+ return (
312
+ <TextBlock
313
+ parts={block.parts}
314
+ citationNumbers={citationNumbers}
315
+ citationResolutions={citationResolutions}
316
+ />
317
+ );
318
  }
319
 
320
  function ActivityPanel({
 
339
  );
340
  const sourceCount = getToolPartSourceCount(parts);
341
 
342
+ // Only a genuinely unfinished tool earns a "Running ..." header; when all
343
+ // calls have returned (e.g. the model is reasoning after its last tool),
344
+ // fall through to the generic activity summary.
345
  const liveTool = isActive
346
  ? toolItems.find((item) => item.part.state !== "output-available")
 
347
  : undefined;
348
 
349
  const summary = buildActivitySummary({
 
633
  function TextBlock({
634
  parts,
635
  citationNumbers,
636
+ citationResolutions,
637
  }: {
638
  parts: TutorMessagePart[];
639
  citationNumbers?: Map<string, number>;
640
+ citationResolutions?: Map<string, string>;
641
  }) {
642
  return (
643
  <div className="space-y-3 text-[15px] leading-[1.72] tracking-[-0.012em] text-[var(--ink)]">
 
646
  key={`text-${index}`}
647
  className="text-[15px] leading-[1.72] tracking-[-0.012em] text-[var(--ink)]"
648
  citationNumbers={citationNumbers}
649
+ citationResolutions={citationResolutions}
650
  >
651
  {part.text ?? ""}
652
  </MarkdownBlock>
 
711
  const meta = CITATION_KIND_META[citation.kind];
712
  const Icon = meta.icon;
713
  const number = citationNumberFor(citationNumbers, citation.url);
714
+ // Server-resolved URLs are trusted data but external input
715
+ // (corpus frontmatter, web results, manifest path fallbacks);
716
+ // only http(s) gets a navigable anchor.
717
+ const navigable = isHttpUrl(citation.url);
718
+ const cardChildren = (
719
+ <>
 
 
 
720
  {number !== undefined ? (
721
  <span className="shrink-0 rounded-full bg-[var(--surface)] px-1.5 text-[10px] font-semibold text-[var(--accent)]">
722
  {number}
 
724
  ) : null}
725
  <Icon className="h-3.5 w-3.5 shrink-0" />
726
  <span className="truncate">{citation.label}</span>
727
+ {navigable ? (
728
+ <ExternalLink className="h-3 w-3 shrink-0 opacity-60 transition group-hover:opacity-100" />
729
+ ) : null}
730
+ </>
731
+ );
732
+ const cardKey = `${citation.kind}-${citation.url}-${index}`;
733
+ const cardTitle = `${meta.label}${citation.sublabel ? ` · ${citation.sublabel}` : ""}`;
734
+ const cardClassName =
735
+ "group inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--line-strong)] bg-[var(--accent-faint)] px-2.5 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:border-[var(--accent)] hover:bg-[var(--surface-strong)]";
736
+ if (!navigable) {
737
+ return (
738
+ <span key={cardKey} title={cardTitle} className={cardClassName}>
739
+ {cardChildren}
740
+ </span>
741
+ );
742
+ }
743
+ return (
744
+ <a
745
+ key={cardKey}
746
+ href={citation.url}
747
+ target="_blank"
748
+ rel="noreferrer"
749
+ title={cardTitle}
750
+ className={cardClassName}
751
+ >
752
+ {cardChildren}
753
  </a>
754
  );
755
  })}
frontend/components/chat-shell.tsx CHANGED
@@ -9,7 +9,6 @@ import {
9
  useLayoutEffect,
10
  useRef,
11
  useState,
12
- useTransition,
13
  } from "react";
14
  import { ChatMessage } from "@/components/chat-message";
15
  import { SourceSidebar } from "@/components/source-sidebar";
@@ -76,7 +75,6 @@ export function ChatShell() {
76
  const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
77
  const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
78
  const [editingText, setEditingText] = useState("");
79
- const [, startTransition] = useTransition();
80
  const threadViewportRef = useRef<HTMLDivElement>(null);
81
  const composerInputRef = useRef<HTMLTextAreaElement>(null);
82
  const pendingScrollRef = useRef(false);
@@ -97,7 +95,10 @@ export function ChatShell() {
97
  if (part.type === "data-thread") {
98
  const nextThreadId = (part as ThreadDataPart).data.threadId;
99
  if (nextThreadId) {
100
- startTransition(() => setThreadId(nextThreadId));
 
 
 
101
  }
102
  }
103
  },
@@ -190,7 +191,10 @@ export function ChatShell() {
190
  }, [selectedModel]);
191
 
192
  const isStreaming = status === "submitted" || status === "streaming";
193
- const isReady = status === "ready";
 
 
 
194
  const typedMessages = messages as TutorMessage[];
195
  const latestMessage = typedMessages[typedMessages.length - 1];
196
  const streamingAssistantId =
@@ -495,7 +499,7 @@ export function ChatShell() {
495
  selectedModel={selectedModel}
496
  />
497
  <ComposerActionButton
498
- disabled={!isStreaming && (!input.trim() || !isReady)}
499
  isStreaming={isStreaming}
500
  streamingLabel={streamingWord}
501
  onClick={
 
9
  useLayoutEffect,
10
  useRef,
11
  useState,
 
12
  } from "react";
13
  import { ChatMessage } from "@/components/chat-message";
14
  import { SourceSidebar } from "@/components/source-sidebar";
 
75
  const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
76
  const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
77
  const [editingText, setEditingText] = useState("");
 
78
  const threadViewportRef = useRef<HTMLDivElement>(null);
79
  const composerInputRef = useRef<HTMLTextAreaElement>(null);
80
  const pendingScrollRef = useRef(false);
 
95
  if (part.type === "data-thread") {
96
  const nextThreadId = (part as ThreadDataPart).data.threadId;
97
  if (nextThreadId) {
98
+ // Urgent update on purpose: deferring it (startTransition) lets a
99
+ // concurrent "New chat" reset finish first and then be overwritten
100
+ // by the stale thread id.
101
+ setThreadId(nextThreadId);
102
  }
103
  }
104
  },
 
191
  }, [selectedModel]);
192
 
193
  const isStreaming = status === "submitted" || status === "streaming";
194
+ // After a stream error the SDK parks status on "error" until clearError()
195
+ // or the next request; handleSubmit clears it, so the error state must
196
+ // stay sendable or the Send button locks out mouse users for good.
197
+ const canSend = status === "ready" || status === "error";
198
  const typedMessages = messages as TutorMessage[];
199
  const latestMessage = typedMessages[typedMessages.length - 1];
200
  const streamingAssistantId =
 
499
  selectedModel={selectedModel}
500
  />
501
  <ComposerActionButton
502
+ disabled={!isStreaming && (!input.trim() || !canSend)}
503
  isStreaming={isStreaming}
504
  streamingLabel={streamingWord}
505
  onClick={
frontend/components/markdown-block.tsx CHANGED
@@ -1,13 +1,18 @@
1
  import type { ReactNode } from "react";
2
- import ReactMarkdown from "react-markdown";
3
  import rehypeHighlight from "rehype-highlight";
4
  import remarkGfm from "remark-gfm";
5
- import { citationNumberFor } from "@/lib/chat-ui";
 
 
 
 
6
 
7
  type MarkdownBlockProps = {
8
  children: string;
9
  className?: string;
10
  citationNumbers?: Map<string, number>;
 
11
  };
12
 
13
  // Above this length the link text is a claim the model wrapped in a link, not
@@ -33,14 +38,21 @@ export function MarkdownBlock({
33
  children,
34
  className = "",
35
  citationNumbers,
 
36
  }: MarkdownBlockProps) {
37
  return (
38
  <div className={`markdown-block ${className}`.trim()}>
39
  <ReactMarkdown
40
  remarkPlugins={[remarkGfm]}
41
  rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
 
 
 
 
 
 
42
  components={{
43
- a: ({ children: linkChildren, href, ...props }) => {
44
  const number = citationNumberFor(citationNumbers, href);
45
  if (number !== undefined) {
46
  const label = nodeToText(linkChildren).trim();
@@ -60,6 +72,12 @@ export function MarkdownBlock({
60
  </>
61
  );
62
  }
 
 
 
 
 
 
63
  return (
64
  <a {...props} href={href} target="_blank" rel="noreferrer">
65
  {linkChildren}
 
1
  import type { ReactNode } from "react";
2
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
3
  import rehypeHighlight from "rehype-highlight";
4
  import remarkGfm from "remark-gfm";
5
+ import {
6
+ citationNumberFor,
7
+ isHttpUrl,
8
+ resolveCitationHref,
9
+ } from "@/lib/chat-ui";
10
 
11
  type MarkdownBlockProps = {
12
  children: string;
13
  className?: string;
14
  citationNumbers?: Map<string, number>;
15
+ citationResolutions?: Map<string, string>;
16
  };
17
 
18
  // Above this length the link text is a claim the model wrapped in a link, not
 
38
  children,
39
  className = "",
40
  citationNumbers,
41
+ citationResolutions,
42
  }: MarkdownBlockProps) {
43
  return (
44
  <div className={`markdown-block ${className}`.trim()}>
45
  <ReactMarkdown
46
  remarkPlugins={[remarkGfm]}
47
  rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
48
+ // KB references (kb://doc/<id>, raw/... paths) must resolve before the
49
+ // default transform runs: it blanks the kb:// scheme and would leave
50
+ // raw paths as relative hrefs into the app's own origin.
51
+ urlTransform={(url) =>
52
+ resolveCitationHref(url, citationResolutions) ?? defaultUrlTransform(url)
53
+ }
54
  components={{
55
+ a: ({ children: linkChildren, href, node: _node, ...props }) => {
56
  const number = citationNumberFor(citationNumbers, href);
57
  if (number !== undefined) {
58
  const label = nodeToText(linkChildren).trim();
 
72
  </>
73
  );
74
  }
75
+ // An unresolved KB reference arrives here with an empty (kb://
76
+ // blanked by the transform) or relative (raw/...) href; a real
77
+ // anchor would open the app's own origin in a new tab.
78
+ if (!isHttpUrl(href) && !href?.startsWith("mailto:")) {
79
+ return <span>{linkChildren}</span>;
80
+ }
81
  return (
82
  <a {...props} href={href} target="_blank" rel="noreferrer">
83
  {linkChildren}
frontend/components/source-sidebar.tsx CHANGED
@@ -23,8 +23,6 @@ import {
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";
28
 
29
  type SourceSidebarProps = {
30
  onNewChat: () => void;
@@ -486,23 +484,22 @@ function formatIndexedMonth(isoDate: string | null | undefined): string | null {
486
  }
487
 
488
  function getPopoverInfo(source: TutorSource): PopoverInfo | undefined {
 
 
 
489
  if (source.group === "courses") {
490
- const meta = COURSE_METADATA[source.key];
491
- if (!meta) return undefined;
492
  return {
493
- description: meta.description,
494
- linkUrl: meta.academyUrl,
495
  linkLabel: "View on Academy",
496
  };
497
  }
498
- const meta = DOC_METADATA[source.key];
499
- if (!meta) return undefined;
500
  const indexedMonth = formatIndexedMonth(source.indexedAt);
501
  const metaParts = [source.version, indexedMonth ? `indexed ${indexedMonth}` : null]
502
  .filter((part): part is string => Boolean(part));
503
  return {
504
- description: meta.description,
505
- linkUrl: meta.docsUrl,
506
  linkLabel: "View docs",
507
  meta: metaParts.length > 0 ? metaParts.join(" · ") : undefined,
508
  };
@@ -565,9 +562,11 @@ function SourceRow({
565
  <button
566
  type="button"
567
  onClick={() => onToggle(source.key)}
 
568
  className="flex min-w-0 flex-1 items-center gap-2 text-left"
569
  >
570
  <span
 
571
  className={clsx(
572
  "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[9px] font-bold",
573
  selected
@@ -580,7 +579,7 @@ function SourceRow({
580
  ✓
581
  </span>
582
  <span className="min-w-0 truncate text-[12.5px] font-medium tracking-[-0.01em]">
583
- {formatSourceLabel(source.label)}
584
  </span>
585
  </button>
586
  {popover ? (
@@ -781,7 +780,3 @@ function ToggleToolRow({
781
  </div>
782
  );
783
  }
784
-
785
- function formatSourceLabel(label: string) {
786
- return label.replace(/\s+Docs$/, "");
787
- }
 
23
  } from "react";
24
  import { createPortal } from "react-dom";
25
  import type { TutorSource, TutorTool } from "@/lib/api";
 
 
26
 
27
  type SourceSidebarProps = {
28
  onNewChat: () => void;
 
484
  }
485
 
486
  function getPopoverInfo(source: TutorSource): PopoverInfo | undefined {
487
+ // Description and link come from the registry via /api/tools; the frontend
488
+ // renders them verbatim so adding a source needs no UI edit.
489
+ if (!source.description || !source.infoUrl) return undefined;
490
  if (source.group === "courses") {
 
 
491
  return {
492
+ description: source.description,
493
+ linkUrl: source.infoUrl,
494
  linkLabel: "View on Academy",
495
  };
496
  }
 
 
497
  const indexedMonth = formatIndexedMonth(source.indexedAt);
498
  const metaParts = [source.version, indexedMonth ? `indexed ${indexedMonth}` : null]
499
  .filter((part): part is string => Boolean(part));
500
  return {
501
+ description: source.description,
502
+ linkUrl: source.infoUrl,
503
  linkLabel: "View docs",
504
  meta: metaParts.length > 0 ? metaParts.join(" · ") : undefined,
505
  };
 
562
  <button
563
  type="button"
564
  onClick={() => onToggle(source.key)}
565
+ aria-pressed={selected}
566
  className="flex min-w-0 flex-1 items-center gap-2 text-left"
567
  >
568
  <span
569
+ aria-hidden="true"
570
  className={clsx(
571
  "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[9px] font-bold",
572
  selected
 
579
  ✓
580
  </span>
581
  <span className="min-w-0 truncate text-[12.5px] font-medium tracking-[-0.01em]">
582
+ {source.shortLabel || source.label}
583
  </span>
584
  </button>
585
  {popover ? (
 
780
  </div>
781
  );
782
  }
 
 
 
 
frontend/eslint.config.mjs CHANGED
@@ -1,6 +1,23 @@
1
  import nextVitals from "eslint-config-next/core-web-vitals";
2
  import nextTypescript from "eslint-config-next/typescript";
3
 
4
- const eslintConfig = [...nextVitals, ...nextTypescript];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  export default eslintConfig;
 
1
  import nextVitals from "eslint-config-next/core-web-vitals";
2
  import nextTypescript from "eslint-config-next/typescript";
3
 
4
+ const eslintConfig = [
5
+ ...nextVitals,
6
+ ...nextTypescript,
7
+ {
8
+ rules: {
9
+ // Allow the standard "intentionally unused" idioms: _-prefixed
10
+ // bindings and siblings dropped via rest destructuring.
11
+ "@typescript-eslint/no-unused-vars": [
12
+ "warn",
13
+ {
14
+ argsIgnorePattern: "^_",
15
+ varsIgnorePattern: "^_",
16
+ ignoreRestSiblings: true,
17
+ },
18
+ ],
19
+ },
20
+ },
21
+ ];
22
 
23
  export default eslintConfig;
frontend/lib/api.ts CHANGED
@@ -1,6 +1,9 @@
1
  export type TutorSource = {
2
  key: string;
3
  label: string;
 
 
 
4
  group: "courses" | "docs";
5
  selectedByDefault: boolean;
6
  version?: string | null;
@@ -43,6 +46,8 @@ export type SourcePartData = {
43
  sourceLabel: string;
44
  score: number;
45
  group: string;
 
 
46
  };
47
 
48
  const FALLBACK_API_BASE_URL = "http://127.0.0.1:8000";
 
1
  export type TutorSource = {
2
  key: string;
3
  label: string;
4
+ shortLabel: string;
5
+ description?: string | null;
6
+ infoUrl?: string | null;
7
  group: "courses" | "docs";
8
  selectedByDefault: boolean;
9
  version?: string | null;
 
46
  sourceLabel: string;
47
  score: number;
48
  group: string;
49
+ // KB-root-relative file path ("raw/docs/...") when the source is a KB file.
50
+ path?: string;
51
  };
52
 
53
  const FALLBACK_API_BASE_URL = "http://127.0.0.1:8000";
frontend/lib/chat-ui.ts CHANGED
@@ -180,29 +180,159 @@ export function getMessageCitations(message: TutorMessage): MessageCitation[] {
180
  return citations;
181
  }
182
 
183
- const MARKDOWN_LINK_PATTERN = /(!?)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
184
-
185
  function normalizeCitationUrl(url: string) {
186
- // Mirror the server's normalize_url so inline links and data-source parts
187
- // resolve to the same key.
188
- return url.replace(/#.*$/, "").replace(/\/+$/, "");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
 
191
  // Number the citation links in a message's answer text by order of first
192
- // appearance, deduped by normalized URL. The same map drives the inline
193
- // citation chips and the numbers on the sources row.
194
- export function buildCitationNumbers(message: TutorMessage): Map<string, number> {
 
 
 
 
 
 
195
  const numbers = new Map<string, number>();
196
  for (const part of message.parts) {
197
  if (!("type" in part) || part.type !== "text") {
198
  continue;
199
  }
200
  const text = (part as TutorMessagePart).text ?? "";
201
- for (const match of text.matchAll(MARKDOWN_LINK_PATTERN)) {
202
- if (match[1] === "!") {
 
203
  continue;
204
  }
205
- const key = normalizeCitationUrl(match[3] ?? "");
206
  if (key && !numbers.has(key)) {
207
  numbers.set(key, numbers.size + 1);
208
  }
 
180
  return citations;
181
  }
182
 
 
 
183
  function normalizeCitationUrl(url: string) {
184
+ // Mirror the server's normalize_url (drop fragment and trailing slash),
185
+ // then canonicalize percent-encoding: react-markdown renders hrefs through
186
+ // micromark's normalizeUri, so the rendered href may be an encoded variant
187
+ // of the raw answer text. Decode-then-encode maps both to one form (it is
188
+ // idempotent across that encoding); URLs that fail to decode stay as-is.
189
+ const base = url.replace(/#.*$/, "").replace(/\/+$/, "");
190
+ try {
191
+ return encodeURI(decodeURI(base));
192
+ } catch {
193
+ return base;
194
+ }
195
+ }
196
+
197
+ // Drop fenced code blocks and inline code spans before scanning for links:
198
+ // rendered code never produces anchors, so a markdown link inside code must
199
+ // not consume a citation number. The $ alternates keep a streaming,
200
+ // not-yet-closed fence from leaking back in.
201
+ function stripCodeSegments(text: string) {
202
+ return text
203
+ .replace(/```[\s\S]*?(?:```|$)/g, " ")
204
+ .replace(/~~~[\s\S]*?(?:~~~|$)/g, " ")
205
+ .replace(/`[^`\n]*`/g, " ");
206
+ }
207
+
208
+ // micromark parses balanced parens inside a link destination, so a regex that
209
+ // stops at the first ")" truncates URLs like .../Spring_(framework). Scan the
210
+ // destination the way the parser does: <...> form, or up to whitespace / the
211
+ // first unbalanced closing paren.
212
+ function scanLinkDestination(text: string, start: number): string {
213
+ let index = start;
214
+ while (index < text.length && /\s/.test(text[index])) {
215
+ index += 1;
216
+ }
217
+ if (text[index] === "<") {
218
+ const end = text.indexOf(">", index + 1);
219
+ return end === -1 ? "" : text.slice(index + 1, end);
220
+ }
221
+ let depth = 0;
222
+ let end = index;
223
+ while (end < text.length) {
224
+ const char = text[end];
225
+ if (/\s/.test(char)) {
226
+ break;
227
+ }
228
+ if (char === "(") {
229
+ depth += 1;
230
+ } else if (char === ")") {
231
+ if (depth === 0) {
232
+ break;
233
+ }
234
+ depth -= 1;
235
+ }
236
+ end += 1;
237
+ }
238
+ return text.slice(index, end);
239
+ }
240
+
241
+ const LINK_OPENER_PATTERN = /(!?)\[[^\]]*\]\(/g;
242
+
243
+ function extractMarkdownLinkUrls(text: string): string[] {
244
+ const urls: string[] = [];
245
+ const scannable = stripCodeSegments(text);
246
+ for (const match of scannable.matchAll(LINK_OPENER_PATTERN)) {
247
+ if (match[1] === "!") {
248
+ continue;
249
+ }
250
+ const url = scanLinkDestination(scannable, match.index + match[0].length);
251
+ if (url) {
252
+ urls.push(url);
253
+ }
254
+ }
255
+ return urls;
256
+ }
257
+
258
+ export function isHttpUrl(url: string | undefined): boolean {
259
+ return Boolean(url && /^https?:\/\//i.test(url.trim()));
260
+ }
261
+
262
+ // The system prompt lets the model cite KB-browsed files as kb://doc/<id> or
263
+ // KB-root paths like raw/docs/peft/lora.md; normalize the path variants the
264
+ // model may produce to the KB-root-relative form the server sends.
265
+ function normalizeKbPath(ref: string) {
266
+ let value = ref.trim();
267
+ if (value.startsWith("./")) {
268
+ value = value.slice(2);
269
+ }
270
+ if (value.startsWith("data/kb/")) {
271
+ value = value.slice("data/kb/".length);
272
+ }
273
+ return value;
274
+ }
275
+
276
+ // Map inline KB references (kb://doc/<id>, raw/... paths) to the resolved
277
+ // https URL of the matching data-source part, so inline citations of
278
+ // KB-browsed files link to the real page instead of rendering as a dead
279
+ // kb:// or relative href.
280
+ export function buildCitationResolutions(
281
+ message: TutorMessage,
282
+ ): Map<string, string> {
283
+ const resolutions = new Map<string, string>();
284
+ for (const part of message.parts) {
285
+ if (!("type" in part) || part.type !== "data-source") {
286
+ continue;
287
+ }
288
+ const data = (part as TutorMessagePart).data as SourcePartData | undefined;
289
+ const url = data?.url?.trim();
290
+ if (!data || !isHttpUrl(url)) {
291
+ continue;
292
+ }
293
+ if (data.docId) {
294
+ resolutions.set(`kb://doc/${data.docId}`, url as string);
295
+ }
296
+ const path = normalizeKbPath(data.path ?? "");
297
+ if (path) {
298
+ resolutions.set(path, url as string);
299
+ }
300
+ }
301
+ return resolutions;
302
+ }
303
+
304
+ export function resolveCitationHref(
305
+ href: string | undefined,
306
+ resolutions: Map<string, string> | undefined,
307
+ ): string | undefined {
308
+ if (!href || !resolutions || resolutions.size === 0) {
309
+ return undefined;
310
+ }
311
+ return resolutions.get(href.trim()) ?? resolutions.get(normalizeKbPath(href));
312
  }
313
 
314
  // Number the citation links in a message's answer text by order of first
315
+ // appearance, deduped by normalized URL. KB references are keyed under their
316
+ // resolved https URL so the inline chip and the source card share a number;
317
+ // references that resolve to nothing navigable get no number (the renderer
318
+ // shows them as plain text instead of a broken chip). The same map drives the
319
+ // inline citation chips and the numbers on the sources row.
320
+ export function buildCitationNumbers(
321
+ message: TutorMessage,
322
+ resolutions?: Map<string, string>,
323
+ ): Map<string, number> {
324
  const numbers = new Map<string, number>();
325
  for (const part of message.parts) {
326
  if (!("type" in part) || part.type !== "text") {
327
  continue;
328
  }
329
  const text = (part as TutorMessagePart).text ?? "";
330
+ for (const url of extractMarkdownLinkUrls(text)) {
331
+ const resolved = resolveCitationHref(url, resolutions) ?? url;
332
+ if (!isHttpUrl(resolved)) {
333
  continue;
334
  }
335
+ const key = normalizeCitationUrl(resolved);
336
  if (key && !numbers.has(key)) {
337
  numbers.set(key, numbers.size + 1);
338
  }
frontend/lib/course-metadata.ts DELETED
@@ -1,29 +0,0 @@
1
- export type CourseMetadata = {
2
- description: string;
3
- academyUrl: string;
4
- };
5
-
6
- export const COURSE_METADATA: Record<string, CourseMetadata> = {
7
- full_stack_ai_engineering: {
8
- description:
9
- "Full-stack LLM engineering, covering RAG, fine-tuning, evaluation, and deploying production systems end-to-end. The deepest technical course.",
10
- academyUrl:
11
- "https://academy.towardsai.net/courses/beginner-to-advanced-llm-dev",
12
- },
13
- beginner_python_for_ai_engineering: {
14
- description:
15
- "Python for the LLM era: API integration, using open-source models, and core training/testing workflows. Assumes no prior Python.",
16
- academyUrl: "https://academy.towardsai.net/courses/python-for-genai",
17
- },
18
- master_ai_for_work: {
19
- description:
20
- "Non-engineer course on using AI tools (ChatGPT, Claude, etc.) for workplace productivity and rolling them out across a team.",
21
- academyUrl:
22
- "https://academy.towardsai.net/courses/ai-business-professionals",
23
- },
24
- agentic_ai_engineering: {
25
- description:
26
- "Designing, building, evaluating, and deploying production-grade AI agents end-to-end.",
27
- academyUrl: "https://academy.towardsai.net/courses/agent-engineering",
28
- },
29
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/lib/doc-metadata.ts DELETED
@@ -1,57 +0,0 @@
1
- export type DocMetadata = {
2
- description: string;
3
- docsUrl: string;
4
- };
5
-
6
- export const DOC_METADATA: Record<string, DocMetadata> = {
7
- transformers: {
8
- description:
9
- "Hugging Face library for state-of-the-art NLP and multimodal models. Load, run, and train pretrained transformers.",
10
- docsUrl: "https://huggingface.co/docs/transformers",
11
- },
12
- peft: {
13
- description:
14
- "Parameter-Efficient Fine-Tuning: LoRA, prefix tuning, and other methods for adapting large models with minimal compute.",
15
- docsUrl: "https://huggingface.co/docs/peft",
16
- },
17
- trl: {
18
- description:
19
- "Train language models with reinforcement learning. Covers SFT, DPO, PPO, and other alignment techniques.",
20
- docsUrl: "https://huggingface.co/docs/trl",
21
- },
22
- llama_index: {
23
- description:
24
- "Framework for building RAG apps: ingestion, indexing, retrievers, and query engines over your own data.",
25
- docsUrl: "https://docs.llamaindex.ai",
26
- },
27
- langchain: {
28
- description:
29
- "Framework for building LLM apps: chains, agents, tool-calling, and production observability.",
30
- docsUrl: "https://docs.langchain.com/oss/python/langchain/overview",
31
- },
32
- langgraph: {
33
- description:
34
- "Graph-based runtime for reliable, stateful AI agents with persistence, streaming, human review, and deployment patterns.",
35
- docsUrl: "https://docs.langchain.com/oss/python/langgraph/overview",
36
- },
37
- deep_agents: {
38
- description:
39
- "LangChain's deep agent harness for planning, delegation, filesystem context, and longer-running agent workflows.",
40
- docsUrl: "https://docs.langchain.com/oss/python/deepagents/overview",
41
- },
42
- openai_cookbooks: {
43
- description:
44
- "Example notebooks and recipes from OpenAI covering practical patterns for using their APIs.",
45
- docsUrl: "https://cookbook.openai.com",
46
- },
47
- openai_docs: {
48
- description:
49
- "Official OpenAI API, Agents SDK, and Codex documentation from the developer docs Markdown index.",
50
- docsUrl: "https://developers.openai.com",
51
- },
52
- claude_code_docs: {
53
- description:
54
- "Official Claude Code and Claude Agent SDK documentation from Anthropic's Markdown index.",
55
- docsUrl: "https://code.claude.com/docs/en/overview",
56
- },
57
- };