feat(search): retry failed nodes
This commit is contained in:
@ -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<ResearchStep['type'], 'complete'>
|
||||
|
||||
@ -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),
|
||||
}]"
|
||||
/>
|
||||
<h2 class="text-xl font-bold my-2">
|
||||
{{ selectedNode.label ?? $t('webBrowsing.generating') }}
|
||||
@ -283,7 +351,7 @@
|
||||
{{ t('webBrowsing.researchGoal') }}
|
||||
</h3>
|
||||
<!-- Root node has no additional information -->
|
||||
<p v-if="isRootNode(selectedNode)">
|
||||
<p v-if="isRootNode(selectedNode.id)">
|
||||
{{ t('webBrowsing.startNode.description') }}
|
||||
</p>
|
||||
<p
|
||||
|
@ -96,6 +96,21 @@
|
||||
hasUserInteraction = false
|
||||
}
|
||||
|
||||
function isChildNode(parentId: string, childId: string) {
|
||||
return childId.length > 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,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -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",
|
||||
|
227
i18n/nl.json
227
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"
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,8 @@
|
||||
"generating": "生成中...",
|
||||
"nodeFailed": "搜索失败",
|
||||
"nodeFailedToast": "搜索步骤 “{label}” 失败",
|
||||
"followUpQuestions": "后续问题"
|
||||
"followUpQuestions": "后续问题",
|
||||
"retry": "重试"
|
||||
},
|
||||
"researchReport": {
|
||||
"title": "4. 研究报告",
|
||||
|
@ -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<ResearchResult> {
|
||||
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<PartialSearchQuery & { nodeId: string }> = []
|
||||
|
||||
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: [],
|
||||
|
28
utils/tree-node.ts
Normal file
28
utils/tree-node.ts
Normal file
@ -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))
|
||||
}
|
Reference in New Issue
Block a user