feat: rewritten search visualization using VueFlow
This commit is contained in:
@ -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>
|
140
components/DeepResearch/SearchFlow.vue
Normal file
140
components/DeepResearch/SearchFlow.vue
Normal 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>
|
78
components/DeepResearch/SearchNode.vue
Normal file
78
components/DeepResearch/SearchNode.vue
Normal 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>
|
@ -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>
|
Reference in New Issue
Block a user