feat(search): retry failed nodes
This commit is contained in:
@ -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
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -87,7 +87,8 @@
|
|||||||
"generating": "生成中...",
|
"generating": "生成中...",
|
||||||
"nodeFailed": "搜索失败",
|
"nodeFailed": "搜索失败",
|
||||||
"nodeFailedToast": "搜索步骤 “{label}” 失败",
|
"nodeFailedToast": "搜索步骤 “{label}” 失败",
|
||||||
"followUpQuestions": "后续问题"
|
"followUpQuestions": "后续问题",
|
||||||
|
"retry": "重试"
|
||||||
},
|
},
|
||||||
"researchReport": {
|
"researchReport": {
|
||||||
"title": "4. 研究报告",
|
"title": "4. 研究报告",
|
||||||
|
@ -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
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