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

@ -20,10 +20,15 @@ Currently available providers:
Please give a 🌟 Star if you like this project! Please give a 🌟 Star if you like this project!
<video width="500" src="https://github.com/user-attachments/assets/2f5a6f9c-18d1-4d40-9822-2de260d55dab" controls></video> <video width="500" src="https://github.com/user-attachments/assets/8f9baa43-a74e-4613-aebb-1bcc29a686f0" controls></video>
## Recent updates ## Recent updates
25/02/16
- Refactored the search visualization using VueFlow
- Style & bug fixes
25/02/15 25/02/15
- Added AI providers DeepSeek, OpenRouter and Ollama; Added web search provider Firecrawl - Added AI providers DeepSeek, OpenRouter and Ollama; Added web search provider Firecrawl
@ -43,10 +48,6 @@ Please give a 🌟 Star if you like this project!
- Added Docker support - Added Docker support
- Fixed "export as PDF" issues - Fixed "export as PDF" issues
25/02/12
- Added Chinese translation. The models will respond in the user's language.
- Various fixes
## How to use ## How to use

View File

@ -14,10 +14,15 @@
- AI 服务OpenAPI 每月 1000 次免费搜索、Firecrawl - AI 服务OpenAPI 每月 1000 次免费搜索、Firecrawl
喜欢本项目请点 ⭐ 收藏! <video width="500" src="https://github.com/user-attachments/assets/2f5a6f9c-18d1-4d40-9822-2de260d55dab" controls></video> 喜欢本项目请点 ⭐ 收藏! <video width="500" src="https://github.com/user-attachments/assets/8f9baa43-a74e-4613-aebb-1bcc29a686f0" controls></video>
## 近期更新 ## 近期更新
25/02/16
- 使用 VueFlow 重构了搜索可视化功能
- 一些样式和 bug 修复
25/02/15 25/02/15
- AI 提供商支持 DeepSeekOpenRouter 和 Ollama联网搜素支持 Firecrawl - AI 提供商支持 DeepSeekOpenRouter 和 Ollama联网搜素支持 Firecrawl
@ -37,11 +42,6 @@
- 支持 Docker 部署 - 支持 Docker 部署
- 修复“导出 PDF”不可用的问题 - 修复“导出 PDF”不可用的问题
25/02/12
- 添加中文支持。模型会自动使用用户的语言回答了。
- 修复一些 bug
## 使用指南 ## 使用指南
在线演示:<a href="https://deep-research.ataw.top" target="_blank">https://deep-research.ataw.top</a> 在线演示:<a href="https://deep-research.ataw.top" target="_blank">https://deep-research.ataw.top</a>

View File

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

View File

@ -0,0 +1,62 @@
import dagre from '@dagrejs/dagre'
import { Position, useVueFlow, type Edge, type Node } from '@vue-flow/core'
// Picked from https://vueflow.dev/examples/layout/animated.html
export function useNodeLayout() {
const { findNode } = useVueFlow()
function layout(nodes: Node[], edges: Edge[]) {
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = true
dagreGraph.setGraph({
rankdir: 'LR',
// distance between nodes at the same level
nodesep: 25,
// distance between levels
ranksep: 30,
})
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
if (!graphNode) {
console.error(`Node with id ${node.id} not found in the graph`)
continue
}
dagreGraph.setNode(node.id, {
width: graphNode.dimensions.width || 100,
height: graphNode.dimensions.height || 50,
})
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph, {
rankdir: 'LR',
nodesep: 25,
ranksep: 30,
})
// set nodes with updated positions
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
}
})
}
return { layout }
}

View File

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

View File

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

View File

