From f2d6d6486f6b5ed6d0f8e9b5ce9755d217315a84 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Thu, 27 Aug 2026 11:32:44 +0200 Subject: [PATCH 1/5] Replace Kapa with Algolia Agent Studio in the AI dock The AI panel now streams answers from Algolia Agent Studio's AG-UI completions endpoint via TanStack AI's useChat, replacing the Kapa SDK and its reCAPTCHA integration. Sources are extracted from the agent's Algolia search tool results, search activity renders inline in the answer flow, and thumbs feedback posts to the Agent Studio feedback endpoint. --- package.json | 2 +- pnpm-lock.yaml | 79 +-- src/components/SearchModal.tsx | 905 ++++++++++++++++++--------------- src/styles/app.css | 6 - src/utils/env.ts | 2 - vite.config.ts | 2 +- 6 files changed, 533 insertions(+), 463 deletions(-) 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..6c9e44de9 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -36,12 +36,11 @@ import { ArrowsInSimpleIcon, } from '@phosphor-icons/react' import { - DefaultKapaApiService, - KapaProvider, - processStream, + fetchServerSentEvents, useChat, - type StreamSource, -} from '@kapaai/react-sdk' + type ConnectConnectionAdapter, + type UIMessage, +} from '@tanstack/ai-react' import { streamingMarkdownExtension } from '@tanstack/markdown/extensions/streaming' import { Markdown as TanStackMarkdown } from '@tanstack/markdown/react' import { Link, useRouterState } from '@tanstack/react-router' @@ -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,21 @@ 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 (staging while the AG-UI compatibility mode rolls out +// to production). 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 = 'betaHAXPMHIMMC' +const AGENT_STUDIO_SEARCH_API_KEY = '8b00405cba281a7d800ccec393e9af24' +const AGENT_STUDIO_AGENT_ID = '97a1b15d-9837-48ed-b4b4-f905b0199cee' +const AGENT_STUDIO_BASE_URL = 'https://agent-studio.staging.eu.algolia.com/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 +281,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 +607,7 @@ const SafeLink = React.forwardRef( }, ) -function KapaMarkdownLink({ +function AiMarkdownLink({ href, children, ...props @@ -677,7 +623,7 @@ function KapaMarkdownLink({ ) } -function KapaMarkdownTable({ +function AiMarkdownTable({ children, className, ...props @@ -697,7 +643,7 @@ function KapaMarkdownTable({ ) } -function KapaMarkdownTableHead({ +function AiMarkdownTableHead({ children, className, ...props @@ -715,7 +661,7 @@ function KapaMarkdownTableHead({ ) } -function KapaMarkdownTableBody({ +function AiMarkdownTableBody({ children, className, ...props @@ -733,7 +679,7 @@ function KapaMarkdownTableBody({ ) } -function KapaMarkdownTableCell({ +function AiMarkdownTableCell({ children, className, ...props @@ -751,7 +697,7 @@ function KapaMarkdownTableCell({ ) } -function KapaMarkdownTableHeaderCell({ +function AiMarkdownTableHeaderCell({ children, className, ...props @@ -769,15 +715,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,76 +762,323 @@ const kapaMarkdownComponents = { ), } -const kapaMarkdownExtensions = [streamingMarkdownExtension()] +const aiMarkdownExtensions = [streamingMarkdownExtension()] -function parseSourceGroupIDs(value: string | undefined) { - if (!value) { - return undefined - } +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() + } + + 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 } - addFeedback(args: KapaSubmitFeedbackArgs): Promise { - return this.service.addFeedback(args) + if (Array.isArray(value)) { + for (const item of value) { + collectSourcesFromToolOutput(item, into, depth + 1) + } + return } - abortCurrent(): void { - this.service.abortCurrent() + 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 '' + } + } + + 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) +} + +/** + * 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 ?? [] + + 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') { + segments.push({ + type: 'tool-call', + toolCall: { + id: part.id, + query: describeToolCallQuery(part.input, part.arguments), + isPending: part.output === undefined && part.state !== 'error', + }, + }) + + if (part.output !== undefined) { + collectSourcesFromToolOutput(part.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(() => { + const transport = fetchServerSentEvents(AGENT_STUDIO_COMPLETIONS_URL, { + headers: AGENT_STUDIO_AUTH_HEADERS, + }) + + // Two Agent Studio quirks to work around when replaying history: + // - TanStack AI stamps `metadata.tanstack` on every wire message (from + // the UIMessage's `metadata` and `createdAt`), but the AG-UI request + // model rejects `metadata` on assistant messages ("Extra inputs are + // not permitted") and 422s. + // - A run that ends mid-search leaves a tool call without a result in + // history; replaying that orphan `tool_use` makes the LLM provider + // reject the whole conversation ("Bad request to provider"). + // Both are stripped from the outgoing copies only. + const sanitizingConnect: ConnectConnectionAdapter['connect'] = ( + messages, + data, + abortSignal, + runContext, + ) => + transport.connect( + isUIMessageArray(messages) + ? messages.map( + ({ metadata: _metadata, createdAt: _createdAt, ...message }) => ({ + ...message, + parts: message.parts.filter( + (part) => + part.type !== 'tool-call' || + part.output !== undefined || + message.parts.some( + (sibling) => + sibling.type === 'tool-result' && + sibling.toolCallId === part.id, + ), + ), + }), + ) + : messages, + data, + abortSignal, + runContext, + ) + + return { ...transport, connect: sanitizingConnect } + }, []) + 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, + vote: reaction === 'upvote' ? 1 : 0, + }), + }).catch(() => { + // Keep the optimistic reaction; feedback is best-effort. + }) + }, + [], + ) + + return { + conversation, + submitQuery: sendMessage, + isBusy: isLoading, + stopGeneration: stop, + addFeedback, + errorMessage: error?.message ?? null, + } +} + +/** AG-UI adapters accept UI or model messages; only UI messages carry parts. */ +function isUIMessageArray( + messages: Parameters[0], +): messages is Array { + return messages.every((message) => 'parts' in message) } function isUnknownRecord(value: unknown): value is Record { @@ -879,7 +1097,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 +1105,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 +1131,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 +1148,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 +1165,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 +1176,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 +1200,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 +1236,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 +1253,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 +1297,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 +1427,7 @@ function CopyChatButton({ conversation, compact = false, }: { - conversation: Array + conversation: Array compact?: boolean }) { const [copied, setCopied] = React.useState(false) @@ -1277,17 +1495,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 +1576,7 @@ function KapaHistoryButton({ {item.title} - {formatKapaHistoryTime(item.updatedAt)} + {formatChatHistoryTime(item.updatedAt)} {isActive ? ( @@ -1431,12 +1649,24 @@ function AIMessageHeader({ action }: { action?: React.ReactNode }) { TanStack - answers by kapa.ai + + powered by + + Algolia + Algolia @@ -1471,7 +1701,7 @@ function AlgoliaAttribution() { ) } -function getKapaSuggestions(selectedLibrary: string) { +function getAiSuggestions(selectedLibrary: string) { if (selectedLibrary === 'query') { return [ 'How do I invalidate a query?', @@ -1519,7 +1749,7 @@ function getKapaSuggestions(selectedLibrary: string) { ] } -function KapaWelcome({ +function AiWelcome({ selectedLibrary, selectedFramework, onSuggestion, @@ -1532,7 +1762,7 @@ function KapaWelcome({ disabled?: boolean compact?: boolean }) { - const suggestions = getKapaSuggestions(selectedLibrary) + const suggestions = getAiSuggestions(selectedLibrary) return (
@@ -1590,7 +1820,7 @@ function KapaWelcome({ ) } -function KapaAnswer({ +function AiAnswer({ qa, isStreaming, error, @@ -1598,7 +1828,7 @@ function KapaAnswer({ onFeedback, compact = false, }: { - qa: KapaDisplayQA + qa: DisplayQA isStreaming: boolean error?: string | null onCopyQuestion: () => void @@ -1608,6 +1838,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 +1872,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 +2014,10 @@ function KapaAnswer({ ) } -function KapaChatPanel({ +function AiChatPanel({ onReset, isFullHeight, onToggleFullHeight, - threadIdOverrideRef, newChatRequestId, surface, isDockMaximized = false, @@ -1758,23 +2026,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 +2049,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 +2084,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 +2131,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 +2178,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 +2203,9 @@ function KapaChatPanel({ handledAiDockAskRequestId.current = aiDockAskRequest.id clearActiveChat() - ask(aiDockAskRequest.question) + setPendingQuestion(aiDockAskRequest.question) clearAiDockAskRequest(aiDockAskRequest.id) }, [ - ask, aiDockAskRequest, clearActiveChat, clearAiDockAskRequest, @@ -2021,7 +2281,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 +2413,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 +2428,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', From d4cfb596a1f1dbc5f6a15573f50c590c9470060d Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Thu, 27 Aug 2026 19:22:04 +0200 Subject: [PATCH 2/5] Drop AG-UI client workarounds fixed server-side Agent Studio now tolerates vendor metadata on AG-UI messages and prunes unresolved historical tool calls before provider replay (algolia/conversational-ai#1629), so the outgoing-message sanitizer is no longer needed. --- src/components/SearchModal.tsx | 60 ++++------------------------------ 1 file changed, 7 insertions(+), 53 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 6c9e44de9..b0e766d49 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -38,7 +38,6 @@ import { import { fetchServerSentEvents, useChat, - type ConnectConnectionAdapter, type UIMessage, } from '@tanstack/ai-react' import { streamingMarkdownExtension } from '@tanstack/markdown/extensions/streaming' @@ -983,51 +982,13 @@ function messagesToDisplayQAs( * `threadId` on the server, so resuming a thread only needs the same id. */ function useAgentStudioChat(threadId: string) { - const connection = React.useMemo(() => { - const transport = fetchServerSentEvents(AGENT_STUDIO_COMPLETIONS_URL, { - headers: AGENT_STUDIO_AUTH_HEADERS, - }) - - // Two Agent Studio quirks to work around when replaying history: - // - TanStack AI stamps `metadata.tanstack` on every wire message (from - // the UIMessage's `metadata` and `createdAt`), but the AG-UI request - // model rejects `metadata` on assistant messages ("Extra inputs are - // not permitted") and 422s. - // - A run that ends mid-search leaves a tool call without a result in - // history; replaying that orphan `tool_use` makes the LLM provider - // reject the whole conversation ("Bad request to provider"). - // Both are stripped from the outgoing copies only. - const sanitizingConnect: ConnectConnectionAdapter['connect'] = ( - messages, - data, - abortSignal, - runContext, - ) => - transport.connect( - isUIMessageArray(messages) - ? messages.map( - ({ metadata: _metadata, createdAt: _createdAt, ...message }) => ({ - ...message, - parts: message.parts.filter( - (part) => - part.type !== 'tool-call' || - part.output !== undefined || - message.parts.some( - (sibling) => - sibling.type === 'tool-result' && - sibling.toolCallId === part.id, - ), - ), - }), - ) - : messages, - data, - abortSignal, - runContext, - ) - - return { ...transport, connect: sanitizingConnect } - }, []) + const connection = React.useMemo( + () => + fetchServerSentEvents(AGENT_STUDIO_COMPLETIONS_URL, { + headers: AGENT_STUDIO_AUTH_HEADERS, + }), + [], + ) const { messages, sendMessage, isLoading, error, stop } = useChat({ connection, threadId, @@ -1074,13 +1035,6 @@ function useAgentStudioChat(threadId: string) { } } -/** AG-UI adapters accept UI or model messages; only UI messages carry parts. */ -function isUIMessageArray( - messages: Parameters[0], -): messages is Array { - return messages.every((message) => 'parts' in message) -} - function isUnknownRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } From d68be03b82c102c71408b609a0309a087e1e9074 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Thu, 27 Aug 2026 20:43:37 +0200 Subject: [PATCH 3/5] Point the AI dock at production Agent Studio Swaps the staging agent for the production one now that AG-UI compatibility mode and its message-tolerance fixes are deployed there. --- src/components/SearchModal.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index b0e766d49..99baad31d 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -223,14 +223,13 @@ const AI_DOCK_MAX_WIDTH_RATIO = 0.5 const AI_DOCK_MAXIMIZED_WIDTH = 1200 const DEFAULT_SEARCH_FRAMEWORK: Framework = 'react' -// Algolia Agent Studio (staging while the AG-UI compatibility mode rolls out -// to production). 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 = 'betaHAXPMHIMMC' -const AGENT_STUDIO_SEARCH_API_KEY = '8b00405cba281a7d800ccec393e9af24' -const AGENT_STUDIO_AGENT_ID = '97a1b15d-9837-48ed-b4b4-f905b0199cee' -const AGENT_STUDIO_BASE_URL = 'https://agent-studio.staging.eu.algolia.com/1' +// 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 = { From 643f37ec2d2b42db6d30f5dfad500ecc3cbb441f Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Thu, 27 Aug 2026 21:04:06 +0200 Subject: [PATCH 4/5] Address PR review: tool-result sources and feedback payload Collect answer sources from sibling tool-result parts, which hydrated threads use instead of inline tool-call output. Send the required agentId with feedback votes and unlock the thumbs buttons when the endpoint rejects a vote. --- src/components/SearchModal.tsx | 46 ++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 99baad31d..a51c1d12f 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -901,6 +901,19 @@ function describeToolCallQuery(input: unknown, rawArguments: string) { 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 @@ -939,23 +952,35 @@ function messagesToDisplayQAs( ) 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: part.output === undefined && part.state !== 'error', + isPending: output === undefined && part.state !== 'error', }, }) - if (part.output !== undefined) { - collectSourcesFromToolOutput(part.output, sources) + if (output !== undefined) { + collectSourcesFromToolOutput(output, sources) } } } @@ -1015,11 +1040,22 @@ function useAgentStudioChat(threadId: string) { }, body: JSON.stringify({ messageId, + agentId: AGENT_STUDIO_AGENT_ID, vote: reaction === 'upvote' ? 1 : 0, }), - }).catch(() => { - // Keep the optimistic reaction; feedback is best-effort. }) + .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 + }) + }) }, [], ) From 0816463c4c98fdba336aace94020f9b03ce6d81f Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Thu, 27 Aug 2026 21:43:20 +0200 Subject: [PATCH 5/5] Autolink bare URLs in AI answers The agent emits bare URLs in prose, and CommonMark only autolinks URLs wrapped in angle brackets. A markdown extension turns bare http(s) URLs in text nodes into links, leaving code spans and existing links alone, and long link labels now wrap cleanly inside the chat bubble. --- src/components/SearchModal.tsx | 64 ++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index a51c1d12f..20aeb9006 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -42,6 +42,7 @@ import { } 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' @@ -613,7 +614,7 @@ function AiMarkdownLink({ return ( {children} @@ -760,7 +761,66 @@ const aiMarkdownComponents = { ), } -const aiMarkdownExtensions = [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 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'