diff --git a/components/DeepResearch/DeepResearch.vue b/components/DeepResearch/DeepResearch.vue index 46ec20b..dfaf4d1 100644 --- a/components/DeepResearch/DeepResearch.vue +++ b/components/DeepResearch/DeepResearch.vue @@ -12,6 +12,7 @@ } from '~/constants/injection-keys' import Flow from './SearchFlow.vue' import SearchFlow from './SearchFlow.vue' + import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node' export type DeepResearchNodeStatus = Exclude @@ -115,7 +116,7 @@ } } // Update the node - if (!isRootNode(node)) { + if (!isRootNode(node.id)) { node.label = step.result.query ?? '' node.researchGoal = step.result.researchGoal } @@ -196,10 +197,6 @@ } } - function isRootNode(node: DeepResearchNode) { - return node.id === '0' - } - function selectNode(nodeId: string) { if (selectedNodeId.value === nodeId) { selectedNodeId.value = undefined @@ -208,37 +205,103 @@ } } - async function startResearch() { + async function startResearch(retryNode?: DeepResearchNode) { if (!form.value.query || !form.value.breadth || !form.value.depth) return - nodes.value = [{ ...rootNode }] - selectedNodeId.value = undefined - searchResults.value = {} - isLoading.value = true - flowRef.value?.clearNodes() + // 如果不是重试,清空所有节点 + if (!retryNode) { + nodes.value = [{ ...rootNode }] + selectedNodeId.value = undefined + searchResults.value = {} + flowRef.value?.clearNodes() + isLoading.value = true + } // Wait after the flow is cleared await new Promise((r) => requestAnimationFrame(r)) try { - const searchLanguage = config.value.webSearch.searchLanguage - ? t('language', {}, { locale: config.value.webSearch.searchLanguage }) - : undefined + let query = getCombinedQuery(form.value, feedback.value) + let existingLearnings: string[] = [] + let existingVisitedUrls: string[] = [] + let currentDepth = 1 + let breadth = form.value.breadth + + if (retryNode) { + query = retryNode.label + // Set the search depth and breadth to its parent's + if (!isRootNode(retryNode.id)) { + const parentId = parentNodeId(retryNode.id)! + currentDepth = nodeDepth(parentId) + breadth = searchBreadth(breadth, parentId) + } + // Collect all parent nodes' learnings and visitedUrls + const parentNodes = nodes.value.filter((n) => + isParentNode(n.id, retryNode.id), + ) + existingLearnings = parentNodes + .flatMap((n) => n.learnings || []) + .filter(Boolean) + existingVisitedUrls = parentNodes + .flatMap((n) => n.searchResults || []) + .map((r) => r.url) + .filter(Boolean) + } + await deepResearch({ - query: getCombinedQuery(form.value, feedback.value), + query, + retryNode, + currentDepth, + breadth, maxDepth: form.value.depth, - breadth: form.value.breadth, languageCode: locale.value, - searchLanguage, + learnings: existingLearnings, + visitedUrls: existingVisitedUrls, onProgress: handleResearchProgress, }) } catch (error) { console.error('Research failed:', error) } finally { - isLoading.value = false + if (!retryNode) { + isLoading.value = false + } } } + async function retryNode(nodeId: string) { + console.log('[DeepResearch] retryNode', nodeId, isLoading.value) + if (!nodeId || isLoading.value) return + + // Remove all child nodes first + nodes.value = nodes.value.filter((n) => !isChildNode(nodeId, n.id)) + flowRef.value?.removeChildNodes(nodeId) + + const node = nodes.value.find((n) => n.id === nodeId) + // Take a clone of the node + // Used in `deepResearch()` to access the node's original query and searchGoal + let nodeCurrentData: DeepResearchNode | undefined + + if (node) { + nodeCurrentData = { ...node } + node.status = undefined + node.error = undefined + node.searchResults = undefined + node.learnings = undefined + node.generateLearningsReasoning = undefined + node.generateQueriesReasoning = undefined + + // Remove related search results + delete searchResults.value[nodeId] + Object.keys(searchResults.value).forEach((key) => { + if (isChildNode(nodeId, key)) { + delete searchResults.value[key] + } + }) + } + + await startResearch(nodeCurrentData) + } + defineExpose({ startResearch, isLoading, @@ -273,6 +336,11 @@ color="error" variant="soft" :duration="8000" + :actions="[{ + label: $t('webBrowsing.retry'), + color: 'secondary', + onClick: () => retryNode(selectedNode!.id), + }]" />

