1
0
Fork 0
docsearch/packages/docsearch-react/src/utils/ai.ts

150 lines
4.2 KiB
TypeScript

import type { TextUIPart } from 'ai';
import type { StoredAskAiState } from '../types';
import type {
AggregatedToolCallPart,
AIMessage,
AIMessagePart,
AIToolPart,
AlgoliaMCPSearchOutputPart,
SearchIndexOutputPart,
SearchOutputPart,
SearchToolPart,
ToolCalls,
} from '../types/AskiAi';
import { sanitizeUserInput } from './sanitize';
export interface ExtractedLink {
url: string;
title?: string;
}
// utility to extract links (markdown and bare urls) from a string
export function extractLinksFromMessage(message: AIMessage | null): ExtractedLink[] {
const links: ExtractedLink[] = [];
// Used to dedupe multiple urls
const seen = new Set<string>();
if (!message) {
return [];
}
message.parts.forEach((part) => {
if (part.type !== 'text') {
return;
}
if (part.text.length === 0) {
return;
}
const markdownLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
const plainLinkRegex = /(?<!\]\()https?:\/\/[^\s<>"{}|\\^`[\]]+/g;
// Strip out all code blocks e.g. ```
const textWithoutCodeBlocks = part.text.replace(/```[\s\S]*?```/g, '');
// Strip out all inline code blocks e.g. `
const cleanText = textWithoutCodeBlocks.replace(/`[^`]*`/g, '');
// Get all markdown based links e.g. []()
const markdownMatches = cleanText.matchAll(markdownLinkRegex);
// Parses the title and url from the found links
for (const match of markdownMatches) {
const title = match[1].trim();
const url = match[2];
if (!seen.has(url)) {
seen.add(url);
links.push({ url, title: title || undefined });
}
}
// Get all "plain" links e.g. https://algolia.com/doc
const plainUrls = cleanText.matchAll(plainLinkRegex);
for (const match of plainUrls) {
// Strip any extra punctuation
const cleanUrl = match[0].replace(/[.,;:!?]+$/, '');
if (!seen.has(cleanUrl)) {
seen.add(cleanUrl);
links.push({ url: cleanUrl });
}
}
});
return links;
}
export const buildDummyAskAiHit = (query: string, messages: AIMessage[], chatId?: string): StoredAskAiState => {
const textPart = messages[0].parts.find((part) => part.type === 'text');
const sanitizedText = textPart?.text ? sanitizeUserInput(textPart.text) : '';
return {
query,
objectID: sanitizedText,
chatId,
messages,
type: 'askAI',
anchor: 'stored',
// dummy content to make it a valid hit
// this is useful to show it among other hits
content: null,
hierarchy: {
lvl0: 'askAI',
lvl1: sanitizedText, // use first message as hit name (sanitized to prevent XSS)
lvl2: new Date().toISOString(),
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
url: '',
url_without_anchor: '',
};
};
export const getMessageContent = (message: AIMessage | null): TextUIPart | undefined =>
message?.parts.find((part) => part.type === 'text');
/**
* Helper function to check if error is a thread depth error (AI-217).
*/
export function isThreadDepthError(error?: Error): boolean {
if (!error) return false;
return error.message?.includes('AI-217') || false;
}
export const EMPTY_TOOLS: Readonly<ToolCalls> = Object.freeze({});
export function isAIToolPart(part: AggregatedToolCallPart | AIMessagePart): part is AIToolPart {
return part.type.startsWith('tool-');
}
export function isSearchToolPart(part: AIToolPart): part is SearchToolPart {
return (
part.type === 'tool-searchIndex' ||
part.type === 'tool-algolia_search_index' ||
part.type.startsWith('tool-algolia_search_index_')
);
}
export function isSearchIndexOutputPart(part: AIMessagePart): part is SearchIndexOutputPart {
return part.type === 'tool-searchIndex' && part.state === 'output-available';
}
export function isAlgoliaMCPSearchOutputPart(part: AIMessagePart): part is AlgoliaMCPSearchOutputPart {
return (
isAIToolPart(part) &&
(part.type === 'tool-algolia_search_index' || part.type.startsWith('tool-algolia_search_index_')) &&
part.state === 'output-available'
);
}
export function isSearchOutputPart(part: AIMessagePart): part is SearchOutputPart {
return isAIToolPart(part) && isSearchToolPart(part) && part.state === 'output-available';
}