feat: rewritten search visualization using VueFlow

This commit is contained in:
AnotiaWang
2025-02-16 21:40:35 +08:00
parent 3830875858
commit c10090d7d9
14 changed files with 611 additions and 228 deletions

View File

@ -4,13 +4,33 @@
type PartialProcessedSearchResult,
type ResearchStep,
} from '~/lib/deep-research'
import type { TreeNode } from './Tree.vue'
import { marked } from 'marked'
import {
feedbackInjectionKey,
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
import Flow from './SearchFlow.vue'
import SearchFlow from './SearchFlow.vue'
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
export type DeepResearchNode = {
id: string
/** Label, represents the search query. Generated from parent node. */
label: string
/** The research goal of this node. Generated from parent node. */
researchGoal?: string
/** Reasoning content when generating queries for the next iteration. */
generateQueriesReasoning?: string
/** Reasoning content when generating learnings for this iteration. */
generateLearningsReasoning?: string
searchResults?: WebSearchResult[]
/** Learnings from search results */
learnings?: string[]
status?: DeepResearchNodeStatus
error?: string
}
const emit = defineEmits<{
(e: 'complete'): void
@ -20,29 +40,43 @@
const { t, locale } = useI18n()
const { config } = storeToRefs(useConfigStore())
const tree = ref<TreeNode>({
id: '0',
label: t('webBrowsing.startNode.label'),
children: [],
})
const selectedNode = ref<TreeNode>()
const flowRef = ref<InstanceType<typeof Flow>>()
const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
// The complete search data.
// There's another tree stored in SearchNode.vue, with only basic data (id, status, ...)
const nodes = ref<DeepResearchNode[]>([{ ...rootNode }])
const selectedNodeId = ref<string>()
const searchResults = ref<Record<string, PartialProcessedSearchResult>>({})
const isLoading = ref(false)
const selectedNode = computed(() => {
if (selectedNodeId.value) {
return nodes.value.find((n) => n.id === selectedNodeId.value)
}
})
// Inject global data from index.vue
const form = inject(formInjectionKey)!
const feedback = inject(feedbackInjectionKey)!
const completeResult = inject(researchResultInjectionKey)!
function handleResearchProgress(step: ResearchStep) {
let node: TreeNode | null = null
let node: DeepResearchNode | undefined
let nodeId = ''
if (step.type !== 'complete') {
nodeId = step.nodeId
node = findNode(tree.value, step.nodeId)
if (node) {
node = nodes.value.find((n) => n.id === nodeId)
if (node && node.status !== step.type) {
// FIXME: currently `node_complete` is always triggered last,
// so error is possibly overridden
if (node.status === 'error') {
return
}
node.status = step.type
flowRef.value?.updateNode(nodeId, {
status: step.type,
})
}
}
@ -57,31 +91,31 @@
case 'generating_query': {
if (!node) {
//
node = {
id: nodeId,
label: '',
label: step.result.query ?? '',
researchGoal: '',
learnings: [],
children: [],
}
const parentNodeId = getParentNodeId(nodeId)
//
if (parentNodeId === '0') {
tree.value.children.push(node)
} else {
//
const parentNode = findNode(
tree.value,
getParentNodeId(step.nodeId),
)
if (parentNode) {
parentNode.children.push(node)
}
const parentNodeId = step.parentNodeId
nodes.value.push(node)
flowRef.value?.addNode(
nodeId,
{
title: node.label,
status: node.status,
},
parentNodeId,
)
} else {
if (node.label !== step.result.query) {
flowRef.value?.updateNode(nodeId, {
title: step.result.query ?? '',
})
}
}
//
if (step.result) {
// Update the node
if (!isRootNode(node)) {
node.label = step.result.query ?? ''
node.researchGoal = step.result.researchGoal
}
@ -121,12 +155,12 @@
break
}
case 'processed_search_result': {
case 'node_complete': {
console.log(
`[DeepResearch] node ${nodeId} processed_search_result:`,
step,
)
if (node) {
if (node && step.result) {
node.learnings = step.result.learnings
searchResults.value[nodeId] = step.result
}
@ -134,7 +168,11 @@
}
case 'error':
console.error(`[DeepResearch] node ${nodeId} error:`, step.message)
console.error(
`[DeepResearch] node ${nodeId} error:`,
node,
step.message,
)
node!.error = step.message
toast.add({
title: t('webBrowsing.nodeFailedToast', {
@ -158,45 +196,30 @@
}
}
// ID
function findNode(root: TreeNode, targetId: string): TreeNode | null {
if (!targetId) return null
if (root.id === targetId) {
return root
}
for (const child of root.children) {
const found = findNode(child, targetId)
if (found) {
return found
}
}
return null
function isRootNode(node: DeepResearchNode) {
return node.id === '0'
}
function selectNode(node: TreeNode) {
if (selectedNode.value?.id === node.id) {
selectedNode.value = undefined
function selectNode(nodeId: string) {
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = undefined
} else {
selectedNode.value = node
selectedNodeId.value = nodeId
}
}
// ID
function getParentNodeId(nodeId: string): string {
const parts = nodeId.split('-')
parts.pop()
return parts.join('-')
}
async function startResearch() {
if (!form.value.query || !form.value.breadth || !form.value.depth) return
tree.value.children = []
selectedNode.value = undefined
nodes.value = [{ ...rootNode }]
selectedNodeId.value = undefined
searchResults.value = {}
isLoading.value = true
// Clear the root node's reasoning content
tree.value.generateQueriesReasoning = ''
flowRef.value?.clearNodes()
// 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 })
@ -234,7 +257,11 @@
</template>
<div class="flex flex-col">
<div class="overflow-y-auto">
<Tree :node="tree" :selected-node="selectedNode" @select="selectNode" />
<SearchFlow
ref="flowRef"
:selected-node-id="selectedNodeId"
@node-click="selectNode"
/>
</div>
<div v-if="selectedNode" class="p-4">
<USeparator :label="$t('webBrowsing.nodeDetails')" />
@ -251,64 +278,82 @@
{{ selectedNode.label ?? $t('webBrowsing.generating') }}
</h2>
<!-- Set loading default to true, because currently don't know how to handle it otherwise -->
<ReasoningAccordion
v-model="selectedNode.generateQueriesReasoning"
loading
/>
<!-- Research goal -->
<h3 class="text-lg font-semibold mt-2">
{{ t('webBrowsing.researchGoal') }}
</h3>
<!-- Root node has no additional information -->
<p v-if="selectedNode.id === '0'">
<p v-if="isRootNode(selectedNode)">
{{ t('webBrowsing.startNode.description') }}
</p>
<template v-else>
<p
v-if="selectedNode.researchGoal"
class="prose max-w-none dark:prose-invert"
v-html="marked(selectedNode.researchGoal, { gfm: true })"
/>
<p
v-if="selectedNode.researchGoal"
class="prose max-w-none dark:prose-invert"
v-html="marked(selectedNode.researchGoal, { gfm: true })"
/>
<!-- Visited URLs -->
<h3 class="text-lg font-semibold mt-2">
{{ t('webBrowsing.visitedUrls') }}
</h3>
<ul class="list-disc list-inside">
<li
v-for="(item, index) in selectedNode.searchResults"
class="whitespace-pre-wrap break-all"
:key="index"
<!-- Visited URLs -->
<h3 class="text-lg font-semibold mt-2">
{{ t('webBrowsing.visitedUrls') }}
</h3>
<ul
v-if="selectedNode.searchResults?.length"
class="list-disc list-inside"
>
<li
v-for="(item, index) in selectedNode.searchResults"
class="whitespace-pre-wrap break-all"
:key="index"
>
<UButton
class="!p-0 contents"
variant="link"
:href="item.url"
target="_blank"
>
<UButton
class="!p-0 contents"
variant="link"
:href="item.url"
target="_blank"
>
{{ item.title || item.url }}
</UButton>
</li>
</ul>
{{ item.title || item.url }}
</UButton>
</li>
</ul>
<span v-else> - </span>
<!-- Learnings -->
<h3 class="text-lg font-semibold mt-2">
{{ t('webBrowsing.learnings') }}
<!-- Learnings -->
<h3 class="text-lg font-semibold mt-2">
{{ t('webBrowsing.learnings') }}
</h3>
<ReasoningAccordion
v-if="selectedNode.generateLearningsReasoning"
v-model="selectedNode.generateLearningsReasoning"
class="my-2"
:loading="
selectedNode.status === 'processing_serach_result_reasoning' ||
selectedNode.status === 'processing_serach_result'
"
/>
<p
v-for="(learning, index) in selectedNode.learnings"
class="prose max-w-none dark:prose-invert"
:key="index"
v-html="marked(`- ${learning}`, { gfm: true })"
/>
<span v-if="!selectedNode.learnings?.length"> - </span>
<!-- Follow up questions -->
<!-- Only show if there is reasoning content. Otherwise the follow-ups are basically just child nodes. -->
<template v-if="selectedNode.generateQueriesReasoning">
<h3 class="text-lg font-semibold my-2">
{{ t('webBrowsing.followUpQuestions') }}
</h3>
<!-- Set loading default to true, because currently don't know how to handle it otherwise -->
<ReasoningAccordion
v-if="selectedNode.generateQueriesReasoning"
v-model="selectedNode.generateQueriesReasoning"
class="my-2"
loading
/>
<p
v-for="(learning, index) in selectedNode.learnings"
class="prose max-w-none dark:prose-invert"
:key="index"
v-html="marked(`- ${learning}`, { gfm: true })"
:loading="
selectedNode.status === 'generating_query_reasoning' ||
selectedNode.status === 'generating_query'
"
/>
</template>
</div>

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import SearchNode from './SearchNode.vue'
import {
type Edge,
type FlowEvents,
type Node,
VueFlow,
useVueFlow,
} from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import type { DeepResearchNodeStatus } from './DeepResearch.vue'
export interface SearchNodeData {
title: string
status?: DeepResearchNodeStatus
}
export type SearchNode = Node<SearchNodeData>
export type SearchEdge = Edge<SearchNodeData>
const emit = defineEmits<{
(e: 'node-click', nodeId: string): void
}>()
defineProps<{
selectedNodeId?: string
}>()
const isLargeScreen = useMediaQuery('(min-width: 768px)')
const defaultPosition = { x: 0, y: 0 }
const nodes = ref<SearchNode[]>([defaultRootNode()])
const edges = ref<SearchEdge[]>([])
let hasUserInteraction = false
const {
addNodes: addFlowNodes,
addEdges: addFlowEdges,
updateNodeData: updateFlowNodeData,
fitView,
} = useVueFlow()
const { layout } = useNodeLayout()
function defaultRootNode(): SearchNode {
return {
id: '0',
data: { title: 'Start' },
position: { ...defaultPosition },
type: 'search', // We only have this type
}
}
function handleNodeClick(nodeId: string) {
emit('node-click', nodeId)
}
function layoutGraph() {
nodes.value = layout(nodes.value, edges.value)
if (!hasUserInteraction) {
nextTick(() => {
fitView({})
})
}
}
function addNode(nodeId: string, data: SearchNodeData, parentId?: string) {
addFlowNodes({
id: nodeId,
data,
position: { ...defaultPosition },
type: 'search',
})
if (parentId) {
addFlowEdges({
id: `e:${parentId}:${nodeId}`,
source: parentId,
target: nodeId,
})
}
layoutGraph()
}
function updateNode(nodeId: string, data: Partial<SearchNodeData>) {
updateFlowNodeData(nodeId, data)
layoutGraph()
}
function clearNodes() {
nodes.value = [defaultRootNode()]
edges.value = []
layoutGraph()
}
function handleDrag(e: PointerEvent | FlowEvents['move']) {
// Triggered by VueFlow internal logic
if ('event' in e && !e.event.sourceEvent) {
return
}
hasUserInteraction = true
}
defineExpose({
addNode,
updateNode,
clearNodes,
})
</script>
<template>
<ClientOnly fallback-tag="span" fallback="Loading graph...">
<div :class="[isLargeScreen ? 'h-100' : 'h-60']">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:edges-updatable="false"
:min-zoom="0.5"
:max-zoom="isLargeScreen ? 2.5 : 1.8"
:default-edge-options="{ animated: true }"
@nodes-initialized="layoutGraph"
@move="handleDrag"
>
<template #node-search="props">
<SearchNode
:data="props.data"
:selected="selectedNodeId === props.id"
@click="handleNodeClick(props.id)"
@pointerdown="handleDrag"
/>
</template>
<Background />
<Controls @fit-view="hasUserInteraction = false" />
</VueFlow>
</div>
</ClientOnly>
</template>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, defineProps } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import type { ButtonProps } from '@nuxt/ui'
import type { SearchNodeData } from './SearchFlow.vue'
const props = defineProps<{
data: SearchNodeData
selected?: boolean
}>()
const theme = computed(() => {
const result = {
icon: '',
pulse: false,
color: 'info' as ButtonProps['color'],
}
if (!props.data?.status) return result
switch (props.data.status) {
case 'generating_query':
case 'generating_query_reasoning':
result.icon = 'i-lucide-clipboard-list'
result.pulse = true
break
case 'generated_query':
result.icon = 'i-lucide-circle-pause'
break
case 'searching':
result.icon = 'i-lucide-search'
result.pulse = true
break
case 'search_complete':
result.icon = 'i-lucide-search-check'
break
case 'processing_serach_result':
case 'processing_serach_result_reasoning':
result.icon = 'i-lucide-brain'
result.pulse = true
break
case 'node_complete':
result.icon = 'i-lucide-circle-check-big'
break
case 'error':
result.icon = 'i-lucide-octagon-x'
result.color = 'error'
break
}
return result
})
</script>
<template>
<UButton
class="process-node"
:class="[theme.pulse && 'animate-pulse', 'max-w-90']"
:color="selected ? 'primary' : theme.color"
:variant="selected ? 'soft' : 'outline'"
:icon="theme.icon"
size="sm"
>
<Handle type="target" :position="Position.Left" />
<Handle type="source" :position="Position.Right" />
{{ data.title }}
</UButton>
</template>
<style scoped>
/* Hide the handles */
.process-node .vue-flow__handle {
border: none;
height: unset;
width: unset;
background: transparent;
font-size: 12px;
}
</style>

View File

@ -1,101 +0,0 @@
<script setup lang="ts">
import type { ButtonProps } from '@nuxt/ui'
import type { ResearchStep } from '~/lib/deep-research'
export type TreeNodeStatus = Exclude<ResearchStep['type'], 'complete'>
export type TreeNode = {
id: string
/** Label, represents the search query. Generated from parent node. */
label: string
/** The research goal of this node. Generated from parent node. */
researchGoal?: string
/** Reasoning content when generating queries for the next iteration. */
generateQueriesReasoning?: string
/** Reasoning content when generating learnings for this iteration. */
generateLearningsReasoning?: string
searchResults?: WebSearchResult[]
/** Learnings from search results */
learnings?: string[]
status?: TreeNodeStatus
children: TreeNode[]
error?: string
}
const props = defineProps<{
node: TreeNode
selectedNode?: TreeNode
}>()
const emit = defineEmits<{
(e: 'select', value: TreeNode): void
}>()
const theme = computed(() => {
const result = {
icon: '',
pulse: false,
color: 'info' as ButtonProps['color'],
}
if (!props.node.status) return result
switch (props.node.status) {
case 'generating_query':
result.icon = 'i-lucide-clipboard-list'
result.pulse = true
break
case 'generated_query':
// FIXME: 因为 deepResearch 有并发限制,这个 case 是为了明确区分状态。
// 但是目前进入这个状态之后再进入 searching 状态,图标不会更新成 search不知道原因
// 暂时禁用了这个 case
// result.name = 'i-lucide-pause'
// result.pulse = true
// break
case 'searching':
result.icon = 'i-lucide-search'
result.pulse = true
break
case 'search_complete':
result.icon = 'i-lucide-search-check'
break
case 'processing_serach_result':
result.icon = 'i-lucide-brain'
result.pulse = true
break
case 'processed_search_result':
result.icon = 'i-lucide-circle-check-big'
break
case 'error':
result.icon = 'i-lucide-octagon-x'
result.color = 'error'
break
}
return result
})
</script>
<template>
<div class="flex items-center gap-1">
<UIcon name="i-lucide-circle-dot" />
<UButton
:class="['max-w-90 shrink-0', theme.pulse && 'animate-pulse']"
:icon="theme.icon"
size="sm"
:color="selectedNode?.id === node.id ? 'primary' : theme.color"
:variant="selectedNode?.id === node.id ? 'soft' : 'outline'"
@click="emit('select', node)"
>
{{ node.label }}
</UButton>
<ol v-if="node.children.length > 0" class="flex flex-col gap-y-2">
<li v-for="node in node.children" :key="node.id">
<Tree
class="ml-2"
:node="node"
:selected-node
@select="emit('select', $event)"
/>
</li>
</ol>
</div>
</template>