feat(DeepResearch): support fullscreen mode
This commit is contained in:
@ -9,10 +9,11 @@
|
|||||||
formInjectionKey,
|
formInjectionKey,
|
||||||
researchResultInjectionKey,
|
researchResultInjectionKey,
|
||||||
} from '~/constants/injection-keys'
|
} from '~/constants/injection-keys'
|
||||||
import Flow from './SearchFlow.vue'
|
import Flow, { type SearchNode, type SearchEdge } from './SearchFlow.vue'
|
||||||
import SearchFlow from './SearchFlow.vue'
|
import SearchFlow from './SearchFlow.vue'
|
||||||
import NodeDetail from './NodeDetail.vue'
|
import NodeDetail from './NodeDetail.vue'
|
||||||
import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node'
|
import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node'
|
||||||
|
import { UCard, UModal, UButton } from '#components'
|
||||||
|
|
||||||
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
|
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
|
||||||
|
|
||||||
@ -39,7 +40,7 @@
|
|||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { config } = storeToRefs(useConfigStore())
|
const isLargeScreen = useMediaQuery('(min-width: 768px)')
|
||||||
|
|
||||||
const flowRef = ref<InstanceType<typeof Flow>>()
|
const flowRef = ref<InstanceType<typeof Flow>>()
|
||||||
const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
|
const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
|
||||||
@ -49,6 +50,12 @@
|
|||||||
const selectedNodeId = ref<string>()
|
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 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(() => {
|
const selectedNode = computed(() => {
|
||||||
if (selectedNodeId.value) {
|
if (selectedNodeId.value) {
|
||||||
@ -202,19 +209,35 @@
|
|||||||
selectedNodeId.value = undefined
|
selectedNodeId.value = undefined
|
||||||
} else {
|
} else {
|
||||||
selectedNodeId.value = nodeId
|
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) {
|
async function startResearch(retryNode?: DeepResearchNode) {
|
||||||
if (!form.value.query || !form.value.breadth || !form.value.depth) return
|
if (!form.value.query || !form.value.breadth || !form.value.depth) return
|
||||||
|
|
||||||
// 如果不是重试,清空所有节点
|
// Clear all nodes if it's not a retry
|
||||||
if (!retryNode) {
|
if (!retryNode) {
|
||||||
nodes.value = [{ ...rootNode }]
|
nodes.value = [{ ...rootNode }]
|
||||||
selectedNodeId.value = undefined
|
selectedNodeId.value = undefined
|
||||||
searchResults.value = {}
|
searchResults.value = {}
|
||||||
flowRef.value?.clearNodes()
|
flowNodes.value = [flowRootNode()]
|
||||||
|
flowEdges.value = []
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
// Wait for the nodes and edges to reflect to `SearchFlow.vue`
|
||||||
|
nextTick(() => {
|
||||||
|
flowRef.value?.reset()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait after the flow is cleared
|
// Wait after the flow is cleared
|
||||||
@ -302,6 +325,21 @@
|
|||||||
await startResearch(nodeCurrentData)
|
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({
|
defineExpose({
|
||||||
startResearch,
|
startResearch,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -309,18 +347,75 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UCard>
|
<UModal v-if="isFullscreen" open fullscreen>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-500">
|
<div>
|
||||||
{{ t('webBrowsing.description') }}
|
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
|
||||||
<br />
|
<p class="text-sm text-gray-500">
|
||||||
{{ t('webBrowsing.clickToView') }}
|
{{ t('webBrowsing.clickToView') }}
|
||||||
</p>
|
</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>
|
</template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<SearchFlow
|
<SearchFlow
|
||||||
ref="flowRef"
|
ref="flowRef"
|
||||||
|
v-model:nodes="flowNodes"
|
||||||
|
v-model:edges="flowEdges"
|
||||||
:selected-node-id="selectedNodeId"
|
:selected-node-id="selectedNodeId"
|
||||||
@node-click="selectNode"
|
@node-click="selectNode"
|
||||||
/>
|
/>
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4">
|
<div>
|
||||||
<USeparator :label="$t('webBrowsing.nodeDetails')" />
|
<USeparator :label="$t('webBrowsing.nodeDetails')" />
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="node.error"
|
v-if="node.error"
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
type Node,
|
type Node,
|
||||||
VueFlow,
|
VueFlow,
|
||||||
useVueFlow,
|
useVueFlow,
|
||||||
|
getNodesInside,
|
||||||
} from '@vue-flow/core'
|
} from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
import { Controls } from '@vue-flow/controls'
|
||||||
@ -25,43 +26,55 @@
|
|||||||
(e: 'node-click', nodeId: string): void
|
(e: 'node-click', nodeId: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
selectedNodeId?: string
|
selectedNodeId?: string
|
||||||
|
fullscreen?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isLargeScreen = useMediaQuery('(min-width: 768px)')
|
const nodes = defineModel<SearchNode[]>('nodes', { required: true })
|
||||||
const defaultPosition = { x: 0, y: 0 }
|
const edges = defineModel<SearchEdge[]>('edges', { required: true })
|
||||||
const nodes = ref<SearchNode[]>([defaultRootNode()])
|
|
||||||
const edges = ref<SearchEdge[]>([])
|
|
||||||
let hasUserInteraction = false
|
|
||||||
|
|
||||||
|
const isLargeScreen = useMediaQuery('(min-width: 768px)')
|
||||||
const {
|
const {
|
||||||
addNodes: addFlowNodes,
|
addNodes: addFlowNodes,
|
||||||
addEdges: addFlowEdges,
|
addEdges: addFlowEdges,
|
||||||
updateNodeData: updateFlowNodeData,
|
updateNodeData: updateFlowNodeData,
|
||||||
fitView,
|
fitView,
|
||||||
|
viewport,
|
||||||
|
vueFlowRef,
|
||||||
} = useVueFlow()
|
} = useVueFlow()
|
||||||
const { layout } = useNodeLayout()
|
const { layout } = useNodeLayout()
|
||||||
|
|
||||||
function defaultRootNode(): SearchNode {
|
let hasUserInteraction = false
|
||||||
return {
|
|
||||||
id: '0',
|
|
||||||
data: { title: 'Start' },
|
|
||||||
position: { ...defaultPosition },
|
|
||||||
type: 'search', // We only have this type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNodeClick(nodeId: string) {
|
function handleNodeClick(nodeId: string) {
|
||||||
emit('node-click', nodeId)
|
emit('node-click', nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutGraph() {
|
function layoutGraph(force = false) {
|
||||||
nodes.value = layout(nodes.value, edges.value)
|
nodes.value = layout(nodes.value, edges.value)
|
||||||
if (!hasUserInteraction) {
|
if (!hasUserInteraction || force) {
|
||||||
nextTick(() => {
|
// Wait a bit for the viewport to update after resize
|
||||||
fitView({})
|
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({
|
addFlowNodes({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
data,
|
data,
|
||||||
position: { ...defaultPosition },
|
position: { ...{ x: 0, y: 0 } },
|
||||||
type: 'search',
|
type: 'search',
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -89,9 +102,8 @@
|
|||||||
layoutGraph()
|
layoutGraph()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNodes() {
|
function reset() {
|
||||||
nodes.value = [defaultRootNode()]
|
console.warn('reset')
|
||||||
edges.value = []
|
|
||||||
layoutGraph()
|
layoutGraph()
|
||||||
hasUserInteraction = false
|
hasUserInteraction = false
|
||||||
}
|
}
|
||||||
@ -123,14 +135,15 @@
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
addNode,
|
addNode,
|
||||||
updateNode,
|
updateNode,
|
||||||
clearNodes,
|
reset,
|
||||||
removeChildNodes,
|
removeChildNodes,
|
||||||
|
layoutGraph,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly fallback-tag="span" fallback="Loading graph...">
|
<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
|
<VueFlow
|
||||||
v-model:nodes="nodes"
|
v-model:nodes="nodes"
|
||||||
v-model:edges="edges"
|
v-model:edges="edges"
|
||||||
|
Reference in New Issue
Block a user