diff --git a/package.json b/package.json index 0a82141e4..ca6774d88 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "@codemirror/state": "^6.7.1", "@fingerprintjs/fingerprintjs-pro-react": "^2.7.1", "@floating-ui/react": "^0.27.19", - "@kapaai/react-sdk": "^0.9.10", "@lezer/highlight": "^1.2.3", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^9.0.3", @@ -75,6 +74,7 @@ "@tanstack/ai-anthropic": "^0.18.0", "@tanstack/ai-client": "^0.28.0", "@tanstack/ai-openai": "^0.22.0", + "@tanstack/ai-react": "^0.22.1", "@tanstack/charts": "0.16.0", "@tanstack/create": "^0.69.0", "@tanstack/highlight": "^0.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd1680b5f..a78df0b19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,6 @@ importers: '@floating-ui/react': specifier: ^0.27.19 version: 0.27.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@kapaai/react-sdk': - specifier: ^0.9.10 - version: 0.9.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 @@ -108,6 +105,9 @@ importers: '@tanstack/ai-openai': specifier: ^0.22.0 version: 0.22.0(@tanstack/ai@0.49.1(@opentelemetry/api@1.9.1)(zod@4.3.6))(ws@8.21.0)(zod@4.3.6) + '@tanstack/ai-react': + specifier: ^0.22.1 + version: 0.22.1(@opentelemetry/api@1.9.1)(@tanstack/ai@0.49.1(@opentelemetry/api@1.9.1)(zod@4.3.6))(@types/react@19.2.14)(react@19.2.3)(zod@4.3.6) '@tanstack/charts': specifier: 0.16.0 version: 0.16.0(lit@3.3.2)(octane@0.1.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.16(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))(preact@10.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12) @@ -1380,15 +1380,6 @@ packages: peerDependencies: three: ^0.182.0 - '@hcaptcha/loader@2.3.0': - resolution: {integrity: sha512-i4lnNxKBe+COf3R1nFZEWaZoHIoJjvDgWqvcNrdZq8ehoSNMN6KVZ56dcQ02qKie2h3+BkbkwlJA9DOIuLlK/g==} - - '@hcaptcha/react-hcaptcha@1.17.4': - resolution: {integrity: sha512-rIvgesG1N7SS9sAYYHFoWm+nXqRrxq7RcA9z2pKkDWV+S1GdfmrTNYA1aPyVWVe3eowphTCwyDJvl97Swwy0mw==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - '@hono/node-server@1.19.12': resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} engines: {node: '>=18.14.1'} @@ -1693,12 +1684,6 @@ packages: peerDependencies: tslib: '2' - '@kapaai/react-sdk@0.9.10': - resolution: {integrity: sha512-osQyFgBJmhNM207MpB0aZbjs2kjNHjEJbNc5YEReJuyFE72Iydn9CoZGxox2GYRgMIlQ7wljHCzgQqkvR8lXqA==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -3471,6 +3456,17 @@ packages: '@tanstack/ai': ^0.49.1 zod: ^4.0.0 + '@tanstack/ai-react@0.22.1': + resolution: {integrity: sha512-3v2MJ4V11RDAxQ3JUaOp33ET0nobckQgPKnxdaqWexCqdaHZox0xGu4/w1u3bmQqjm5w73+9lOnRgp+fUfu4Fw==} + peerDependencies: + '@mcp-ui/client': ^7 + '@tanstack/ai': ^0.49.1 + '@types/react': '>=18.0.0' + react: '>=18.0.0' + peerDependenciesMeta: + '@mcp-ui/client': + optional: true + '@tanstack/ai-utils@0.4.0': resolution: {integrity: sha512-xqvAgPJkIuGk1m+jZKyb7vBTQIaBmUD9EVzlPkDWWMp80n69uwL0TnBZCVk+OwL9goTQiFy648dnBfLsiJgl6A==} @@ -5512,9 +5508,6 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - js-cookie@3.0.8: - resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -6741,13 +6734,6 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} - tldts-core@7.4.2: - resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} - - tldts@7.4.2: - resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} - hasBin: true - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -7991,15 +7977,6 @@ snapshots: lit: 3.3.2 three: 0.183.2 - '@hcaptcha/loader@2.3.0': {} - - '@hcaptcha/react-hcaptcha@1.17.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@babel/runtime': 7.29.2 - '@hcaptcha/loader': 2.3.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - '@hono/node-server@1.19.12(hono@4.12.9)': dependencies: hono: 4.12.9 @@ -8259,16 +8236,6 @@ snapshots: '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) tslib: 2.8.1 - '@kapaai/react-sdk@0.9.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@fingerprintjs/fingerprintjs-pro-react': 2.7.1 - '@fingerprintjs/fingerprintjs-pro-spa': 1.3.3 - '@hcaptcha/react-hcaptcha': 1.17.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - js-cookie: 3.0.8 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - tldts: 7.4.2 - '@lezer/common@1.5.2': {} '@lezer/css@1.3.6': @@ -9814,6 +9781,16 @@ snapshots: - '@smithy/signature-v4' - ws + '@tanstack/ai-react@0.22.1(@opentelemetry/api@1.9.1)(@tanstack/ai@0.49.1(@opentelemetry/api@1.9.1)(zod@4.3.6))(@types/react@19.2.14)(react@19.2.3)(zod@4.3.6)': + dependencies: + '@tanstack/ai': 0.49.1(@opentelemetry/api@1.9.1)(zod@4.3.6) + '@tanstack/ai-client': 0.28.0(@opentelemetry/api@1.9.1)(zod@4.3.6) + '@types/react': 19.2.14 + react: 19.2.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - zod + '@tanstack/ai-utils@0.4.0': {} '@tanstack/ai@0.49.1(@opentelemetry/api@1.9.1)(zod@4.3.6)': @@ -12116,8 +12093,6 @@ snapshots: jose@6.2.2: {} - js-cookie@3.0.8: {} - js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -13464,12 +13439,6 @@ snapshots: tinypool@2.1.0: {} - tldts-core@7.4.2: {} - - tldts@7.4.2: - dependencies: - tldts-core: 7.4.2 - toidentifier@1.0.1: {} tr46@0.0.3: {} diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index ecb9a3861..20aeb9006 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -36,14 +36,13 @@ import { ArrowsInSimpleIcon, } from '@phosphor-icons/react' import { - DefaultKapaApiService, - KapaProvider, - processStream, + fetchServerSentEvents, useChat, - type StreamSource, -} from '@kapaai/react-sdk' + type UIMessage, +} from '@tanstack/ai-react' import { streamingMarkdownExtension } from '@tanstack/markdown/extensions/streaming' import { Markdown as TanStackMarkdown } from '@tanstack/markdown/react' +import type { InlineNode, MarkdownExtension } from '@tanstack/markdown' import { Link, useRouterState } from '@tanstack/react-router' import { useSearchContext } from '~/contexts/SearchContext' import { publicLibraries, type Framework } from '~/libraries' @@ -53,7 +52,6 @@ import { usePersistFrameworkPreference } from './FrameworkSelect' import { shouldPersistFrameworkForHit } from '~/utils/searchRecords' import { CodeBlock } from '~/components/markdown/CodeBlock' import { InlineCode } from '~/ui/InlineCode' -import { env } from '~/utils/env' import { getRoutableInternalLinkTarget, isSafeHref } from '~/utils/url-boundary' /** @@ -225,8 +223,20 @@ const AI_DOCK_DEFAULT_WIDTH = 360 const AI_DOCK_MAX_WIDTH_RATIO = 0.5 const AI_DOCK_MAXIMIZED_WIDTH = 1200 const DEFAULT_SEARCH_FRAMEWORK: Framework = 'react' -const KAPA_RECAPTCHA_READY_TIMEOUT_MS = 8_000 -const KAPA_INTEGRATION_ID = '86e864f7-401e-48c8-ac6f-5fcbc86c5668' + +// Algolia Agent Studio. The API key is search-ACL only and safe to ship to +// browsers; abuse is bounded by Agent Studio's approved domains and +// per-agent/per-IP rate limits. +const AGENT_STUDIO_APP_ID = 'FQ0DQ6MA3C' +const AGENT_STUDIO_SEARCH_API_KEY = '2a257513b7a9e64906b9e0e53a6bc3ce' +const AGENT_STUDIO_AGENT_ID = 'eeaedc22-16c5-4314-974f-38d93631761b' +const AGENT_STUDIO_BASE_URL = `https://${AGENT_STUDIO_APP_ID.toLowerCase()}.algolia.net/agent-studio/1` +const AGENT_STUDIO_COMPLETIONS_URL = `${AGENT_STUDIO_BASE_URL}/agents/${AGENT_STUDIO_AGENT_ID}/completions?compatibilityMode=ag-ui&stream=true` +const AGENT_STUDIO_FEEDBACK_URL = `${AGENT_STUDIO_BASE_URL}/feedback` +const AGENT_STUDIO_AUTH_HEADERS = { + 'X-Algolia-Application-Id': AGENT_STUDIO_APP_ID, + 'X-Algolia-API-Key': AGENT_STUDIO_SEARCH_API_KEY, +} type SearchSurface = 'modal' | 'dock' type AiDockStyle = React.CSSProperties & { @@ -270,71 +280,6 @@ function writeAiDockWidth(width: number) { localStorage.setItem(AI_DOCK_WIDTH_STORAGE_KEY, String(Math.round(width))) } -function waitForKapaRecaptchaReady() { - if (typeof window === 'undefined') { - return Promise.resolve() - } - - return new Promise((resolve) => { - let timeoutId: number | null = null - let intervalId: number | null = null - let isSettled = false - let hasRegisteredReadyCallback = false - - const cleanup = () => { - if (timeoutId !== null) { - window.clearTimeout(timeoutId) - timeoutId = null - } - - if (intervalId !== null) { - window.clearInterval(intervalId) - intervalId = null - } - } - - const finish = () => { - if (isSettled) { - return - } - - isSettled = true - cleanup() - window.setTimeout(resolve, 0) - } - - const checkReady = () => { - if (hasRegisteredReadyCallback) { - return true - } - - const enterprise = window.grecaptcha?.enterprise - - if (!enterprise) { - return false - } - - hasRegisteredReadyCallback = true - - if (intervalId !== null) { - window.clearInterval(intervalId) - intervalId = null - } - - enterprise.ready(finish) - return true - } - - timeoutId = window.setTimeout(finish, KAPA_RECAPTCHA_READY_TIMEOUT_MS) - - if (checkReady()) { - return - } - - intervalId = window.setInterval(checkReady, 50) - }) -} - function buildSearchFilters({ selectedLibrary, selectedFramework, @@ -661,7 +606,7 @@ const SafeLink = React.forwardRef( }, ) -function KapaMarkdownLink({ +function AiMarkdownLink({ href, children, ...props @@ -669,7 +614,7 @@ function KapaMarkdownLink({ return ( {children} @@ -677,7 +622,7 @@ function KapaMarkdownLink({ ) } -function KapaMarkdownTable({ +function AiMarkdownTable({ children, className, ...props @@ -697,7 +642,7 @@ function KapaMarkdownTable({ ) } -function KapaMarkdownTableHead({ +function AiMarkdownTableHead({ children, className, ...props @@ -715,7 +660,7 @@ function KapaMarkdownTableHead({ ) } -function KapaMarkdownTableBody({ +function AiMarkdownTableBody({ children, className, ...props @@ -733,7 +678,7 @@ function KapaMarkdownTableBody({ ) } -function KapaMarkdownTableCell({ +function AiMarkdownTableCell({ children, className, ...props @@ -751,7 +696,7 @@ function KapaMarkdownTableCell({ ) } -function KapaMarkdownTableHeaderCell({ +function AiMarkdownTableHeaderCell({ children, className, ...props @@ -769,15 +714,40 @@ function KapaMarkdownTableHeaderCell({ ) } -const kapaMarkdownComponents = { +const aiMarkdownComponents = { pre: CodeBlock, code: InlineCode, - a: KapaMarkdownLink, - table: KapaMarkdownTable, - thead: KapaMarkdownTableHead, - tbody: KapaMarkdownTableBody, - td: KapaMarkdownTableCell, - th: KapaMarkdownTableHeaderCell, + a: AiMarkdownLink, + table: AiMarkdownTable, + thead: AiMarkdownTableHead, + tbody: AiMarkdownTableBody, + td: AiMarkdownTableCell, + th: AiMarkdownTableHeaderCell, + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + // Sized in em so headings scale with the compact (13px) and regular (14px) + // chat bubbles alike. + h1: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

+ {children} +

+ ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h4: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h5: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + h6: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), ul: ({ children }: { children?: React.ReactNode }) => ( ), @@ -791,75 +761,372 @@ const kapaMarkdownComponents = { ), } -const kapaMarkdownExtensions = [streamingMarkdownExtension()] +// The agent often emits bare URLs in prose, and CommonMark only autolinks +// URLs wrapped in angle brackets. Turn bare http(s) URLs inside text nodes +// into links. Code spans and existing links are separate inline node types, +// so this transform can never touch them. +const BARE_URL_PATTERN = /https?:\/\/[^\s<>]+/g +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?)'"\]]+$/ -function parseSourceGroupIDs(value: string | undefined) { - if (!value) { - return undefined - } +function autolinkInlineNodes(nodes: Array): Array { + return nodes.flatMap((node): Array => { + if ( + node.type === 'strong' || + node.type === 'emphasis' || + node.type === 'strike' + ) { + return [{ ...node, children: autolinkInlineNodes(node.children) }] + } + + if (node.type !== 'text') { + return [node] + } + + const parts: Array = [] + let cursor = 0 + + for (const match of node.value.matchAll(BARE_URL_PATTERN)) { + const index = match.index ?? 0 + const url = match[0].replace(TRAILING_PUNCTUATION_PATTERN, '') + + if (index > cursor) { + parts.push({ type: 'text', value: node.value.slice(cursor, index) }) + } + parts.push({ + type: 'link', + href: url, + children: [{ type: 'text', value: url }], + }) + cursor = index + url.length + } + + if (parts.length === 0) { + return [node] + } + + if (cursor < node.value.length) { + parts.push({ type: 'text', value: node.value.slice(cursor) }) + } + + return parts + }) +} + +const autolinkBareUrlsExtension: MarkdownExtension = { + name: 'autolink-bare-urls', + transformInline: autolinkInlineNodes, +} + +const aiMarkdownExtensions = [ + streamingMarkdownExtension(), + autolinkBareUrlsExtension, +] + +type FeedbackReaction = 'upvote' | 'downvote' - const ids = value - .split(',') - .map((id) => id.trim()) - .filter((id) => id.length > 0) +type AnswerSource = { + title: string + subtitle: string + source_url: string + source_type: string +} - return ids.length > 0 ? ids : undefined +type AnswerToolCall = { + id: string + query: string + isPending: boolean } -type KapaReaction = 'upvote' | 'downvote' +type AnswerSegment = + | { type: 'text'; content: string } + | { type: 'tool-call'; toolCall: AnswerToolCall } -type KapaDisplayQA = { +type DisplayQA = { id: string | null question: string answer: string - sources: Array + sources: Array + // Ordered answer flow (text interleaved with search-tool activity) for the + // live conversation; not persisted to chat history, which falls back to + // the flat `answer` text. + segments?: Array isGenerationAborted: boolean isFeedbackSubmissionEnabled?: boolean - reaction: KapaReaction | null + reaction: FeedbackReaction | null } -type KapaHistoryItem = { +type ChatHistoryItem = { threadId: string title: string createdAt: number updatedAt: number - conversation: Array -} - -type KapaSubmitQueryArgs = Parameters[0] -type KapaChatStreamCallbacks = Parameters< - DefaultKapaApiService['submitQuery'] ->[1] -type KapaSubmitFeedbackArgs = Parameters< - DefaultKapaApiService['addFeedback'] ->[0] - -const KAPA_HISTORY_STORAGE_KEY = 'tanstack-kapa-chat-history' -const KAPA_HISTORY_LIMIT = 5 - -class KapaThreadOverrideApiService { - private service = new DefaultKapaApiService(processStream) - - constructor( - private threadIdOverrideRef: React.MutableRefObject, - ) {} - - submitQuery( - args: KapaSubmitQueryArgs, - callbacks: KapaChatStreamCallbacks, - ): Promise { - return this.service.submitQuery( - { ...args, threadId: this.threadIdOverrideRef.current }, - callbacks, - ) + conversation: Array +} + +const CHAT_HISTORY_STORAGE_KEY = 'tanstack-ai-chat-history' +const CHAT_HISTORY_LIMIT = 5 + +function createThreadId() { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() } - addFeedback(args: KapaSubmitFeedbackArgs): Promise { - return this.service.addFeedback(args) + return `thread_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}` +} + +function messageText(message: UIMessage) { + return message.parts + .map((part) => (part.type === 'text' ? part.content : '')) + .join('') +} + +/** + * Pull doc links out of the agent's Algolia search tool results. The tool + * output is provider-shaped JSON, so this walks it defensively and collects + * anything that looks like a hit: a record with a safe URL plus a usable + * title (explicit `title` or the deepest DocSearch `hierarchy` level). + */ +function collectSourcesFromToolOutput( + value: unknown, + into: Map, + depth = 0, +) { + if (depth > 6 || into.size >= 6) { + return + } + + if (Array.isArray(value)) { + for (const item of value) { + collectSourcesFromToolOutput(item, into, depth + 1) + } + return + } + + if (!isUnknownRecord(value)) { + return + } + + const url = readString(value.url) || readString(value.source_url) + + if (url && isSafeHref(url) && !into.has(url)) { + const hierarchy = isUnknownRecord(value.hierarchy) ? value.hierarchy : {} + const hierarchyLevels = ['lvl6', 'lvl5', 'lvl4', 'lvl3', 'lvl2', 'lvl1'] + .map((level) => readString(hierarchy[level])) + .filter((level) => level.length > 0) + const title = + readString(value.title) || + hierarchyLevels[0] || + readString(hierarchy.lvl0) + + if (title) { + into.set(url, { + title, + subtitle: readString(hierarchy.lvl0), + source_url: url, + source_type: 'documentation', + }) + return + } + } + + for (const nested of Object.values(value)) { + collectSourcesFromToolOutput(nested, into, depth + 1) + } +} + +/** + * Turn the search tool's input into a human-readable label. The agent's + * Algolia tools take `{ queries: [{ query }], originalQuery, ... }`. + */ +function describeToolCallQuery(input: unknown, rawArguments: string) { + let parsed = input + + if (parsed === undefined && rawArguments) { + try { + parsed = JSON.parse(rawArguments) + } catch { + return '' + } } - abortCurrent(): void { - this.service.abortCurrent() + if (!isUnknownRecord(parsed)) { + return '' + } + + const queries = Array.isArray(parsed.queries) ? parsed.queries : [] + const queryTexts = queries + .map((entry) => (isUnknownRecord(entry) ? readString(entry.query) : '')) + .filter((query) => query.length > 0) + + return queryTexts.join(', ') || readString(parsed.originalQuery) +} + +/** Tool results arrive as JSON strings on the wire; parse them for source extraction. */ +function parseToolResult(content: string | Array): unknown { + if (typeof content !== 'string') { + return content + } + + try { + return JSON.parse(content) + } catch { + return undefined + } +} + +/** + * Fold the AG-UI message list into the question/answer pairs the panel + * renders: each user message opens a QA, and the assistant messages that + * follow contribute answer text, search-tool activity, and tool-derived + * sources. + */ +function messagesToDisplayQAs( + messages: Array, + reactions: Record, +): Array { + const qas: Array = [] + let current: DisplayQA | null = null + + for (const message of messages) { + if (message.role === 'user') { + current = { + id: null, + question: messageText(message), + answer: '', + sources: [], + segments: [], + isGenerationAborted: false, + isFeedbackSubmissionEnabled: false, + reaction: null, + } + qas.push(current) + continue + } + + if (message.role !== 'assistant' || !current) { + continue + } + + const sources = new Map( + current.sources.map((source) => [source.source_url, source]), + ) + const segments = current.segments ?? [] + + // A completed tool call carries its output inline while streaming, but a + // hydrated or replayed thread can represent it as a sibling tool-result + // part instead. Index those so both shapes yield sources. + const toolResultOutputs = new Map() + for (const part of message.parts) { + if (part.type === 'tool-result' && part.error === undefined) { + toolResultOutputs.set(part.toolCallId, parseToolResult(part.content)) + } + } + + for (const part of message.parts) { + if (part.type === 'text' && part.content.trim()) { + segments.push({ type: 'text', content: part.content.trim() }) + } + + if (part.type === 'tool-call') { + const output = part.output ?? toolResultOutputs.get(part.id) + + segments.push({ + type: 'tool-call', + toolCall: { + id: part.id, + query: describeToolCallQuery(part.input, part.arguments), + isPending: output === undefined && part.state !== 'error', + }, + }) + + if (output !== undefined) { + collectSourcesFromToolOutput(output, sources) + } + } + } + + current.id = message.id + current.segments = segments + // Flat text fallback, used for chat history persistence. The agent + // narrates between tool calls, so join the chunks as paragraphs. + current.answer = segments + .flatMap((segment) => (segment.type === 'text' ? [segment.content] : [])) + .join('\n\n') + current.sources = Array.from(sources.values()) + current.isFeedbackSubmissionEnabled = true + current.reaction = reactions[message.id] ?? null + } + + return qas +} + +/** + * Chat state for the AI panel, backed by Algolia Agent Studio's AG-UI + * completions endpoint via TanStack AI. Conversations are keyed by + * `threadId` on the server, so resuming a thread only needs the same id. + */ +function useAgentStudioChat(threadId: string) { + const connection = React.useMemo( + () => + fetchServerSentEvents(AGENT_STUDIO_COMPLETIONS_URL, { + headers: AGENT_STUDIO_AUTH_HEADERS, + }), + [], + ) + const { messages, sendMessage, isLoading, error, stop } = useChat({ + connection, + threadId, + }) + const [reactions, setReactions] = React.useState< + Record + >({}) + + const conversation = React.useMemo( + () => messagesToDisplayQAs(messages, reactions), + [messages, reactions], + ) + + const addFeedback = React.useCallback( + (messageId: string, reaction: FeedbackReaction) => { + // Optimistic: Agent Studio accepts a single vote per message, and the + // UI already locks the buttons after the first reaction. + setReactions((current) => ({ ...current, [messageId]: reaction })) + + fetch(AGENT_STUDIO_FEEDBACK_URL, { + method: 'POST', + headers: { + ...AGENT_STUDIO_AUTH_HEADERS, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messageId, + agentId: AGENT_STUDIO_AGENT_ID, + vote: reaction === 'upvote' ? 1 : 0, + }), + }) + .then((response) => { + if (!response.ok) { + throw new Error(`feedback rejected: ${response.status}`) + } + }) + .catch(() => { + // Unlock the buttons so the vote can be retried. + setReactions((current) => { + const { [messageId]: _failed, ...rest } = current + return rest + }) + }) + }, + [], + ) + + return { + conversation, + submitQuery: sendMessage, + isBusy: isLoading, + stopGeneration: stop, + addFeedback, + errorMessage: error?.message ?? null, } } @@ -879,7 +1146,7 @@ function readNumber(value: unknown) { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } -function parseKapaReaction(value: unknown): KapaReaction | null { +function parseFeedbackReaction(value: unknown): FeedbackReaction | null { if (value === 'upvote' || value === 'downvote') { return value } @@ -887,7 +1154,7 @@ function parseKapaReaction(value: unknown): KapaReaction | null { return null } -function parseKapaSource(value: unknown): StreamSource | null { +function parseAnswerSource(value: unknown): AnswerSource | null { if (!isUnknownRecord(value)) { return null } @@ -913,7 +1180,7 @@ function parseKapaSource(value: unknown): StreamSource | null { } } -function parseKapaHistoryQA(value: unknown): KapaDisplayQA | null { +function parseChatHistoryQA(value: unknown): DisplayQA | null { if (!isUnknownRecord(value)) { return null } @@ -930,14 +1197,14 @@ function parseKapaHistoryQA(value: unknown): KapaDisplayQA | null { id: typeof value.id === 'string' ? value.id : null, question, answer: readString(value.answer), - sources: sourceValues.map(parseKapaSource).filter(isDefined), + sources: sourceValues.map(parseAnswerSource).filter(isDefined), isGenerationAborted: value.isGenerationAborted === true, isFeedbackSubmissionEnabled: value.isFeedbackSubmissionEnabled === true, - reaction: parseKapaReaction(value.reaction), + reaction: parseFeedbackReaction(value.reaction), } } -function parseKapaHistoryItem(value: unknown): KapaHistoryItem | null { +function parseChatHistoryItem(value: unknown): ChatHistoryItem | null { if (!isUnknownRecord(value)) { return null } @@ -947,7 +1214,7 @@ function parseKapaHistoryItem(value: unknown): KapaHistoryItem | null { ? value.conversation : [] const conversation = conversationValues - .map(parseKapaHistoryQA) + .map(parseChatHistoryQA) .filter(isDefined) if (!threadId || conversation.length === 0) { @@ -958,20 +1225,20 @@ function parseKapaHistoryItem(value: unknown): KapaHistoryItem | null { return { threadId, - title: readString(value.title) || buildKapaHistoryTitle(conversation), + title: readString(value.title) || buildChatHistoryTitle(conversation), createdAt: readNumber(value.createdAt) || updatedAt, updatedAt, conversation, } } -function readKapaHistory() { +function readChatHistory() { if (typeof window === 'undefined') { return [] } try { - const raw = localStorage.getItem(KAPA_HISTORY_STORAGE_KEY) + const raw = localStorage.getItem(CHAT_HISTORY_STORAGE_KEY) if (!raw) { return [] } @@ -982,31 +1249,31 @@ function readKapaHistory() { } return parsed - .map(parseKapaHistoryItem) + .map(parseChatHistoryItem) .filter(isDefined) .sort((a, b) => b.updatedAt - a.updatedAt) - .slice(0, KAPA_HISTORY_LIMIT) + .slice(0, CHAT_HISTORY_LIMIT) } catch { return [] } } -function writeKapaHistory(items: Array) { +function writeChatHistory(items: Array) { if (typeof window === 'undefined') { return } try { localStorage.setItem( - KAPA_HISTORY_STORAGE_KEY, - JSON.stringify(items.slice(0, KAPA_HISTORY_LIMIT)), + CHAT_HISTORY_STORAGE_KEY, + JSON.stringify(items.slice(0, CHAT_HISTORY_LIMIT)), ) } catch { // Ignore storage failures so chat keeps working. } } -function buildKapaHistoryTitle(conversation: Array) { +function buildChatHistoryTitle(conversation: Array) { const firstQuestion = conversation .find((qa) => qa.question.trim().length > 0) @@ -1018,7 +1285,7 @@ function buildKapaHistoryTitle(conversation: Array) { : firstQuestion } -function toStoredKapaQA(qa: KapaDisplayQA): KapaDisplayQA { +function toStoredQA(qa: DisplayQA): DisplayQA { return { id: qa.id, question: qa.question, @@ -1035,30 +1302,30 @@ function toStoredKapaQA(qa: KapaDisplayQA): KapaDisplayQA { } } -function upsertKapaHistoryItem( +function upsertChatHistoryItem( threadId: string, - conversation: Array, + conversation: Array, ) { - const current = readKapaHistory() + const current = readChatHistory() const existing = current.find((item) => item.threadId === threadId) const now = Date.now() - const nextItem: KapaHistoryItem = { + const nextItem: ChatHistoryItem = { threadId, - title: buildKapaHistoryTitle(conversation), + title: buildChatHistoryTitle(conversation), createdAt: existing?.createdAt ?? now, updatedAt: now, - conversation: conversation.map(toStoredKapaQA), + conversation: conversation.map(toStoredQA), } const next = [ nextItem, ...current.filter((item) => item.threadId !== threadId), - ].slice(0, KAPA_HISTORY_LIMIT) + ].slice(0, CHAT_HISTORY_LIMIT) - writeKapaHistory(next) + writeChatHistory(next) return next } -function formatKapaHistoryTime(updatedAt: number) { +function formatChatHistoryTime(updatedAt: number) { const elapsed = Date.now() - updatedAt if (elapsed < 60_000) { @@ -1079,17 +1346,17 @@ function formatKapaHistoryTime(updatedAt: number) { }).format(new Date(updatedAt)) } -function useKapaChatHistory() { +function useChatHistory() { const [items, setItems] = - React.useState>(readKapaHistory) + React.useState>(readChatHistory) const save = React.useCallback( - (threadId: string, conversation: Array) => { + (threadId: string, conversation: Array) => { if (conversation.length === 0) { return } - setItems(upsertKapaHistoryItem(threadId, conversation)) + setItems(upsertChatHistoryItem(threadId, conversation)) }, [], ) @@ -1209,7 +1476,7 @@ function CopyChatButton({ conversation, compact = false, }: { - conversation: Array + conversation: Array compact?: boolean }) { const [copied, setCopied] = React.useState(false) @@ -1277,17 +1544,17 @@ function CopyChatButton({ ) } -function KapaHistoryButton({ +function ChatHistoryButton({ items, activeThreadId, isBusy, onSelect, compact = false, }: { - items: Array + items: Array activeThreadId: string | null isBusy: boolean - onSelect: (item: KapaHistoryItem) => void + onSelect: (item: ChatHistoryItem) => void compact?: boolean }) { const { cancelAiDockHoverClose } = useSearchContext() @@ -1358,7 +1625,7 @@ function KapaHistoryButton({ {item.title} - {formatKapaHistoryTime(item.updatedAt)} + {formatChatHistoryTime(item.updatedAt)} {isActive ? ( @@ -1431,12 +1698,24 @@ function AIMessageHeader({ action }: { action?: React.ReactNode }) { TanStack - answers by kapa.ai + + powered by + + Algolia + Algolia @@ -1471,7 +1750,7 @@ function AlgoliaAttribution() { ) } -function getKapaSuggestions(selectedLibrary: string) { +function getAiSuggestions(selectedLibrary: string) { if (selectedLibrary === 'query') { return [ 'How do I invalidate a query?', @@ -1519,7 +1798,7 @@ function getKapaSuggestions(selectedLibrary: string) { ] } -function KapaWelcome({ +function AiWelcome({ selectedLibrary, selectedFramework, onSuggestion, @@ -1532,7 +1811,7 @@ function KapaWelcome({ disabled?: boolean compact?: boolean }) { - const suggestions = getKapaSuggestions(selectedLibrary) + const suggestions = getAiSuggestions(selectedLibrary) return (
@@ -1590,7 +1869,7 @@ function KapaWelcome({ ) } -function KapaAnswer({ +function AiAnswer({ qa, isStreaming, error, @@ -1598,7 +1877,7 @@ function KapaAnswer({ onFeedback, compact = false, }: { - qa: KapaDisplayQA + qa: DisplayQA isStreaming: boolean error?: string | null onCopyQuestion: () => void @@ -1608,6 +1887,8 @@ function KapaAnswer({ const canSubmitFeedback = qa.id !== null && qa.isFeedbackSubmissionEnabled && qa.reaction === null const hasAnswerError = !isStreaming && !qa.answer && !!error + const segments = qa.segments ?? [] + const hasAnswerContent = segments.length > 0 || !!qa.answer return (
@@ -1640,21 +1921,58 @@ function KapaAnswer({ > {error}

- ) : qa.answer ? ( + ) : hasAnswerContent ? (
- - {qa.answer} - + {segments.length > 0 ? ( + segments.map((segment, index) => + segment.type === 'tool-call' ? ( +
+ + + {segment.toolCall.isPending && isStreaming + ? segment.toolCall.query + ? `Searching the docs for “${segment.toolCall.query}”…` + : 'Searching the docs…' + : segment.toolCall.query + ? `Searched the docs for “${segment.toolCall.query}”` + : 'Searched the docs'} + +
+ ) : ( + + {segment.content} + + ), + ) + ) : ( + + {qa.answer} + + )}
) : (
@@ -1745,11 +2063,10 @@ function KapaAnswer({ ) } -function KapaChatPanel({ +function AiChatPanel({ onReset, isFullHeight, onToggleFullHeight, - threadIdOverrideRef, newChatRequestId, surface, isDockMaximized = false, @@ -1758,23 +2075,20 @@ function KapaChatPanel({ onReset: () => void isFullHeight: boolean onToggleFullHeight: () => void - threadIdOverrideRef: React.MutableRefObject newChatRequestId: number surface: SearchSurface isDockMaximized?: boolean onToggleDockMaximized?: () => void }) { + const [threadId, setThreadId] = React.useState(createThreadId) const { conversation, - threadId, submitQuery, - isGeneratingAnswer, - isPreparingAnswer, + isBusy, stopGeneration, - resetConversation, addFeedback, - error, - } = useChat() + errorMessage, + } = useAgentStudioChat(threadId) const { aiDockAskRequest, clearAiDockAskRequest, @@ -1784,27 +2098,29 @@ function KapaChatPanel({ const { selectedLibrary, selectedFramework, showSearchResults, searchQuery } = useSearchFilters() const scrollRef = React.useRef(null) - const { items: historyItems, save: saveHistory } = useKapaChatHistory() + const { items: historyItems, save: saveHistory } = useChatHistory() const [selectedHistoryItem, setSelectedHistoryItem] = - React.useState(null) - const isBusy = isGeneratingAnswer || isPreparingAnswer + React.useState(null) + // Question waiting for the chat client of a freshly created thread. Changing + // `threadId` recreates the client on the next render, so a "clear then ask" + // flow (e.g. Ask AI from the search modal) must defer the submit. + const [pendingQuestion, setPendingQuestion] = React.useState( + null, + ) const isDock = surface === 'dock' const isSubmittingRef = React.useRef(false) - const pendingSubmitIdRef = React.useRef(0) const lockedToBottom = React.useRef(true) const handledNewChatRequestId = React.useRef(newChatRequestId) const handledAiDockAskRequestId = React.useRef(0) - const displayConversation = React.useMemo>(() => { - const liveConversation = Array.from(conversation) - + const displayConversation = React.useMemo>(() => { if (!selectedHistoryItem) { - return liveConversation + return conversation } - return [...selectedHistoryItem.conversation, ...liveConversation] + return [...selectedHistoryItem.conversation, ...conversation] }, [conversation, selectedHistoryItem]) const hasConversation = displayConversation.length > 0 - const activeThreadId = threadId ?? selectedHistoryItem?.threadId ?? null + const activeThreadId = threadId React.useEffect(() => { if (!isDock) { @@ -1817,13 +2133,7 @@ function KapaChatPanel({ }, [displayConversation.length, isBusy, isDock, searchQuery, setAiDockDirty]) React.useEffect(() => { - if (threadId) { - threadIdOverrideRef.current = threadId - } - }, [threadId, threadIdOverrideRef]) - - React.useEffect(() => { - if (!threadId || isBusy || displayConversation.length === 0) { + if (isBusy || displayConversation.length === 0) { return } @@ -1870,44 +2180,35 @@ function KapaChatPanel({ return } - const submitId = pendingSubmitIdRef.current + 1 - pendingSubmitIdRef.current = submitId isSubmittingRef.current = true - - waitForKapaRecaptchaReady().then(() => { - if (pendingSubmitIdRef.current !== submitId) { - return - } - - submitQuery(trimmed) - }) + void submitQuery(trimmed) }, [isBusy, submitQuery], ) const selectHistoryItem = React.useCallback( - (item: KapaHistoryItem) => { + (item: ChatHistoryItem) => { if (isBusy) { return } - resetConversation() - threadIdOverrideRef.current = item.threadId + // Agent Studio persists the conversation server-side keyed by threadId, + // so adopting the stored id resumes the thread; the stored QAs are + // rendered as the prefix of the transcript. setSelectedHistoryItem(item) + setThreadId(item.threadId) lockedToBottom.current = true }, - [isBusy, resetConversation, threadIdOverrideRef], + [isBusy], ) const clearActiveChat = React.useCallback(() => { - pendingSubmitIdRef.current += 1 isSubmittingRef.current = false - resetConversation() - threadIdOverrideRef.current = null setSelectedHistoryItem(null) + setThreadId(createThreadId()) lockedToBottom.current = true onReset() - }, [onReset, resetConversation, threadIdOverrideRef]) + }, [onReset]) const startNewChat = React.useCallback(() => { if (isBusy) { @@ -1926,6 +2227,15 @@ function KapaChatPanel({ startNewChat() }, [newChatRequestId, startNewChat]) + React.useEffect(() => { + if (pendingQuestion === null || isBusy) { + return + } + + setPendingQuestion(null) + ask(pendingQuestion) + }, [ask, isBusy, pendingQuestion]) + React.useEffect(() => { if (!isDock || !aiDockAskRequest) { return @@ -1942,10 +2252,9 @@ function KapaChatPanel({ handledAiDockAskRequestId.current = aiDockAskRequest.id clearActiveChat() - ask(aiDockAskRequest.question) + setPendingQuestion(aiDockAskRequest.question) clearAiDockAskRequest(aiDockAskRequest.id) }, [ - ask, aiDockAskRequest, clearActiveChat, clearAiDockAskRequest, @@ -2021,7 +2330,7 @@ function KapaChatPanel({ onToggle={onToggleDockMaximized} /> ) : null} - {displayConversation.length === 0 ? ( - = 0 const isLatestLiveQA = liveIndex === conversation.length - 1 const isStreamingLatest = isLiveQA && isLatestLiveQA && isBusy - const answerError = isLiveQA && isLatestLiveQA ? error : null + const answerError = isLiveQA && isLatestLiveQA ? errorMessage : null return ( - - {error} + {errorMessage}
)}
@@ -2153,122 +2462,6 @@ function StickyTopBlur() { ) } -function KapaUnavailablePanel({ - isFullHeight, - onToggleFullHeight, - surface, - isDockMaximized = false, - onToggleDockMaximized, -}: { - isFullHeight: boolean - onToggleFullHeight: () => void - surface: SearchSurface - isDockMaximized?: boolean - onToggleDockMaximized?: () => void -}) { - const { closeSearch, setAiDockDirty } = useSearchContext() - const { selectedLibrary, selectedFramework, showSearchResults, searchQuery } = - useSearchFilters() - const isDock = surface === 'dock' - - React.useEffect(() => { - if (!isDock) { - return - } - - setAiDockDirty(searchQuery.trim().length > 0) - }, [isDock, searchQuery, setAiDockDirty]) - - return ( -
-
- {isDock ? null : } -
- {isDock ? ( -
-
- TanStack AI -
-
- ) : ( - <> - - - - )} -
- {isDock && onToggleDockMaximized ? ( -
- -
- ) : null} -
-
-
- Kapa is not configured. Add{' '} - VITE_KAPA_INTEGRATION_ID to enable - AI answers. -
- {}} - disabled - compact={isDock} - /> -
-
-
- -
- {}} surface={surface} /> -
-
- ) -} - function SearchPanel({ isFullHeight, onToggleFullHeight, @@ -2284,23 +2477,6 @@ function SearchPanel({ isDockMaximized?: boolean onToggleDockMaximized?: () => void }) { - const integrationId = KAPA_INTEGRATION_ID - const threadIdOverrideRef = React.useRef(null) - const apiService = React.useMemo( - () => new KapaThreadOverrideApiService(threadIdOverrideRef), - [], - ) - const sourceGroupIDsInclude = React.useMemo( - () => parseSourceGroupIDs(env.VITE_KAPA_SOURCE_GROUP_IDS), - [], - ) - - React.useEffect(() => { - return () => { - apiService.abortCurrent() - } - }, [apiService]) - return (
- {integrationId ? ( - - {}} - isFullHeight={isFullHeight} - onToggleFullHeight={onToggleFullHeight} - threadIdOverrideRef={threadIdOverrideRef} - newChatRequestId={newChatRequestId} - surface={surface} - isDockMaximized={isDockMaximized} - onToggleDockMaximized={onToggleDockMaximized} - /> - - ) : ( - - )} + {}} + isFullHeight={isFullHeight} + onToggleFullHeight={onToggleFullHeight} + newChatRequestId={newChatRequestId} + surface={surface} + isDockMaximized={isDockMaximized} + onToggleDockMaximized={onToggleDockMaximized} + />
) } diff --git a/src/styles/app.css b/src/styles/app.css index b22f9bacb..0f5a2dbdf 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -2563,12 +2563,6 @@ html:not(.dark) .library-landing-graphic [style*='drop-shadow'] { @apply my-4; } -/* Kapa injects invisible reCAPTCHA; keep it from occupying the UI corner. */ -.grecaptcha-badge { - display: none !important; - pointer-events: none !important; -} - button[aria-label='Open TanStack Devtools'] { display: none !important; } diff --git a/src/utils/env.ts b/src/utils/env.ts index c5855418d..c6c4e7c14 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -23,8 +23,6 @@ const serverEnvSchema = v.object({ }) const clientEnvSchema = v.object({ - VITE_KAPA_INTEGRATION_ID: v.optional(v.string()), - VITE_KAPA_SOURCE_GROUP_IDS: v.optional(v.string()), VITE_WEBCONTAINER_API_KEY: v.optional(v.string()), }) diff --git a/vite.config.ts b/vite.config.ts index f6cfa87c8..817ba3d47 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -161,7 +161,7 @@ const useSyncExternalStoreShimIndexAlias = { const serverBundledClientPackages = [ ...(shouldUseRedact ? ['@tanstack/redact'] : []), /^@radix-ui\//, - '@kapaai/react-sdk', + /^@tanstack\/ai(?:-|$)/, '@tanstack/highlight', '@tanstack/markdown', '@tanstack/react-hotkeys',