mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-03 03:14:57 -05:00
add SearchDocsTool integration in ChatMessagesContainer for enhanced document search functionality
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user