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

@ -0,0 +1,362 @@
<script setup lang="ts">
import {
deepResearch,
type PartialProcessedSearchResult,
type ResearchStep,
} from '~/lib/deep-research'
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
}>()
const toast = useToast()
const { t, locale } = useI18n()
const { config } = storeToRefs(useConfigStore())
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: DeepResearchNode | undefined
let nodeId = ''
if (step.type !== 'complete') {
nodeId = step.nodeId
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,
})
}
}
switch (step.type) {
case 'generating_query_reasoning': {
if (node) {
node.generateQueriesReasoning =
(node.generateQueriesReasoning ?? '') + step.delta
}
break
}
case 'generating_query': {
if (!node) {
node = {
id: nodeId,
label: step.result.query ?? '',
researchGoal: '',
learnings: [],
}
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 ?? '',
})
}
}
// Update the node
if (!isRootNode(node)) {
node.label = step.result.query ?? ''
node.researchGoal = step.result.researchGoal
}
break
}
case 'generated_query': {
console.log(`[DeepResearch] node ${nodeId} generated query:`, step)
break
}
case 'searching': {
console.log(`[DeepResearch] node ${nodeId} searching:`, step)
break
}
case 'search_complete': {
console.log(`[DeepResearch] node ${nodeId} search complete:`, step)
if (node) {
node.searchResults = step.results
}
break
}
case 'processing_serach_result_reasoning': {
if (node) {
node.generateLearningsReasoning =
(node.generateLearningsReasoning ?? '') + step.delta
}
break
}
case 'processing_serach_result': {
if (node) {
node.learnings = step.result.learnings || []
}
break
}
case 'node_complete': {
console.log(
`[DeepResearch] node ${nodeId} processed_search_result:`,
step,
)
if (node && step.result) {
node.learnings = step.result.learnings
searchResults.value[nodeId] = step.result
}
break
}
case 'error':
console.error(
`[DeepResearch] node ${nodeId} error:`,
node,
step.message,
)
node!.error = step.message
toast.add({
title: t('webBrowsing.nodeFailedToast', {
label: node!.label ?? nodeId,
}),
description: step.message,
color: 'error',
duration: 8000,
})
break
case 'complete':
console.log(`[DeepResearch] complete:`, step)
completeResult.value = {
learnings: step.learnings,
visitedUrls: step.visitedUrls,
}
emit('complete')
isLoading.value = false
break
}
}
function isRootNode(node: DeepResearchNode) {
return node.id === '0'
}
function selectNode(nodeId: string) {
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = undefined
} else {
selectedNodeId.value = nodeId
}
}
async function startResearch() {
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()
// 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
await deepResearch({
query: getCombinedQuery(form.value, feedback.value),
maxDepth: form.value.depth,
breadth: form.value.breadth,
languageCode: locale.value,
searchLanguage,
onProgress: handleResearchProgress,
})
} catch (error) {
console.error('Research failed:', error)
} finally {
isLoading.value = false
}
}
defineExpose({
startResearch,
isLoading,
})
</script>
<template>
<UCard>
<template #header>
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
<p class="text-sm text-gray-500">
{{ t('webBrowsing.description') }}
<br />
{{ t('webBrowsing.clickToView') }}
</p>
</template>
<div class="flex flex-col">
<div class="overflow-y-auto">
<SearchFlow
ref="flowRef"
:selected-node-id="selectedNodeId"
@node-click="selectNode"
/>
</div>
<div v-if="selectedNode" class="p-4">
<USeparator :label="$t('webBrowsing.nodeDetails')" />
<UAlert
v-if="selectedNode.error"
class="my-2"
:title="$t('webBrowsing.nodeFailed')"
:description="selectedNode.error"
color="error"
variant="soft"
:duration="8000"
/>
<h2 class="text-xl font-bold my-2">
{{ selectedNode.label ?? $t('webBrowsing.generating') }}
</h2>
<!-- Research goal -->
<h3 class="text-lg font-semibold mt-2">
{{ t('webBrowsing.researchGoal') }}
</h3>
<!-- Root node has no additional information -->
<p v-if="isRootNode(selectedNode)">
{{ t('webBrowsing.startNode.description') }}
</p>
<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
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"
>
{{ item.title || item.url }}
</UButton>
</li>
</ul>
<span v-else> - </span>
<!-- 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"
:loading="
selectedNode.status === 'generating_query_reasoning' ||
selectedNode.status === 'generating_query'
"
/>
</template>
</div>
</div>
</UCard>
</template>

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>