diff --git a/package.json b/package.json index c42e37dfe..645b9431d 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,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.31.0", "@octokit/graphql": "^9.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20ff1f131..aaf98aa26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,9 +82,6 @@ importers: '@js-temporal/polyfill': specifier: ^0.5.1 version: 0.5.1 - '@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 @@ -1696,15 +1693,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'} @@ -2055,12 +2043,6 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@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==} @@ -6416,9 +6398,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==} @@ -7952,13 +7931,6 @@ packages: resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} - tldts-core@7.4.2: - resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} - - tldts@7.4.2: - resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} - hasBin: true - tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -9660,15 +9632,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 @@ -9913,16 +9876,6 @@ snapshots: dependencies: jsbi: 4.3.2 - '@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': @@ -14531,8 +14484,6 @@ snapshots: jose@6.2.2: {} - js-cookie@3.0.8: {} - js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -16209,12 +16160,6 @@ snapshots: tinyrainbow@3.2.0: {} - tldts-core@7.4.2: {} - - tldts@7.4.2: - dependencies: - tldts-core: 7.4.2 - tmp-promise@3.0.3: dependencies: tmp: 0.2.7 diff --git a/src/components/AiDock.tsx b/src/components/AiDock.tsx index 371185582..91cfcf00b 100644 --- a/src/components/AiDock.tsx +++ b/src/components/AiDock.tsx @@ -29,22 +29,21 @@ 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 { autolinkBareUrlsExtension } from '~/components/markdown/autolinkBareUrls' +import { LibraryWordmark } from '~/components/LibraryWordmark' import { useSearchContext } from '~/contexts/SearchContext' -import { publicLibraries } from '~/libraries' +import { ai, publicLibraries } from '~/libraries' import { frameworkOptions } from '~/libraries/frameworks' import { capitalize } from '~/utils/utils' import { CodeBlock } from '~/components/markdown/CodeBlock' import { copyTextToClipboard } from '~/utils/browser-effects' import { InlineCode } from '~/ui/InlineCode' -import { env } from '~/utils/env' import { isSafeHref } from '~/utils/url-boundary' import { type AlgoliaHit, @@ -68,8 +67,20 @@ const AI_DOCK_MIN_WIDTH = 320 const AI_DOCK_DEFAULT_WIDTH = 360 const AI_DOCK_MAX_WIDTH_RATIO = 0.5 const AI_DOCK_MAXIMIZED_WIDTH = 1200 -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 & { @@ -114,72 +125,7 @@ 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 KapaMarkdownLink({ +function AiMarkdownLink({ href, children, ...props @@ -187,7 +133,7 @@ function KapaMarkdownLink({ return ( {children} @@ -195,7 +141,7 @@ function KapaMarkdownLink({ ) } -function KapaMarkdownTable({ +function AiMarkdownTable({ children, className, ...props @@ -215,7 +161,7 @@ function KapaMarkdownTable({ ) } -function KapaMarkdownTableHead({ +function AiMarkdownTableHead({ children, className, ...props @@ -233,7 +179,7 @@ function KapaMarkdownTableHead({ ) } -function KapaMarkdownTableBody({ +function AiMarkdownTableBody({ children, className, ...props @@ -251,7 +197,7 @@ function KapaMarkdownTableBody({ ) } -function KapaMarkdownTableCell({ +function AiMarkdownTableCell({ children, className, ...props @@ -269,7 +215,7 @@ function KapaMarkdownTableCell({ ) } -function KapaMarkdownTableHeaderCell({ +function AiMarkdownTableHeaderCell({ children, className, ...props @@ -287,15 +233,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 }) => ( ), @@ -309,75 +280,342 @@ const kapaMarkdownComponents = { ), } -const kapaMarkdownExtensions = [streamingMarkdownExtension()] +const aiMarkdownExtensions = [ + streamingMarkdownExtension(), + autolinkBareUrlsExtension, +] -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 + 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, - ) +const CHAT_HISTORY_STORAGE_KEY = 'tanstack-ai-chat-history' +const CHAT_HISTORY_LIMIT = 5 + +/** + * Which Agent Studio thread the panel is on, plus the QAs restored from local + * history for that thread. Agent Studio persists conversations server-side + * keyed by `threadId`, so resuming only needs the id; the saved QAs render + * as the prefix of the transcript, ahead of whatever the live client holds. + */ +type ThreadSelection = { + threadId: string + savedConversation: Array +} + +function freshThread(): ThreadSelection { + return { threadId: crypto.randomUUID(), savedConversation: [] } +} + +/** + * Re-selecting the thread that is already live must be a no-op: the client + * keeps its messages when `threadId` is unchanged, so restoring the saved + * transcript as a prefix would duplicate every answer. + */ +function selectThread( + current: ThreadSelection, + item: ChatHistoryItem, +): ThreadSelection { + if (item.threadId === current.threadId) { + return current + } + + return { threadId: item.threadId, savedConversation: item.conversation } +} + +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 } - addFeedback(args: KapaSubmitFeedbackArgs): Promise { - return this.service.addFeedback(args) + if (!isUnknownRecord(value)) { + return } - abortCurrent(): void { - this.service.abortCurrent() + 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) +} + +/** 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, clear } = 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, + // Cancels any in-flight stream and drops messages *and* the error, which + // would otherwise outlive a threadId change (the SDK only resets messages). + resetChat: clear, + addFeedback, + errorMessage: error?.message ?? null, } } @@ -397,7 +635,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 } @@ -405,7 +643,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 } @@ -431,7 +669,7 @@ function parseKapaSource(value: unknown): StreamSource | null { } } -function parseKapaHistoryQA(value: unknown): KapaDisplayQA | null { +function parseChatHistoryQA(value: unknown): DisplayQA | null { if (!isUnknownRecord(value)) { return null } @@ -448,14 +686,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 } @@ -465,7 +703,7 @@ function parseKapaHistoryItem(value: unknown): KapaHistoryItem | null { ? value.conversation : [] const conversation = conversationValues - .map(parseKapaHistoryQA) + .map(parseChatHistoryQA) .filter(isDefined) if (!threadId || conversation.length === 0) { @@ -476,20 +714,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 [] } @@ -500,31 +738,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) @@ -536,7 +774,7 @@ function buildKapaHistoryTitle(conversation: Array) { : firstQuestion } -function toStoredKapaQA(qa: KapaDisplayQA): KapaDisplayQA { +function toStoredQA(qa: DisplayQA): DisplayQA { return { id: qa.id, question: qa.question, @@ -553,30 +791,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) { @@ -597,17 +835,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)) }, [], ) @@ -727,7 +965,7 @@ function CopyChatButton({ conversation, compact = false, }: { - conversation: Array + conversation: Array compact?: boolean }) { const [copied, setCopied] = React.useState(false) @@ -795,17 +1033,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() @@ -876,7 +1114,7 @@ function KapaHistoryButton({ {item.title} - {formatKapaHistoryTime(item.updatedAt)} + {formatChatHistoryTime(item.updatedAt)} {isActive ? ( @@ -927,43 +1165,7 @@ function MessageActionButton({ ) } -function AIMessageHeader({ action }: { action?: React.ReactNode }) { - return ( -
-
-
- TanStack - -
-
- - TanStack - - - answers by kapa.ai - -
-
- {action} -
- ) -} - -function getKapaSuggestions(selectedLibrary: string) { +function getAiSuggestions(selectedLibrary: string) { if (selectedLibrary === 'query') { return [ 'How do I invalidate a query?', @@ -1011,7 +1213,7 @@ function getKapaSuggestions(selectedLibrary: string) { ] } -function KapaWelcome({ +function AiWelcome({ selectedLibrary, selectedFramework, onSuggestion, @@ -1024,11 +1226,10 @@ function KapaWelcome({ disabled?: boolean compact?: boolean }) { - const suggestions = getKapaSuggestions(selectedLibrary) + const suggestions = getAiSuggestions(selectedLibrary) return (
-

void @@ -1100,6 +1301,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 (

@@ -1121,7 +1324,6 @@ function KapaAnswer({
-
{hasAnswerError ? (

{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} + + )}
) : (
@@ -1237,11 +1476,10 @@ function KapaAnswer({ ) } -function KapaChatPanel({ +function AiChatPanel({ onReset, isFullHeight, onToggleFullHeight, - threadIdOverrideRef, newChatRequestId, surface, isDockMaximized = false, @@ -1250,23 +1488,22 @@ function KapaChatPanel({ onReset: () => void isFullHeight: boolean onToggleFullHeight: () => void - threadIdOverrideRef: React.MutableRefObject newChatRequestId: number surface: SearchSurface isDockMaximized?: boolean onToggleDockMaximized?: () => void }) { + const [thread, setThread] = React.useState(freshThread) + const { threadId, savedConversation } = thread const { conversation, - threadId, submitQuery, - isGeneratingAnswer, - isPreparingAnswer, + isBusy, stopGeneration, - resetConversation, + resetChat, addFeedback, - error, - } = useChat() + errorMessage, + } = useAgentStudioChat(threadId) const { aiDockAskRequest, clearAiDockAskRequest, @@ -1276,27 +1513,27 @@ function KapaChatPanel({ const { selectedLibrary, selectedFramework, showSearchResults, searchQuery } = useSearchFilters() const scrollRef = React.useRef(null) - const { items: historyItems, save: saveHistory } = useKapaChatHistory() - const [selectedHistoryItem, setSelectedHistoryItem] = - React.useState(null) - const isBusy = isGeneratingAnswer || isPreparingAnswer + const { items: historyItems, save: saveHistory } = useChatHistory() + // 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) - - if (!selectedHistoryItem) { - return liveConversation - } - - return [...selectedHistoryItem.conversation, ...liveConversation] - }, [conversation, selectedHistoryItem]) + const displayConversation = React.useMemo>( + () => + savedConversation.length === 0 + ? conversation + : [...savedConversation, ...conversation], + [conversation, savedConversation], + ) const hasConversation = displayConversation.length > 0 - const activeThreadId = threadId ?? selectedHistoryItem?.threadId ?? null + const activeThreadId = threadId React.useEffect(() => { if (!isDock) { @@ -1309,13 +1546,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 } @@ -1362,61 +1593,49 @@ 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 - setSelectedHistoryItem(item) + setThread((current) => selectThread(current, item)) lockedToBottom.current = true }, - [isBusy, resetConversation, threadIdOverrideRef], + [isBusy], ) const clearActiveChat = React.useCallback(() => { - pendingSubmitIdRef.current += 1 isSubmittingRef.current = false - resetConversation() - threadIdOverrideRef.current = null - setSelectedHistoryItem(null) + resetChat() + setThread(freshThread()) lockedToBottom.current = true onReset() - }, [onReset, resetConversation, threadIdOverrideRef]) + }, [onReset, resetChat]) - const startNewChat = React.useCallback(() => { - if (isBusy) { - stopGeneration() + React.useEffect(() => { + if (handledNewChatRequestId.current === newChatRequestId) { + return } + handledNewChatRequestId.current = newChatRequestId clearActiveChat() - }, [clearActiveChat, isBusy, stopGeneration]) + }, [newChatRequestId, clearActiveChat]) React.useEffect(() => { - if (handledNewChatRequestId.current === newChatRequestId) { + if (pendingQuestion === null || isBusy) { return } - handledNewChatRequestId.current = newChatRequestId - startNewChat() - }, [newChatRequestId, startNewChat]) + setPendingQuestion(null) + ask(pendingQuestion) + }, [ask, isBusy, pendingQuestion]) React.useEffect(() => { if (!isDock || !aiDockAskRequest) { @@ -1434,10 +1653,9 @@ function KapaChatPanel({ handledAiDockAskRequestId.current = aiDockAskRequest.id clearActiveChat() - ask(aiDockAskRequest.question) + setPendingQuestion(aiDockAskRequest.question) clearAiDockAskRequest(aiDockAskRequest.id) }, [ - ask, aiDockAskRequest, clearActiveChat, clearAiDockAskRequest, @@ -1469,10 +1687,39 @@ function KapaChatPanel({ {isDock ? null : }
{isDock ? ( -
-
- TanStack AI -
+
+ + + + + + Algolia + Algolia +
) : ( <> @@ -1513,7 +1760,7 @@ function KapaChatPanel({ onToggle={onToggleDockMaximized} /> ) : null} -
)}
@@ -1643,122 +1886,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, @@ -1774,23 +1901,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} + />
) } @@ -2105,10 +2197,10 @@ function SearchResultsInChat({ surface }: { surface: SearchSurface }) { }} >
-
+
diff --git a/src/components/markdown/autolinkBareUrls.ts b/src/components/markdown/autolinkBareUrls.ts new file mode 100644 index 000000000..6b62464ef --- /dev/null +++ b/src/components/markdown/autolinkBareUrls.ts @@ -0,0 +1,72 @@ +import type { InlineNode, MarkdownExtension } from '@tanstack/markdown' + +// 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 = /[.,;:!?'"]+$/ + +// GFM autolink rule: a trailing `)` is only punctuation when the URL has +// more closing than opening parens, so `/routes/(auth)` stays intact while +// `(see https://x.dev/a)` drops the wrapper. +function trimBareUrl(raw: string) { + let url = raw.replace(TRAILING_PUNCTUATION_PATTERN, '') + + while (url.endsWith(')') && url.split(')').length > url.split('(').length) { + url = url.slice(0, -1).replace(TRAILING_PUNCTUATION_PATTERN, '') + } + + return url +} + +export 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 = trimBareUrl(match[0]) + + 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 + }) +} + +export const autolinkBareUrlsExtension: MarkdownExtension = { + name: 'autolink-bare-urls', + transformInline: autolinkInlineNodes, +} diff --git a/src/styles/app.css b/src/styles/app.css index 9b06041ab..72623f57f 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -3009,12 +3009,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/tests/ai-dock-autolink.test.ts b/tests/ai-dock-autolink.test.ts new file mode 100644 index 000000000..46d8ec71a --- /dev/null +++ b/tests/ai-dock-autolink.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { InlineNode } from '@tanstack/markdown' +import { autolinkInlineNodes } from '../src/components/markdown/autolinkBareUrls' + +function autolink(value: string) { + return autolinkInlineNodes([{ type: 'text', value }]) +} + +function hrefs(nodes: Array) { + return nodes.flatMap((node) => (node.type === 'link' ? [node.href] : [])) +} + +test('bare URLs in prose become links', () => { + const nodes = autolink('See https://tanstack.com/router for details.') + + assert.deepEqual(hrefs(nodes), ['https://tanstack.com/router']) + assert.deepEqual(nodes[0], { type: 'text', value: 'See ' }) + assert.deepEqual(nodes[2], { type: 'text', value: ' for details.' }) +}) + +test('balanced parentheses stay part of the URL', () => { + assert.deepEqual( + hrefs(autolink('https://github.com/x/y/blob/main/routes/(auth)')), + ['https://github.com/x/y/blob/main/routes/(auth)'], + ) +}) + +test('a wrapping parenthesis is not part of the URL', () => { + assert.deepEqual(hrefs(autolink('(see https://tanstack.com/start).')), [ + 'https://tanstack.com/start', + ]) + assert.deepEqual(hrefs(autolink('(https://tanstack.com/routes/(auth))')), [ + 'https://tanstack.com/routes/(auth)', + ]) +}) + +test('non-text inline nodes are left alone', () => { + const code: InlineNode = { + type: 'inlineCode', + value: 'https://tanstack.com', + } + const link: InlineNode = { + type: 'link', + href: 'https://tanstack.com', + children: [{ type: 'text', value: 'https://tanstack.com' }], + } + + assert.deepEqual(autolinkInlineNodes([code, link]), [code, link]) +}) diff --git a/vite.config.ts b/vite.config.ts index eda40a8d1..3acff5669 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -149,7 +149,7 @@ const serverVariantAliases: Record = { const serverBundledClientPackages = [ ...(shouldUseRedact ? ['@tanstack/redact'] : []), /^@radix-ui\//, - '@kapaai/react-sdk', + /^@tanstack\/ai(?:-|$)/, '@tanstack/highlight', '@tanstack/markdown', '@tanstack/react-hotkeys',