feat(search): retry failed nodes

This commit is contained in:
AnotiaWang
2025-02-22 15:14:12 +08:00
parent 73e0df2206
commit 66c28978d1
7 changed files with 349 additions and 216 deletions

View File

@ -12,6 +12,7 @@
} from '~/constants/injection-keys' } from '~/constants/injection-keys'
import Flow from './SearchFlow.vue' import Flow from './SearchFlow.vue'
import SearchFlow from './SearchFlow.vue' import SearchFlow from './SearchFlow.vue'
import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node'
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'> export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
@ -115,7 +116,7 @@
} }
} }
// Update the node // Update the node
if (!isRootNode(node)) { if (!isRootNode(node.id)) {
node.label = step.result.query ?? '' node.label = step.result.query ?? ''
node.researchGoal = step.result.researchGoal node.researchGoal = step.result.researchGoal
} }
@ -196,10 +197,6 @@
} }
} }
function isRootNode(node: DeepResearchNode) {
return node.id === '0'
}
function selectNode(nodeId: string) { function selectNode(nodeId: string) {
if (selectedNodeId.value === nodeId) { if (selectedNodeId.value === nodeId) {
selectedNodeId.value = undefined selectedNodeId.value = undefined
@ -208,36 +205,102 @@
} }
} }
async function startResearch() { async function startResearch(retryNode?: DeepResearchNode) {
if (!form.value.query || !form.value.breadth || !form.value.depth) return if (!form.value.query || !form.value.breadth || !form.value.depth) return
// 如果不是重试,清空所有节点
if (!retryNode) {
nodes.value = [{ ...rootNode }] nodes.value = [{ ...rootNode }]
selectedNodeId.value = undefined selectedNodeId.value = undefined
searchResults.value = {} searchResults.value = {}
isLoading.value = true
flowRef.value?.clearNodes() flowRef.value?.clearNodes()
isLoading.value = true
}
// Wait after the flow is cleared // Wait after the flow is cleared
await new Promise((r) => requestAnimationFrame(r)) await new Promise((r) => requestAnimationFrame(r))
try { try {
const searchLanguage = config.value.webSearch.searchLanguage let query = getCombinedQuery(form.value, feedback.value)
? t('language', {}, { locale: config.value.webSearch.searchLanguage }) let existingLearnings: string[] = []
: undefined 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({ await deepResearch({
query: getCombinedQuery(form.value, feedback.value), query,
retryNode,
currentDepth,
breadth,
maxDepth: form.value.depth, maxDepth: form.value.depth,
breadth: form.value.breadth,
languageCode: locale.value, languageCode: locale.value,
searchLanguage, learnings: existingLearnings,
visitedUrls: existingVisitedUrls,
onProgress: handleResearchProgress, onProgress: handleResearchProgress,
}) })
} catch (error) { } catch (error) {
console.error('Research failed:', error) console.error('Research failed:', error)
} finally { } finally {
if (!retryNode) {
isLoading.value = false 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({ defineExpose({
startResearch, startResearch,
@ -273,6 +336,11 @@
color="error" color="error"
variant="soft" variant="soft"
:duration="8000" :duration="8000"
:actions="[{
label: $t('webBrowsing.retry'),
color: 'secondary',
onClick: () => retryNode(selectedNode!.id),
}]"
/> />
<h2 class="text-xl font-bold my-2"> <h2 class="text-xl font-bold my-2">
{{ selectedNode.label ?? $t('webBrowsing.generating') }} {{ selectedNode.label ?? $t('webBrowsing.generating') }}
@ -283,7 +351,7 @@
{{ t('webBrowsing.researchGoal') }} {{ t('webBrowsing.researchGoal') }}
</h3> </h3>
<!-- Root node has no additional information --> <!-- Root node has no additional information -->
<p v-if="isRootNode(selectedNode)"> <p v-if="isRootNode(selectedNode.id)">
{{ t('webBrowsing.startNode.description') }} {{ t('webBrowsing.startNode.description') }}
</p> </p>
<p <p

View File

@ -96,6 +96,21 @@
hasUserInteraction = false 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']) { function handleDrag(e: PointerEvent | FlowEvents['move']) {
// Triggered by VueFlow internal logic // Triggered by VueFlow internal logic
if ('event' in e && !e.event.sourceEvent) { if ('event' in e && !e.event.sourceEvent) {
@ -109,6 +124,7 @@
addNode, addNode,
updateNode, updateNode,
clearNodes, clearNodes,
removeChildNodes,
}) })
</script> </script>

View File

@ -87,7 +87,8 @@
"generating": "Generating...", "generating": "Generating...",
"nodeFailed": "Search failed", "nodeFailed": "Search failed",
"nodeFailedToast": "Search node \"{label}\" failed", "nodeFailedToast": "Search node \"{label}\" failed",
"followUpQuestions": "Follow-up Questions" "followUpQuestions": "Follow-up Questions",
"retry": "Retry"
}, },
"researchReport": { "researchReport": {
"title": "4. Research Report", "title": "4. Research Report",

View File

@ -87,7 +87,8 @@
"generating": "Genereren...", "generating": "Genereren...",
"nodeFailed": "Zoeken mislukt", "nodeFailed": "Zoeken mislukt",
"nodeFailedToast": "Zoekknooppunt \"{label}\" is mislukt", "nodeFailedToast": "Zoekknooppunt \"{label}\" is mislukt",
"followUpQuestions": "Vervolgvragen" "followUpQuestions": "Vervolgvragen",
"retry": "Opnieuw proberen"
}, },
"researchReport": { "researchReport": {
"title": "4. Onderzoeksrapport", "title": "4. Onderzoeksrapport",

View File

@ -87,7 +87,8 @@
"generating": "生成中...", "generating": "生成中...",
"nodeFailed": "搜索失败", "nodeFailed": "搜索失败",
"nodeFailedToast": "搜索步骤 “{label}” 失败", "nodeFailedToast": "搜索步骤 “{label}” 失败",
"followUpQuestions": "后续问题" "followUpQuestions": "后续问题",
"retry": "重试"
}, },
"researchReport": { "researchReport": {
"title": "4. 研究报告", "title": "4. 研究报告",

View File

@ -7,6 +7,7 @@ import { languagePrompt, systemPrompt } from './prompt'
import zodToJsonSchema from 'zod-to-json-schema' import zodToJsonSchema from 'zod-to-json-schema'
import { useAiModel } from '~/composables/useAiProvider' import { useAiModel } from '~/composables/useAiProvider'
import type { Locale } from '~/components/LangSwitcher.vue' import type { Locale } from '~/components/LangSwitcher.vue'
import type { DeepResearchNode } from '~/components/DeepResearch/DeepResearch.vue'
export type ResearchResult = { export type ResearchResult = {
learnings: string[] learnings: string[]
@ -221,38 +222,54 @@ export async function deepResearch({
learnings = [], learnings = [],
visitedUrls = [], visitedUrls = [],
onProgress, onProgress,
currentDepth = 1, currentDepth,
nodeId = '0', nodeId = '0',
searchLanguage, retryNode,
}: { }: {
query: string query: string
breadth: number breadth: number
maxDepth: number maxDepth: number
/** Language code */ /** Language code for SERP queries and web searches */
languageCode: Locale languageCode: Locale
/** Accumulated learnings from all nodes visited so far */
learnings?: string[] learnings?: string[]
/** Accumulated visited URLs from all nodes visited so far */
visitedUrls?: string[] visitedUrls?: string[]
onProgress: (step: ResearchStep) => void currentDepth: number
currentDepth?: number /** Current node ID. Used for recursive calls */
nodeId?: string nodeId?: string
/** Force the LLM to generate serp queries in a certain language */ /** The Node ID to retry. Passed from DeepResearch.vue */
searchLanguage?: string retryNode?: DeepResearchNode
onProgress: (step: ResearchStep) => void
}): Promise<ResearchResult> { }): Promise<ResearchResult> {
const { t } = useNuxtApp().$i18n const { t } = useNuxtApp().$i18n
const language = t('language', {}, { locale: languageCode }) const language = t('language', {}, { locale: languageCode })
const globalLimit = usePLimit() const globalLimit = usePLimit()
try { try {
let searchQueries: Array<PartialSearchQuery & { nodeId: string }> = []
// 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,
},
]
}
// Otherwise (fresh start or retrying on root node)
else {
const searchQueriesResult = generateSearchQueries({ const searchQueriesResult = generateSearchQueries({
query, query,
learnings, learnings,
numQueries: breadth, numQueries: breadth,
language, language,
searchLanguage, searchLanguage: language,
}) })
let searchQueries: PartialSearchQuery[] = []
for await (const chunk of parseStreamingJson( for await (const chunk of parseStreamingJson(
searchQueriesResult.fullStream, searchQueriesResult.fullStream,
searchQueriesTypeSchema, searchQueriesTypeSchema,
@ -262,14 +279,17 @@ export async function deepResearch({
// Temporary fix: Exclude queries that equals `undefined` // 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 // 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 // https://github.com/AnotiaWang/deep-research-web-ui/issues/7
searchQueries = chunk.value.queries.filter( searchQueries = chunk.value.queries
(q) => q.query !== 'undefined', .filter((q) => q.query !== 'undefined')
) .map((q, i) => ({
...q,
nodeId: childNodeId(nodeId, i),
}))
for (let i = 0; i < searchQueries.length; i++) { for (let i = 0; i < searchQueries.length; i++) {
onProgress({ onProgress({
type: 'generating_query', type: 'generating_query',
result: searchQueries[i], result: searchQueries[i],
nodeId: childNodeId(nodeId, i), nodeId: searchQueries[i].nodeId,
parentNodeId: nodeId, parentNodeId: nodeId,
}) })
} }
@ -302,18 +322,19 @@ export async function deepResearch({
nodeId, nodeId,
}) })
for (let i = 0; i < searchQueries.length; i++) { for (const searchQuery of searchQueries) {
onProgress({ onProgress({
type: 'generated_query', type: 'generated_query',
query, query: searchQuery.query!,
result: searchQueries[i], result: searchQuery,
nodeId: childNodeId(nodeId, i), nodeId: searchQuery.nodeId,
}) })
} }
}
// Run in parallel and limit the concurrency // Run in parallel and limit the concurrency
const results = await Promise.all( const results = await Promise.all(
searchQueries.map((searchQuery, i) => searchQueries.map((searchQuery) =>
globalLimit(async () => { globalLimit(async () => {
if (!searchQuery?.query) { if (!searchQuery?.query) {
return { return {
@ -324,7 +345,7 @@ export async function deepResearch({
onProgress({ onProgress({
type: 'searching', type: 'searching',
query: searchQuery.query, query: searchQuery.query,
nodeId: childNodeId(nodeId, i), nodeId: searchQuery.nodeId,
}) })
try { try {
// search the web // search the web
@ -341,7 +362,7 @@ export async function deepResearch({
onProgress({ onProgress({
type: 'search_complete', type: 'search_complete',
results, results,
nodeId: childNodeId(nodeId, i), nodeId: searchQuery.nodeId,
}) })
// Breadth for the next search is half of the current breadth // Breadth for the next search is half of the current breadth
const nextBreadth = Math.ceil(breadth / 2) const nextBreadth = Math.ceil(breadth / 2)
@ -359,33 +380,32 @@ export async function deepResearch({
searchResultTypeSchema, searchResultTypeSchema,
(value) => !!value.learnings?.length, (value) => !!value.learnings?.length,
)) { )) {
const id = childNodeId(nodeId, i)
if (chunk.type === 'object') { if (chunk.type === 'object') {
searchResult = chunk.value searchResult = chunk.value
onProgress({ onProgress({
type: 'processing_serach_result', type: 'processing_serach_result',
result: chunk.value, result: chunk.value,
query: searchQuery.query, query: searchQuery.query,
nodeId: id, nodeId: searchQuery.nodeId,
}) })
} else if (chunk.type === 'reasoning') { } else if (chunk.type === 'reasoning') {
onProgress({ onProgress({
type: 'processing_serach_result_reasoning', type: 'processing_serach_result_reasoning',
delta: chunk.delta, delta: chunk.delta,
nodeId: id, nodeId: searchQuery.nodeId,
}) })
} else if (chunk.type === 'error') { } else if (chunk.type === 'error') {
onProgress({ onProgress({
type: 'error', type: 'error',
message: chunk.message, message: chunk.message,
nodeId: id, nodeId: searchQuery.nodeId,
}) })
break break
} else if (chunk.type === 'bad-end') { } else if (chunk.type === 'bad-end') {
onProgress({ onProgress({
type: 'error', type: 'error',
message: t('invalidStructuredOutput'), message: t('invalidStructuredOutput'),
nodeId: id, nodeId: searchQuery.nodeId,
}) })
break break
} }
@ -407,7 +427,7 @@ export async function deepResearch({
learnings: searchResult.learnings ?? [], learnings: searchResult.learnings ?? [],
followUpQuestions: searchResult.followUpQuestions ?? [], followUpQuestions: searchResult.followUpQuestions ?? [],
}, },
nodeId: childNodeId(nodeId, i), nodeId: searchQuery.nodeId,
}) })
if ( if (
@ -436,9 +456,8 @@ export async function deepResearch({
visitedUrls: allUrls, visitedUrls: allUrls,
onProgress, onProgress,
currentDepth: nextDepth, currentDepth: nextDepth,
nodeId: childNodeId(nodeId, i), nodeId: searchQuery.nodeId,
languageCode, languageCode,
searchLanguage,
}) })
return r return r
} catch (error) { } catch (error) {
@ -453,15 +472,14 @@ export async function deepResearch({
} }
} }
} catch (e: any) { } catch (e: any) {
const id = childNodeId(nodeId, i)
console.error( console.error(
`Error in node ${id} for query ${searchQuery.query}`, `Error in node ${searchQuery.nodeId} for query ${searchQuery.query}`,
e, e,
) )
onProgress({ onProgress({
type: 'error', type: 'error',
message: e.message, message: e.message,
nodeId: id, nodeId: searchQuery.nodeId,
}) })
return { return {
learnings: [], learnings: [],

28
utils/tree-node.ts Normal file
View 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))
}