{{ selectedNode.label ?? $t('webBrowsing.generating') }} @@ -283,7 +351,7 @@ {{ t('webBrowsing.researchGoal') }}

-

+

{{ t('webBrowsing.startNode.description') }}

parentId.length && childId.startsWith(parentId) + } + + function removeChildNodes(parentId: string) { + const childNodes = nodes.value.filter((n) => isChildNode(parentId, n.id)) + childNodes.forEach((node) => { + // 移除节点和相关的边 + nodes.value = nodes.value.filter((n) => n.id !== node.id) + edges.value = edges.value.filter( + (e) => e.source !== node.id && e.target !== node.id, + ) + }) + } + function handleDrag(e: PointerEvent | FlowEvents['move']) { // Triggered by VueFlow internal logic if ('event' in e && !e.event.sourceEvent) { @@ -109,6 +124,7 @@ addNode, updateNode, clearNodes, + removeChildNodes, }) diff --git a/i18n/en.json b/i18n/en.json index dd29069..e19dcc8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -87,7 +87,8 @@ "generating": "Generating...", "nodeFailed": "Search failed", "nodeFailedToast": "Search node \"{label}\" failed", - "followUpQuestions": "Follow-up Questions" + "followUpQuestions": "Follow-up Questions", + "retry": "Retry" }, "researchReport": { "title": "4. Research Report", diff --git a/i18n/nl.json b/i18n/nl.json index d3f87f8..0e93c11 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -1,113 +1,114 @@ -{ - "language": "Nederlands", - "modelThinking": "Nadenken...", - "modelThinkingComplete": "Nadenken klaar", - "invalidStructuredOutput": "Het model heeft content geretourneerd die onvolledig of ongeldig is en niet kan worden verwerkt. Probeer een groter of \"slimmer\" model te gebruiken.", - "index": { - "projectDescription": "Dit is een Web-UI voor {0} waarmee AI online kan zoeken en zelf dieper kan graven op basis van specifieke vragen, en vervolgens een onderzoeksrapport kan genereren.\nDit project bevat AI-reacties voor realtime feedback en visualisatie van het onderzoeksproces met behulp van een boomdiagram.\nAlle API-aanvragen worden direct vanuit uw browser verzonden. Er worden geen gegevens extern opgeslagen.", - "missingConfigTitle": "Configuratie niet compleet", - "missingConfigDescription": "Voor dit project moet u uw eigen API-sleutels meenemen." - }, - "settings": { - "title": "Instellingen", - "disclaimer": "Instellingen zijn lokaal opgeslagen in de browser.", - "save": "Opslaan", - "ai": { - "provider": "AI Provider", - "apiKey": "API sleutel", - "apiBase": "API Basis-URL", - "model": "Model", - "contextSize": "Contextlengte", - "contextSizeHelp": "Het maximale aantal tokens dat naar het model wordt verzonden. Kan niet groter zijn dan de contextlengte van het model.", - "providers": { - "openaiCompatible": { - "title": "OpenAI-compatible", - "description": "Elke provider die compatibel is met de OpenAI-API. Let op: Sommige providers hebben hun eigen opties voor betere prestaties." - }, - "siliconflow": { - "title": "SiliconFlow", - "description": "Biedt ¥14 (€1,85) gratis credits bij registratie. Krijg één API-sleutel bij {0}." - } - } - }, - "webSearch": { - "provider": "Zoekmachine provider", - "apiKey": "API-sleutel", - "queryLanguage": "Zoekopdracht taal", - "queryLanguageHelp": "De taal van de zoekopdracht. Handig als u zoekresultaten in een andere taal wilt krijgen.\nBij het schrijven van conclusies gebruikt het AI-model nog steeds dezelfde taal als de web-UI.", - "providers": { - "tavily": { - "help": "Vergelijkbaar met Firecrawl, maar biedt 1000 gratis credits / maand. Krijg één API-sleutel bij {0}.", - "advancedSearch": "Uitgebreid zoeken", - "advancedSearchHelp": "Verkrijg betere resultaten. Kost elke keer 1 credit extra.", - "searchTopic": "Zoekonderwerp", - "searchTopicHelp": "Optimaliseer de zoekopdracht voor het geselecteerde onderwerp met op maat gemaakte en samengestelde informatie. Standaard 'general'." - }, - "firecrawl": { - "help": "Ontvang een API-sleutel bij {0} als u de officiële service gebruikt." - } - }, - "concurrencyLimitHelp": "Beperk de gelijktijdige zoektaken. Dit is handig om te voorkomen dat de zoekmachine overbelast raakt en verzoeken mislukken.", - "concurrencyLimit": "Concurrency Limit", - "apiBase": "API Basis-URL" - } - }, - "researchTopic": { - "title": "1. Onderzoeksonderwerp", - "inputTitle": "Onderzoeksonderwerp", - "placeholder": "Wat je maar wilt onderzoeken...", - "numOfQuestions": "Aantal vragen", - "numOfQuestionsHelp": "Het aantal vervolgvragen ter verduidelijking.", - "depth": "Diepte", - "depthHelp": "Aantal iteraties.", - "breadth": "Breedte", - "breadthHelp": "Aantal zoekopdrachten in de eerste iteratie. De zoekbreedte van elke iteratie is de helft van de vorige.", - "start": "Begin Onderzoek", - "researching": "Onderzoeken..." - }, - "modelFeedback": { - "title": "2. Modelfeedback", - "description": "De AI stelt u een aantal vervolgvragen om de onderzoeksrichting te verduidelijken.", - "waiting": "Wachten op feedback van het model...", - "submit": "Antwoord versturen", - "error": "Fout bij het ontvangen van feedback: {0}", - "noQuestions": "Het model heeft geen vervolgvragen geretourneerd." - }, - "webBrowsing": { - "title": "3. Zoeken op het Internet", - "description": "De AI zal vervolgens het internet doorzoeken op basis van ons onderzoeksdoel, en herhalen totdat de diepte is bereikt.", - "clickToView": "Klik op een onderliggend knooppunt om details te bekijken.", - "nodeDetails": "Knooppuntdetails", - "startNode": { - "description": "Dit is het begin van uw diepgaande onderzoeksreis!" - }, - "researchGoal": "Onderzoeksdoel", - "visitedUrls": "Bezochte websites", - "learnings": "Lessen", - "generating": "Genereren...", - "nodeFailed": "Zoeken mislukt", - "nodeFailedToast": "Zoekknooppunt \"{label}\" is mislukt", - "followUpQuestions": "Vervolgvragen" - }, - "researchReport": { - "title": "4. Onderzoeksrapport", - "exportPdf": "PDF exporteren", - "exportMarkdown": "Markdown exporteren", - "sources": "Bronnen", - "waiting": "Wachten op het rapport...", - "generating": "Rapport genereren...", - "error": "Rapport genereren mislukt: {0}", - "downloadingFonts": "Het downloaden van de benodigde lettertypen kan enige tijd duren...", - "downloadFontFailed": "Downloaden van lettertype mislukt", - "regenerate": "Regenereren" - }, - "error": { - "requestBlockedByCORS": "De huidige API-provider staat mogelijk geen cross-origin-verzoeken toe. Probeer een andere API-provider of neem contact op met de provider voor ondersteuning.." - }, - "autoUpdate": { - "newVersionTitle": "Nieuwe versie beschikbaar: {0}", - "newVersionDescription": "Let op: Als u een zelfgehoste versie gebruikt, implementeer deze dan opnieuw om nieuwe functies en bugfixes te krijgen.", - "refresh": "Pagina vernieuwen", - "dismiss": "Afwijzen" - } -} +{ + "language": "Nederlands", + "modelThinking": "Nadenken...", + "modelThinkingComplete": "Nadenken klaar", + "invalidStructuredOutput": "Het model heeft content geretourneerd die onvolledig of ongeldig is en niet kan worden verwerkt. Probeer een groter of \"slimmer\" model te gebruiken.", + "index": { + "projectDescription": "Dit is een Web-UI voor {0} waarmee AI online kan zoeken en zelf dieper kan graven op basis van specifieke vragen, en vervolgens een onderzoeksrapport kan genereren.\nDit project bevat AI-reacties voor realtime feedback en visualisatie van het onderzoeksproces met behulp van een boomdiagram.\nAlle API-aanvragen worden direct vanuit uw browser verzonden. Er worden geen gegevens extern opgeslagen.", + "missingConfigTitle": "Configuratie niet compleet", + "missingConfigDescription": "Voor dit project moet u uw eigen API-sleutels meenemen." + }, + "settings": { + "title": "Instellingen", + "disclaimer": "Instellingen zijn lokaal opgeslagen in de browser.", + "save": "Opslaan", + "ai": { + "provider": "AI Provider", + "apiKey": "API sleutel", + "apiBase": "API Basis-URL", + "model": "Model", + "contextSize": "Contextlengte", + "contextSizeHelp": "Het maximale aantal tokens dat naar het model wordt verzonden. Kan niet groter zijn dan de contextlengte van het model.", + "providers": { + "openaiCompatible": { + "title": "OpenAI-compatible", + "description": "Elke provider die compatibel is met de OpenAI-API. Let op: Sommige providers hebben hun eigen opties voor betere prestaties." + }, + "siliconflow": { + "title": "SiliconFlow", + "description": "Biedt ¥14 (€1,85) gratis credits bij registratie. Krijg één API-sleutel bij {0}." + } + } + }, + "webSearch": { + "provider": "Zoekmachine provider", + "apiKey": "API-sleutel", + "queryLanguage": "Zoekopdracht taal", + "queryLanguageHelp": "De taal van de zoekopdracht. Handig als u zoekresultaten in een andere taal wilt krijgen.\nBij het schrijven van conclusies gebruikt het AI-model nog steeds dezelfde taal als de web-UI.", + "providers": { + "tavily": { + "help": "Vergelijkbaar met Firecrawl, maar biedt 1000 gratis credits / maand. Krijg één API-sleutel bij {0}.", + "advancedSearch": "Uitgebreid zoeken", + "advancedSearchHelp": "Verkrijg betere resultaten. Kost elke keer 1 credit extra.", + "searchTopic": "Zoekonderwerp", + "searchTopicHelp": "Optimaliseer de zoekopdracht voor het geselecteerde onderwerp met op maat gemaakte en samengestelde informatie. Standaard 'general'." + }, + "firecrawl": { + "help": "Ontvang een API-sleutel bij {0} als u de officiële service gebruikt." + } + }, + "concurrencyLimitHelp": "Beperk de gelijktijdige zoektaken. Dit is handig om te voorkomen dat de zoekmachine overbelast raakt en verzoeken mislukken.", + "concurrencyLimit": "Concurrency Limit", + "apiBase": "API Basis-URL" + } + }, + "researchTopic": { + "title": "1. Onderzoeksonderwerp", + "inputTitle": "Onderzoeksonderwerp", + "placeholder": "Wat je maar wilt onderzoeken...", + "numOfQuestions": "Aantal vragen", + "numOfQuestionsHelp": "Het aantal vervolgvragen ter verduidelijking.", + "depth": "Diepte", + "depthHelp": "Aantal iteraties.", + "breadth": "Breedte", + "breadthHelp": "Aantal zoekopdrachten in de eerste iteratie. De zoekbreedte van elke iteratie is de helft van de vorige.", + "start": "Begin Onderzoek", + "researching": "Onderzoeken..." + }, + "modelFeedback": { + "title": "2. Modelfeedback", + "description": "De AI stelt u een aantal vervolgvragen om de onderzoeksrichting te verduidelijken.", + "waiting": "Wachten op feedback van het model...", + "submit": "Antwoord versturen", + "error": "Fout bij het ontvangen van feedback: {0}", + "noQuestions": "Het model heeft geen vervolgvragen geretourneerd." + }, + "webBrowsing": { + "title": "3. Zoeken op het Internet", + "description": "De AI zal vervolgens het internet doorzoeken op basis van ons onderzoeksdoel, en herhalen totdat de diepte is bereikt.", + "clickToView": "Klik op een onderliggend knooppunt om details te bekijken.", + "nodeDetails": "Knooppuntdetails", + "startNode": { + "description": "Dit is het begin van uw diepgaande onderzoeksreis!" + }, + "researchGoal": "Onderzoeksdoel", + "visitedUrls": "Bezochte websites", + "learnings": "Lessen", + "generating": "Genereren...", + "nodeFailed": "Zoeken mislukt", + "nodeFailedToast": "Zoekknooppunt \"{label}\" is mislukt", + "followUpQuestions": "Vervolgvragen", + "retry": "Opnieuw proberen" + }, + "researchReport": { + "title": "4. Onderzoeksrapport", + "exportPdf": "PDF exporteren", + "exportMarkdown": "Markdown exporteren", + "sources": "Bronnen", + "waiting": "Wachten op het rapport...", + "generating": "Rapport genereren...", + "error": "Rapport genereren mislukt: {0}", + "downloadingFonts": "Het downloaden van de benodigde lettertypen kan enige tijd duren...", + "downloadFontFailed": "Downloaden van lettertype mislukt", + "regenerate": "Regenereren" + }, + "error": { + "requestBlockedByCORS": "De huidige API-provider staat mogelijk geen cross-origin-verzoeken toe. Probeer een andere API-provider of neem contact op met de provider voor ondersteuning.." + }, + "autoUpdate": { + "newVersionTitle": "Nieuwe versie beschikbaar: {0}", + "newVersionDescription": "Let op: Als u een zelfgehoste versie gebruikt, implementeer deze dan opnieuw om nieuwe functies en bugfixes te krijgen.", + "refresh": "Pagina vernieuwen", + "dismiss": "Afwijzen" + } +} diff --git a/i18n/zh.json b/i18n/zh.json index 60b75a4..063d543 100644 --- a/i18n/zh.json +++ b/i18n/zh.json @@ -87,7 +87,8 @@ "generating": "生成中...", "nodeFailed": "搜索失败", "nodeFailedToast": "搜索步骤 “{label}” 失败", - "followUpQuestions": "后续问题" + "followUpQuestions": "后续问题", + "retry": "重试" }, "researchReport": { "title": "4. 研究报告", diff --git a/lib/deep-research.ts b/lib/deep-research.ts index f3d8372..88d5bee 100644 --- a/lib/deep-research.ts +++ b/lib/deep-research.ts @@ -7,6 +7,7 @@ import { languagePrompt, systemPrompt } from './prompt' import zodToJsonSchema from 'zod-to-json-schema' import { useAiModel } from '~/composables/useAiProvider' import type { Locale } from '~/components/LangSwitcher.vue' +import type { DeepResearchNode } from '~/components/DeepResearch/DeepResearch.vue' export type ResearchResult = { learnings: string[] @@ -221,99 +222,119 @@ export async function deepResearch({ learnings = [], visitedUrls = [], onProgress, - currentDepth = 1, + currentDepth, nodeId = '0', - searchLanguage, + retryNode, }: { query: string breadth: number maxDepth: number - /** Language code */ + /** Language code for SERP queries and web searches */ languageCode: Locale + /** Accumulated learnings from all nodes visited so far */ learnings?: string[] + /** Accumulated visited URLs from all nodes visited so far */ visitedUrls?: string[] - onProgress: (step: ResearchStep) => void - currentDepth?: number + currentDepth: number + /** Current node ID. Used for recursive calls */ nodeId?: string - /** Force the LLM to generate serp queries in a certain language */ - searchLanguage?: string + /** The Node ID to retry. Passed from DeepResearch.vue */ + retryNode?: DeepResearchNode + onProgress: (step: ResearchStep) => void }): Promise { const { t } = useNuxtApp().$i18n const language = t('language', {}, { locale: languageCode }) const globalLimit = usePLimit() try { - const searchQueriesResult = generateSearchQueries({ - query, - learnings, - numQueries: breadth, - language, - searchLanguage, - }) + let searchQueries: Array = [] - let searchQueries: PartialSearchQuery[] = [] - - for await (const chunk of parseStreamingJson( - searchQueriesResult.fullStream, - searchQueriesTypeSchema, - (value) => !!value.queries?.length && !!value.queries[0]?.query, - )) { - if (chunk.type === 'object' && chunk.value.queries) { - // Temporary fix: Exclude queries that equals `undefined` - // Currently only being reported to be seen on GPT-4o, where the model simply returns `undefined` for certain questions - // https://github.com/AnotiaWang/deep-research-web-ui/issues/7 - searchQueries = chunk.value.queries.filter( - (q) => q.query !== 'undefined', - ) - for (let i = 0; i < searchQueries.length; i++) { - onProgress({ - type: 'generating_query', - result: searchQueries[i], - nodeId: childNodeId(nodeId, i), - parentNodeId: nodeId, - }) - } - } else if (chunk.type === 'reasoning') { - // Reasoning part goes to the parent node - onProgress({ - type: 'generating_query_reasoning', - delta: chunk.delta, + // If retryNode is provided and not a root node, just use the query from the node + if (retryNode && retryNode.id !== '0') { + nodeId = retryNode.id + searchQueries = [ + { + query: retryNode.label, + researchGoal: retryNode.researchGoal, nodeId, - }) - } else if (chunk.type === 'error') { - onProgress({ - type: 'error', - message: chunk.message, - nodeId, - }) - break - } else if (chunk.type === 'bad-end') { - onProgress({ - type: 'error', - message: t('invalidStructuredOutput'), - nodeId, - }) - break - } + }, + ] } - - onProgress({ - type: 'node_complete', - nodeId, - }) - - for (let i = 0; i < searchQueries.length; i++) { - onProgress({ - type: 'generated_query', + // Otherwise (fresh start or retrying on root node) + else { + const searchQueriesResult = generateSearchQueries({ query, - result: searchQueries[i], - nodeId: childNodeId(nodeId, i), + learnings, + numQueries: breadth, + language, + searchLanguage: language, }) + + for await (const chunk of parseStreamingJson( + searchQueriesResult.fullStream, + searchQueriesTypeSchema, + (value) => !!value.queries?.length && !!value.queries[0]?.query, + )) { + if (chunk.type === 'object' && chunk.value.queries) { + // Temporary fix: Exclude queries that equals `undefined` + // Currently only being reported to be seen on GPT-4o, where the model simply returns `undefined` for certain questions + // https://github.com/AnotiaWang/deep-research-web-ui/issues/7 + searchQueries = chunk.value.queries + .filter((q) => q.query !== 'undefined') + .map((q, i) => ({ + ...q, + nodeId: childNodeId(nodeId, i), + })) + for (let i = 0; i < searchQueries.length; i++) { + onProgress({ + type: 'generating_query', + result: searchQueries[i], + nodeId: searchQueries[i].nodeId, + parentNodeId: nodeId, + }) + } + } else if (chunk.type === 'reasoning') { + // Reasoning part goes to the parent node + onProgress({ + type: 'generating_query_reasoning', + delta: chunk.delta, + nodeId, + }) + } else if (chunk.type === 'error') { + onProgress({ + type: 'error', + message: chunk.message, + nodeId, + }) + break + } else if (chunk.type === 'bad-end') { + onProgress({ + type: 'error', + message: t('invalidStructuredOutput'), + nodeId, + }) + break + } + } + + onProgress({ + type: 'node_complete', + nodeId, + }) + + for (const searchQuery of searchQueries) { + onProgress({ + type: 'generated_query', + query: searchQuery.query!, + result: searchQuery, + nodeId: searchQuery.nodeId, + }) + } } // Run in parallel and limit the concurrency const results = await Promise.all( - searchQueries.map((searchQuery, i) => + searchQueries.map((searchQuery) => globalLimit(async () => { if (!searchQuery?.query) { return { @@ -324,7 +345,7 @@ export async function deepResearch({ onProgress({ type: 'searching', query: searchQuery.query, - nodeId: childNodeId(nodeId, i), + nodeId: searchQuery.nodeId, }) try { // search the web @@ -341,7 +362,7 @@ export async function deepResearch({ onProgress({ type: 'search_complete', results, - nodeId: childNodeId(nodeId, i), + nodeId: searchQuery.nodeId, }) // Breadth for the next search is half of the current breadth const nextBreadth = Math.ceil(breadth / 2) @@ -359,33 +380,32 @@ export async function deepResearch({ searchResultTypeSchema, (value) => !!value.learnings?.length, )) { - const id = childNodeId(nodeId, i) if (chunk.type === 'object') { searchResult = chunk.value onProgress({ type: 'processing_serach_result', result: chunk.value, query: searchQuery.query, - nodeId: id, + nodeId: searchQuery.nodeId, }) } else if (chunk.type === 'reasoning') { onProgress({ type: 'processing_serach_result_reasoning', delta: chunk.delta, - nodeId: id, + nodeId: searchQuery.nodeId, }) } else if (chunk.type === 'error') { onProgress({ type: 'error', message: chunk.message, - nodeId: id, + nodeId: searchQuery.nodeId, }) break } else if (chunk.type === 'bad-end') { onProgress({ type: 'error', message: t('invalidStructuredOutput'), - nodeId: id, + nodeId: searchQuery.nodeId, }) break } @@ -407,7 +427,7 @@ export async function deepResearch({ learnings: searchResult.learnings ?? [], followUpQuestions: searchResult.followUpQuestions ?? [], }, - nodeId: childNodeId(nodeId, i), + nodeId: searchQuery.nodeId, }) if ( @@ -436,9 +456,8 @@ export async function deepResearch({ visitedUrls: allUrls, onProgress, currentDepth: nextDepth, - nodeId: childNodeId(nodeId, i), + nodeId: searchQuery.nodeId, languageCode, - searchLanguage, }) return r } catch (error) { @@ -453,15 +472,14 @@ export async function deepResearch({ } } } catch (e: any) { - const id = childNodeId(nodeId, i) console.error( - `Error in node ${id} for query ${searchQuery.query}`, + `Error in node ${searchQuery.nodeId} for query ${searchQuery.query}`, e, ) onProgress({ type: 'error', message: e.message, - nodeId: id, + nodeId: searchQuery.nodeId, }) return { learnings: [], diff --git a/utils/tree-node.ts b/utils/tree-node.ts new file mode 100644 index 0000000..0ae3014 --- /dev/null +++ b/utils/tree-node.ts @@ -0,0 +1,28 @@ +export function isChildNode(parentId: string, childId: string) { + return childId.length > parentId.length && childId.startsWith(parentId) +} + +export function isParentNode(parentId: string, childId: string) { + return childId.length < parentId.length && childId.startsWith(parentId) +} + +export function isRootNode(nodeId: string) { + return nodeId === '0' // equal to `nodeDepth(nodeId) === 1` +} + +export function parentNodeId(nodeId: string) { + return nodeId.split('-').shift() +} + +export function nodeIndex(nodeId: string) { + return parseInt(nodeId.split('-').pop()!) +} + +export function nodeDepth(nodeId: string) { + return nodeId.split('-').length +} + +/** Returns the next search breadth at a given node */ +export function searchBreadth(initialBreadth: number, nodeId: string) { + return Math.ceil(initialBreadth / Math.pow(2, nodeDepth(nodeId) - 1)) +}