feat(DeepResearch): support fullscreen mode

This commit is contained in:
AnotiaWang
2025-02-24 15:09:44 +08:00
parent 8c50ceb516
commit fa74b3909c
3 changed files with 145 additions and 37 deletions

View File

@ -9,10 +9,11 @@
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
import Flow from './SearchFlow.vue'
import Flow, { type SearchNode, type SearchEdge } from './SearchFlow.vue'
import SearchFlow from './SearchFlow.vue'
import NodeDetail from './NodeDetail.vue'
import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node'
import { UCard, UModal, UButton } from '#components'
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
@ -39,7 +40,7 @@
const toast = useToast()
const { t, locale } = useI18n()
const { config } = storeToRefs(useConfigStore())
const isLargeScreen = useMediaQuery('(min-width: 768px)')
const flowRef = ref<InstanceType<typeof Flow>>()
const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
@ -49,6 +50,12 @@
const selectedNodeId = ref<string>()
const searchResults = ref<Record<string, PartialProcessedSearchResult>>({})
const isLoading = ref(false)
const isFullscreen = ref(false)
// The edges and nodes of SearchFlow.vue
// These are not managed inside SearchFlow, because here we need to switch between
// two SearchFlows in fullscreen and non-fullscreen mode
const flowNodes = ref<SearchNode[]>([flowRootNode()])
const flowEdges = ref<SearchEdge[]>([])
const selectedNode = computed(() => {
if (selectedNodeId.value) {
@ -202,19 +209,35 @@
selectedNodeId.value = undefined
} else {
selectedNodeId.value = nodeId
flowRef.value?.layoutGraph(true)
}
}
// The default root node for SearchFlow
function flowRootNode(): SearchNode {
return {
id: '0',
data: { title: 'Start' },
position: { x: 0, y: 0 },
type: 'search', // We only have this type
}
}
async function startResearch(retryNode?: DeepResearchNode) {
if (!form.value.query || !form.value.breadth || !form.value.depth) return
// 如果不是重试,清空所有节点
// Clear all nodes if it's not a retry
if (!retryNode) {
nodes.value = [{ ...rootNode }]
selectedNodeId.value = undefined
searchResults.value = {}
flowRef.value?.clearNodes()
flowNodes.value = [flowRootNode()]
flowEdges.value = []
isLoading.value = true
// Wait for the nodes and edges to reflect to `SearchFlow.vue`
nextTick(() => {
flowRef.value?.reset()
})
}
// Wait after the flow is cleared
@ -302,6 +325,21 @@
await startResearch(nodeCurrentData)
}
let scrollY = 0
function toggleFullscreen() {
// Because changing `isFullscreen` causes the height of the page to change (UCard disappears and appears)
// so we should scroll back to the last position after exiting fullscreen mode.
if (!isFullscreen.value) {
scrollY = window.scrollY
} else {
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY })
})
}
isFullscreen.value = !isFullscreen.value
}
defineExpose({
startResearch,
isLoading,
@ -309,18 +347,75 @@
</script>
<template>
<UCard>
<UModal v-if="isFullscreen" open fullscreen>
<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>
<div class="flex items-center justify-between">
<div>
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
<p class="text-sm text-gray-500">
{{ t('webBrowsing.clickToView') }}
</p>
</div>
<UButton
icon="i-heroicons-arrows-pointing-out"
:variant="isFullscreen ? 'solid' : 'ghost'"
:color="isFullscreen ? 'primary' : 'info'"
@click="toggleFullscreen"
/>
</div>
</template>
<template #body>
<div :class="['flex h-full', !isLargeScreen && 'flex-col']">
<div class="flex-1">
<SearchFlow
ref="flowRef"
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:selected-node-id="selectedNodeId"
fullscreen
@node-click="selectNode"
/>
</div>
<div
v-if="selectedNode"
:class="[
'border-gray-200',
isLargeScreen
? 'border-l w-1/3 pl-4 sm:pl-6'
: 'h-1/2 overflow-y-scroll',
]"
>
<NodeDetail :node="selectedNode" @retry="retryNode" />
</div>
</div>
</template>
</UModal>
<UCard v-if="!isFullscreen">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
<p class="text-sm text-gray-500">
{{ t('webBrowsing.description') }}
<br />
{{ t('webBrowsing.clickToView') }}
</p>
</div>
<UButton
icon="i-heroicons-arrows-pointing-out"
variant="ghost"
color="info"
@click="toggleFullscreen"
/>
</div>
</template>
<div class="flex flex-col">
<SearchFlow
ref="flowRef"
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:selected-node-id="selectedNodeId"
@node-click="selectNode"
/>

View File

@ -13,7 +13,7 @@
</script>
<template>
<div class="p-4">
<div>
<USeparator :label="$t('webBrowsing.nodeDetails')" />
<UAlert
v-if="node.error"

View File

@ -9,6 +9,7 @@
type Node,
VueFlow,
useVueFlow,
getNodesInside,
} from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
@ -25,43 +26,55 @@
(e: 'node-click', nodeId: string): void
}>()
defineProps<{
const props = defineProps<{
selectedNodeId?: string
fullscreen?: boolean
}>()
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 nodes = defineModel<SearchNode[]>('nodes', { required: true })
const edges = defineModel<SearchEdge[]>('edges', { required: true })
const isLargeScreen = useMediaQuery('(min-width: 768px)')
const {
addNodes: addFlowNodes,
addEdges: addFlowEdges,
updateNodeData: updateFlowNodeData,
fitView,
viewport,
vueFlowRef,
} = useVueFlow()
const { layout } = useNodeLayout()
function defaultRootNode(): SearchNode {
return {
id: '0',
data: { title: 'Start' },
position: { ...defaultPosition },
type: 'search', // We only have this type
}
}
let hasUserInteraction = false
function handleNodeClick(nodeId: string) {
emit('node-click', nodeId)
}
function layoutGraph() {
function layoutGraph(force = false) {
nodes.value = layout(nodes.value, edges.value)
if (!hasUserInteraction) {
nextTick(() => {
fitView({})
})
if (!hasUserInteraction || force) {
// Wait a bit for the viewport to update after resize
setTimeout(() => {
// If a node is selected and is outside the viewport, move it to the viewport
if (props.selectedNodeId) {
const rect = vueFlowRef.value?.getBoundingClientRect()
if (!rect) return
const nodesInViewport = getNodesInside(
// @ts-ignore
nodes.value,
rect,
viewport.value,
)
if (!nodesInViewport.some((n) => n.id === props.selectedNodeId)) {
fitView({ nodes: [props.selectedNodeId], maxZoom: 1.3 })
}
} else {
fitView({ maxZoom: 1.4 })
}
}, 10)
}
}
@ -69,7 +82,7 @@
addFlowNodes({
id: nodeId,
data,
position: { ...defaultPosition },
position: { ...{ x: 0, y: 0 } },
type: 'search',
})
@ -89,9 +102,8 @@
layoutGraph()
}
function clearNodes() {
nodes.value = [defaultRootNode()]
edges.value = []
function reset() {
console.warn('reset')
layoutGraph()
hasUserInteraction = false
}
@ -123,14 +135,15 @@
defineExpose({
addNode,
updateNode,
clearNodes,
reset,
removeChildNodes,
layoutGraph,
})
</script>
<template>
<ClientOnly fallback-tag="span" fallback="Loading graph...">
<div :class="[isLargeScreen ? 'h-100' : 'h-60']">
<div :class="[fullscreen ? 'h-full' : isLargeScreen ? 'h-100' : 'h-60']">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"