add SearchDocsTool integration in ChatMessagesContainer for enhanced document search functionality

This commit is contained in:
abhi1992002
2026-02-03 13:19:35 +05:30
parent 8c381faa06
commit 7772c71a15
3 changed files with 425 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import { MessageSquareIcon } from "lucide-react";
import { UIMessage, UIDataTypes, UITools, ToolUIPart } from "ai";
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
import { SearchDocsTool } from "../../tools/SearchDocs/SearchDocs";
interface ChatMessagesContainerProps {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
@@ -70,6 +71,14 @@ export const ChatMessagesContainer = ({
part={part as ToolUIPart}
/>
);
case "tool-search_docs":
case "tool-get_doc_page":
return (
<SearchDocsTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
default:
return null;
}

View File

@@ -0,0 +1,209 @@
"use client";
import type { ToolUIPart } from "ai";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CaretDownIcon } from "@phosphor-icons/react";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { cn } from "@/lib/utils";
import {
getDocsToolOutput,
getDocsToolTitle,
getToolLabel,
getAnimationText,
StateIcon,
toDocsUrl,
type DocsToolType,
} from "./helpers";
export interface DocsToolPart {
type: DocsToolType;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: DocsToolPart;
}
function truncate(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars).trimEnd()}`;
}
export function SearchDocsTool({ part }: Props) {
const shouldReduceMotion = useReducedMotion();
const [isExpanded, setIsExpanded] = useState(false);
const output = getDocsToolOutput(part);
const text = getAnimationText(part);
const normalized = useMemo(() => {
if (!output) return null;
const title = getDocsToolTitle(part.type, output);
const label = getToolLabel(part.type);
return { title, label };
}, [output, part.type]);
const isOutputAvailable = part.state === "output-available" && !!output;
const hasExpandableContent =
isOutputAvailable &&
((output.type === "doc_search_results" && output.count > 0) ||
output.type === "doc_page" ||
output.type === "no_results" ||
output.type === "error");
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
</div>
{hasExpandableContent && normalized && (
<div className="mt-2 rounded-2xl border bg-background px-3 py-2">
<button
type="button"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{normalized.label}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{normalized.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{output.type === "doc_search_results"
? `Found ${output.count} result${output.count === 1 ? "" : "s"} for "${output.query}"`
: output.type === "doc_page"
? output.path
: output.message}
</p>
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
{output.type === "doc_search_results" && (
<div className="grid gap-2 pb-2 pt-3">
{output.results.map((r) => {
const href = r.doc_url ?? toDocsUrl(r.path);
return (
<div
key={r.path}
className="rounded-2xl border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{r.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{r.path}
{r.section ? `${r.section}` : ""}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{truncate(r.snippet, 240)}
</p>
</div>
<Link
href={href}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
</div>
</div>
);
})}
</div>
)}
{output.type === "doc_page" && (
<div className="pb-2 pt-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{output.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{output.path}
</p>
</div>
<Link
href={output.doc_url ?? toDocsUrl(output.path)}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
>
Open
</Link>
</div>
<p className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{truncate(output.content, 800)}
</p>
</div>
)}
{output.type === "no_results" && (
<div className="pb-2 pt-3">
<p className="text-sm text-foreground">{output.message}</p>
{output.suggestions && output.suggestions.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{output.suggestions.slice(0, 5).map((s) => (
<li key={s}>{s}</li>
))}
</ul>
)}
</div>
)}
{output.type === "error" && (
<div className="pb-2 pt-3">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<p className="mt-2 text-xs text-muted-foreground">
{output.error}
</p>
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
export interface SearchDocsInput {
query: string;
}
export interface GetDocPageInput {
path: string;
}
export interface DocSearchResult {
title: string;
path: string;
section: string;
snippet: string;
score: number;
doc_url?: string | null;
}
export interface DocSearchResultsOutput {
type: "doc_search_results";
message: string;
session_id?: string;
results: DocSearchResult[];
count: number;
query: string;
}
export interface DocPageOutput {
type: "doc_page";
message: string;
session_id?: string;
title: string;
path: string;
content: string;
doc_url?: string | null;
}
export interface NoResultsOutput {
type: "no_results";
message: string;
suggestions?: string[];
session_id?: string;
}
export interface ErrorOutput {
type: "error";
message: string;
error?: string;
session_id?: string;
}
export type DocsToolOutput =
| DocSearchResultsOutput
| DocPageOutput
| NoResultsOutput
| ErrorOutput;
export type DocsToolType = "tool-search_docs" | "tool-get_doc_page" | string;
export function getToolLabel(toolType: DocsToolType): string {
switch (toolType) {
case "tool-search_docs":
return "Docs";
case "tool-get_doc_page":
return "Docs page";
default:
return "Docs";
}
}
function parseOutput(output: unknown): DocsToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as DocsToolOutput;
} catch {
return null;
}
}
if (typeof output === "object") {
return output as DocsToolOutput;
}
return null;
}
export function getDocsToolOutput(part: unknown): DocsToolOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
export function getDocsToolTitle(
toolType: DocsToolType,
output: DocsToolOutput,
): string {
if (toolType === "tool-search_docs") {
if (output.type === "doc_search_results") return "Documentation results";
if (output.type === "no_results") return "No documentation found";
return "Documentation search error";
}
if (output.type === "doc_page") return "Documentation page";
if (output.type === "no_results") return "No documentation found";
return "Documentation page error";
}
export function getAnimationText(part: {
type: DocsToolType;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}): string {
switch (part.type) {
case "tool-search_docs": {
switch (part.state) {
case "input-streaming":
return "Searching docs for you";
case "input-available": {
const query = (
part.input as SearchDocsInput | undefined
)?.query?.trim();
return query ? `Searching docs for "${query}"` : "Searching docs";
}
case "output-available": {
const output = parseOutput(part.output);
const query = (
part.input as SearchDocsInput | undefined
)?.query?.trim();
if (!output) return "Found documentation";
if (output.type === "doc_search_results") {
const count = output.count ?? output.results.length;
return query
? `Found ${count} doc result${count === 1 ? "" : "s"} for "${query}"`
: `Found ${count} doc result${count === 1 ? "" : "s"}`;
}
if (output.type === "no_results") {
return query ? `No docs found for "${query}"` : "No docs found";
}
return "Error searching docs";
}
case "output-error":
return "Error searching docs";
default:
return "Processing";
}
}
case "tool-get_doc_page": {
switch (part.state) {
case "input-streaming":
return "Loading documentation page";
case "input-available": {
const path = (
part.input as GetDocPageInput | undefined
)?.path?.trim();
return path ? `Loading "${path}"` : "Loading documentation page";
}
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Loaded documentation page";
if (output.type === "doc_page") return `Loaded "${output.title}"`;
if (output.type === "no_results")
return "Documentation page not found";
return "Error loading documentation page";
}
case "output-error":
return "Error loading documentation page";
default:
return "Processing";
}
}
}
return "Processing";
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
}
export function toDocsUrl(path: string): string {
const urlPath = path.includes(".")
? path.slice(0, path.lastIndexOf("."))
: path;
return `https://docs.agpt.co/${urlPath}`;
}