@ -27,7 +27,12 @@ export type ProcessedSearchResult = z.infer<typeof searchResultTypeSchema>
export type PartialProcessedSearchResult = DeepPartial<ProcessedSearchResult> export type PartialProcessedSearchResult = DeepPartial<ProcessedSearchResult>
export type ResearchStep = export type ResearchStep =
| { type: 'generating_query'; result: PartialSearchQuery; nodeId: string } | {
type: 'generating_query'
result: PartialSearchQuery
nodeId: string
parentNodeId?: string
}
| { type: 'generating_query_reasoning'; delta: string; nodeId: string } | { type: 'generating_query_reasoning'; delta: string; nodeId: string }
| { | {
type: 'generated_query' type: 'generated_query'
@ -49,9 +54,8 @@ export type ResearchStep =
nodeId: string nodeId: string
} }
| { | {
type: 'processed_search_result' type: 'node_complete'
query: string result?: ProcessedSearchResult
result: ProcessedSearchResult
nodeId: string nodeId: string
} }
| { type: 'error'; message: string; nodeId: string } | { type: 'error'; message: string; nodeId: string }
@ -239,6 +243,12 @@ export async function deepResearch({
const { t } = useNuxtApp().$i18n const { t } = useNuxtApp().$i18n
const language = t('language', {}, { locale: languageCode }) const language = t('language', {}, { locale: languageCode })
onProgress({
type: 'generating_query',
nodeId,
result: {},
})
try { try {
const searchQueriesResult = generateSearchQueries({ const searchQueriesResult = generateSearchQueries({
query, query,
@ -268,9 +278,11 @@ export async function deepResearch({
type: 'generating_query', type: 'generating_query',
result: searchQueries[i], result: searchQueries[i],
nodeId: childNodeId(nodeId, i), nodeId: childNodeId(nodeId, i),
parentNodeId: nodeId,
}) })
} }
} else if (chunk.type === 'reasoning') { } else if (chunk.type === 'reasoning') {
// Reasoning part goes to the parent node
onProgress({ onProgress({
type: 'generating_query_reasoning', type: 'generating_query_reasoning',
delta: chunk.delta, delta: chunk.delta,
@ -293,6 +305,11 @@ export async function deepResearch({
} }
} }
onProgress({
type: 'node_complete',
nodeId,
})
for (let i = 0; i < searchQueries.length; i++) { for (let i = 0; i < searchQueries.length; i++) {
onProgress({ onProgress({
type: 'generated_query', type: 'generated_query',
@ -318,13 +335,13 @@ export async function deepResearch({
nodeId: childNodeId(nodeId, i), nodeId: childNodeId(nodeId, i),
}) })
try { try {
// Use Tavily to search the web // search the web
const results = await useWebSearch()(searchQuery.query, { const results = await useWebSearch()(searchQuery.query, {
maxResults: 5, maxResults: 5,
lang: languageCode, lang: languageCode,
}) })
console.log( console.log(
`Ran ${searchQuery.query}, found ${results.length} contents`, `[DeepResearch] Searched "${searchQuery.query}", found ${results.length} contents`,
) )
// Collect URLs from this search // Collect URLs from this search
@ -393,12 +410,11 @@ export async function deepResearch({
const nextDepth = currentDepth + 1 const nextDepth = currentDepth + 1
onProgress({ onProgress({
type: 'processed_search_result', type: 'node_complete',
result: { result: {
learnings: allLearnings, learnings: allLearnings,
followUpQuestions: searchResult.followUpQuestions ?? [], followUpQuestions: searchResult.followUpQuestions ?? [],
}, },
query: searchQuery.query,
nodeId: childNodeId(nodeId, i), nodeId: childNodeId(nodeId, i),
}) })

View File

@ -16,6 +16,7 @@
"@ai-sdk/openai": "^1.1.11", "@ai-sdk/openai": "^1.1.11",
"@ai-sdk/ui-utils": "^1.1.14", "@ai-sdk/ui-utils": "^1.1.14",
"@ai-sdk/vue": "^1.1.17", "@ai-sdk/vue": "^1.1.17",
"@dagrejs/dagre": "^1.1.4",
"@iconify-json/lucide": "^1.2.26", "@iconify-json/lucide": "^1.2.26",
"@mendable/firecrawl-js": "^1.17.0", "@mendable/firecrawl-js": "^1.17.0",
"@nuxt/ui": "3.0.0-alpha.12", "@nuxt/ui": "3.0.0-alpha.12",
@ -25,6 +26,9 @@
"@pinia/nuxt": "^0.10.1", "@pinia/nuxt": "^0.10.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tavily/core": "^0.3.1", "@tavily/core": "^0.3.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
"ai": "^4.1.41", "ai": "^4.1.41",
"js-tiktoken": "^1.0.19", "js-tiktoken": "^1.0.19",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",

View File

@ -52,7 +52,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type ResearchForm from '~/components/ResearchForm.vue' import type ResearchForm from '~/components/ResearchForm.vue'
import type ResearchFeedback from '~/components/ResearchFeedback.vue' import type ResearchFeedback from '~/components/ResearchFeedback.vue'
import type DeepResearch from '~/components/DeepResearch.vue' import type DeepResearch from '~/components/DeepResearch/DeepResearch.vue'
import type ResearchReport from '~/components/ResearchReport.vue' import type ResearchReport from '~/components/ResearchReport.vue'
import type ConfigManager from '~/components/ConfigManager.vue' import type ConfigManager from '~/components/ConfigManager.vue'
import type { ResearchInputData } from '~/components/ResearchForm.vue' import type { ResearchInputData } from '~/components/ResearchForm.vue'
@ -64,7 +64,7 @@
researchResultInjectionKey, researchResultInjectionKey,
} from '~/constants/injection-keys' } from '~/constants/injection-keys'
const { t, locale } = useI18n() const { t } = useI18n()
const { config } = storeToRefs(useConfigStore()) const { config } = storeToRefs(useConfigStore())
const toast = useToast() const toast = useToast()
const version = useRuntimeConfig().public.version const version = useRuntimeConfig().public.version

136
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
'@ai-sdk/vue': '@ai-sdk/vue':
specifier: ^1.1.17 specifier: ^1.1.17
version: 1.1.17(vue@3.5.13(typescript@5.7.3))(zod@3.24.2) version: 1.1.17(vue@3.5.13(typescript@5.7.3))(zod@3.24.2)
'@dagrejs/dagre':
specifier: ^1.1.4
version: 1.1.4
'@iconify-json/lucide': '@iconify-json/lucide':
specifier: ^1.2.26 specifier: ^1.2.26
version: 1.2.26 version: 1.2.26
@ -47,6 +50,15 @@ importers:
'@tavily/core': '@tavily/core':
specifier: ^0.3.1 specifier: ^0.3.1
version: 0.3.1 version: 0.3.1
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.42.1(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
'@vue-flow/controls':
specifier: ^1.1.2
version: 1.1.2(@vue-flow/core@1.42.1(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
'@vue-flow/core':
specifier: ^1.42.1
version: 1.42.1(vue@3.5.13(typescript@5.7.3))
ai: ai:
specifier: ^4.1.41 specifier: ^4.1.41
version: 4.1.41(react@19.0.0)(zod@3.24.2) version: 4.1.41(react@19.0.0)(zod@3.24.2)
@ -341,6 +353,13 @@ packages:
resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==}
engines: {node: '>=16.13'} engines: {node: '>=16.13'}
'@dagrejs/dagre@1.1.4':
resolution: {integrity: sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==}
'@dagrejs/graphlib@2.2.4':
resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==}
engines: {node: '>17.0.0'}
'@esbuild/aix-ppc64@0.24.2': '@esbuild/aix-ppc64@0.24.2':
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1297,6 +1316,23 @@ packages:
vite: ^5.0.0 || ^6.0.0 vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vue-flow/background@1.3.2':
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/controls@1.1.2':
resolution: {integrity: sha512-6dtl/JnwDBNau5h3pDBdOCK6tdxiVAOL3cyruRL61gItwq5E97Hmjmj2BIIqX2p7gU1ENg3z80Z4zlu58fGlsg==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/core@1.42.1':
resolution: {integrity: sha512-QzzTxMAXfOeETKc+N3XMp5XpiPxKBHK5kq98avgTsE6MXyeU2E8EkANwwgSB/hvJ/k36RjU0Y7BOwCHiqiI1tw==}
peerDependencies:
vue: ^3.3.0
'@vue-macros/common@1.16.1': '@vue-macros/common@1.16.1':
resolution: {integrity: sha512-Pn/AWMTjoMYuquepLZP813BIcq8DTZiNCoaceuNlvaYuOTd8DqBZWc5u0uOMQZMInwME1mdSmmBAcTluiV9Jtg==} resolution: {integrity: sha512-Pn/AWMTjoMYuquepLZP813BIcq8DTZiNCoaceuNlvaYuOTd8DqBZWc5u0uOMQZMInwME1mdSmmBAcTluiV9Jtg==}
engines: {node: '>=16.14.0'} engines: {node: '>=16.14.0'}
@ -1869,6 +1905,44 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
db0@0.2.3: db0@0.2.3:
resolution: {integrity: sha512-PunuHESDNefmwVy1LDpY663uWwKt2ogLGoB6NOz2sflGREWqDreMwDgF8gfkXxgNXW+dqviyiJGm924H1BaGiw==} resolution: {integrity: sha512-PunuHESDNefmwVy1LDpY663uWwKt2ogLGoB6NOz2sflGREWqDreMwDgF8gfkXxgNXW+dqviyiJGm924H1BaGiw==}
peerDependencies: peerDependencies:
@ -4623,6 +4697,12 @@ snapshots:
dependencies: dependencies:
mime: 3.0.0 mime: 3.0.0
'@dagrejs/dagre@1.1.4':
dependencies:
'@dagrejs/graphlib': 2.2.4
'@dagrejs/graphlib@2.2.4': {}
'@esbuild/aix-ppc64@0.24.2': '@esbuild/aix-ppc64@0.24.2':
optional: true optional: true
@ -5860,6 +5940,26 @@ snapshots:
vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.38.1)(yaml@2.7.0) vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.38.1)(yaml@2.7.0)
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
'@vue-flow/background@1.3.2(@vue-flow/core@1.42.1(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@vue-flow/core': 1.42.1(vue@3.5.13(typescript@5.7.3))
vue: 3.5.13(typescript@5.7.3)
'@vue-flow/controls@1.1.2(@vue-flow/core@1.42.1(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@vue-flow/core': 1.42.1(vue@3.5.13(typescript@5.7.3))
vue: 3.5.13(typescript@5.7.3)
'@vue-flow/core@1.42.1(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.13(typescript@5.7.3))
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.13(typescript@5.7.3)
transitivePeerDependencies:
- '@vue/composition-api'
'@vue-macros/common@1.16.1(vue@3.5.13(typescript@5.7.3))': '@vue-macros/common@1.16.1(vue@3.5.13(typescript@5.7.3))':
dependencies: dependencies:
'@vue/compiler-sfc': 3.5.13 '@vue/compiler-sfc': 3.5.13
@ -6536,6 +6636,42 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
db0@0.2.3: {} db0@0.2.3: {}
debug@2.6.9: debug@2.6.9:

View File

@ -1,3 +1,3 @@
{ {
"version": "1.0.7" "version": "1.1.0"
} }