diff --git a/apps/example/package.json b/apps/example/package.json index 6ba9394..11406e0 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -26,12 +26,19 @@ "pipeline:llm-real": "bun run scripts/llm-generate-real.ts" }, "dependencies": { + "@cloudflare/tanstack-ai": "^0.1.7", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@mdx-js/react": "^3.1.1", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@streamdown/mermaid": "^1.0.2", + "@tanstack/ai-client": "^0.8.0", + "@tanstack/ai": "^0.14.0", + "@tanstack/ai-anthropic": "^0.8.2", + "@tanstack/ai-gemini": "^0.10.0", + "@tanstack/ai-openai": "^0.8.2", + "@tanstack/ai-openrouter": "^0.8.2", "@tanstack/react-router": "^1.169.2", "@tanstack/react-start": "^1.167.65", "ai": "^6.0.177", diff --git a/apps/example/src/components/provider-search-tester.tsx b/apps/example/src/components/provider-search-tester.tsx new file mode 100644 index 0000000..5638585 --- /dev/null +++ b/apps/example/src/components/provider-search-tester.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { Link } from "@tanstack/react-router"; +import type { FormEvent } from "react"; +import { useEffect, useId, useState } from "react"; +import { Streamdown } from "streamdown"; +import { SiteFooter } from "@/components/site-footer"; +import { SiteHeader } from "@/components/site-header"; +import { + type DemoProviderId, + type ProviderAnswerConfig, + providerIds, + providerSearchConfigs, +} from "@/lib/provider-search"; +import type { DemoSearchApiResult } from "@/lib/search"; +import { SEARCH_MAX_QUERY_LENGTH } from "@/lib/use-docs-search"; + +interface ProviderSearchTesterProps { + provider: DemoProviderId; + showChrome?: boolean; +} + +type RequestStatus = "idle" | "loading" | "streaming" | "error"; + +const DEFAULT_QUERY = "How do CommandTabs work?"; + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +async function readErrorMessage(response: Response): Promise { + const data = (await response.json().catch(() => null)) as { + error?: string; + } | null; + return data?.error ?? "Request failed."; +} + +async function readStream( + response: Response, + onText: (text: string) => void +): Promise { + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + const text = decoder.decode(chunk.value, { stream: true }); + fullText += text; + onText(text); + } + + const remainingText = decoder.decode(); + if (remainingText) { + fullText += remainingText; + onText(remainingText); + } + + return fullText; +} + +export function ProviderSearchTester({ + provider, + showChrome = true, +}: ProviderSearchTesterProps) { + const config = providerSearchConfigs[provider]; + const inputId = useId(); + const [answer, setAnswer] = useState(""); + const [answerConfig, setAnswerConfig] = useState( + null + ); + const [error, setError] = useState(""); + const [query, setQuery] = useState(DEFAULT_QUERY); + const [results, setResults] = useState([]); + const [status, setStatus] = useState("idle"); + + useEffect(() => { + let active = true; + setAnswerConfig(null); + setError(""); + async function loadConfig() { + const response = await fetch(`/api/docs/ask/${provider}`); + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + const data = (await response.json()) as ProviderAnswerConfig; + if (active) { + setError(""); + setAnswerConfig(data); + } + } + const promise = loadConfig(); + promise.catch((caughtError: unknown) => { + if (active) { + setError( + caughtError instanceof Error + ? caughtError.message + : "Provider configuration failed." + ); + } + }); + return () => { + active = false; + }; + }, [provider]); + + async function runSearch(trimmedQuery: string) { + const response = await fetch( + `/api/docs/search?q=${encodeURIComponent(trimmedQuery)}` + ); + const data = (await response.json()) as + | DemoSearchApiResult + | { error: string }; + + if (!response.ok || "error" in data) { + throw new Error("error" in data ? data.error : "Search failed."); + } + + setResults(data.results); + } + + async function runProviderAnswer() { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + setError("Enter a query."); + return; + } + + setAnswer(""); + setError(""); + setStatus("loading"); + + try { + await runSearch(trimmedQuery); + setStatus("streaming"); + const response = await fetch(`/api/docs/ask/${provider}`, { + body: JSON.stringify({ query: trimmedQuery }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + + if (!(response.ok && response.body)) { + throw new Error(await readErrorMessage(response)); + } + + const streamedAnswer = await readStream(response, (text) => { + setAnswer((current) => current + text); + }); + + if (!streamedAnswer.trim()) { + throw new Error("The provider returned an empty answer."); + } + + setStatus("idle"); + } catch (caughtError) { + if (isAbortError(caughtError)) { + setStatus("idle"); + return; + } + setStatus("error"); + setError( + caughtError instanceof Error ? caughtError.message : "Request failed." + ); + } + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const promise = runProviderAnswer(); + promise.catch(() => undefined); + } + + const isBusy = status === "loading" || status === "streaming"; + + const content = ( + <> +
+
+
+

+ {config.wrapper} +

+

+ {config.label} search +

+

+ {config.description} +

+ +
+ +
+
+ + {answerConfig?.enabled ? "Configured" : "Not configured"} + + + {answerConfig?.model ?? "loading"} + +
+ +
+ +