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 +56 -13
- frontend/components/chat-shell.tsx +9 -5
- frontend/components/markdown-block.tsx +21 -3
- frontend/components/source-sidebar.tsx +10 -15
- frontend/eslint.config.mjs +18 -1
- frontend/lib/api.ts +5 -0
- frontend/lib/chat-ui.ts +141 -11
- frontend/lib/course-metadata.ts +0 -29
- frontend/lib/doc-metadata.ts +0 -57
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
}
|
| 103 |
},
|
|
@@ -190,7 +191,10 @@ export function ChatShell() {
|
|
| 190 |
}, [selectedModel]);
|
| 191 |
|
| 192 |
const isStreaming = status === "submitted" || status === "streaming";
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
| 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() || !
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 494 |
-
linkUrl:
|
| 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:
|
| 505 |
-
linkUrl:
|
| 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 |
-
{
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 187 |
-
//
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
// Number the citation links in a message's answer text by order of first
|
| 192 |
-
// appearance, deduped by normalized URL.
|
| 193 |
-
//
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 202 |
-
|
|
|
|
| 203 |
continue;
|
| 204 |
}
|
| 205 |
-
const key = normalizeCitationUrl(
|
| 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 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|