Files
ThinkFlowAI/src/composables/useThinkFlow.ts
liuziting e442296757 docs: 更新README文档内容与结构
refactor(useThinkFlow): 改进节点布局算法以支持动态尺寸计算
style(i18n): 修改"deepDive"翻译为"回答"以更准确表达功能
2026-01-22 08:50:54 +08:00

1070 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ThinkFlow 核心业务 composable
* - 统一管理:画布状态(节点/边、交互状态、API 调用、错误处理与导出能力
* - 对外提供页面与组件可直接调用的状态与动作expand / deepDive / image / summary 等)
*/
import { computed, reactive, ref, watch, type Ref } from 'vue'
import { MarkerType, Position, useVueFlow } from '@vue-flow/core'
import { BackgroundVariant } from '@vue-flow/background'
/**
* i18n 翻译函数类型(等价于 vue-i18n 的 t
*/
type Translate = (key: string, params?: any) => string
/**
* 创建 ThinkFlow 的业务上下文。
* @param t 国际化翻译函数
* @param locale 当前语言(用于持久化语言选择)
*/
export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string> }) {
/**
* 默认模式下的 API Key当前不使用环境变量注入
* - 如需鉴权,请通过 Settings 的 Custom 模式填写 apiKey
*/
const API_KEY = ''
/**
* API 配置(支持默认/自定义两种模式)
* - 自定义模式写入 localStorage刷新后仍保留
*/
const apiConfig = reactive({
mode: localStorage.getItem('api_mode') || 'default',
chat: {
baseUrl: localStorage.getItem('chat_baseUrl') || '',
model: localStorage.getItem('chat_model') || '',
apiKey: localStorage.getItem('chat_apiKey') || ''
},
image: {
baseUrl: localStorage.getItem('image_baseUrl') || '',
model: localStorage.getItem('image_model') || '',
apiKey: localStorage.getItem('image_apiKey') || ''
}
})
/**
* 默认接口配置(当用户选择默认模式时使用)
* - apiKey 允许为空:会回退到 API_KEY环境变量
*/
const DEFAULT_CONFIG = {
chat: {
baseUrl: 'https://thinkflow.lz-t.top/chat/completions',
model: 'glm-4-flash',
apiKey: ''
},
image: {
baseUrl: 'https://thinkflow.lz-t.top/images/generations',
model: 'cogview-3-flash',
apiKey: ''
}
}
/**
* 语言选择持久化(与 i18n/index.ts 中的初始化配合)
*/
watch(
() => locale.value,
newVal => {
localStorage.setItem('language', newVal)
}
)
/**
* API 配置持久化:任何字段变化都会更新 localStorage
*/
watch(
() => apiConfig,
newVal => {
localStorage.setItem('api_mode', newVal.mode)
localStorage.setItem('chat_baseUrl', newVal.chat.baseUrl)
localStorage.setItem('chat_model', newVal.chat.model)
localStorage.setItem('chat_apiKey', newVal.chat.apiKey)
localStorage.setItem('image_baseUrl', newVal.image.baseUrl)
localStorage.setItem('image_model', newVal.image.model)
localStorage.setItem('image_apiKey', newVal.image.apiKey)
},
{ deep: true }
)
/**
* 设置弹窗开关(由顶部导航触发)
*/
const showSettings = ref(false)
/**
* VueFlow 实例能力集合:节点/边增删改与视图控制
*/
const {
addNodes,
addEdges,
setNodes,
setEdges,
nodes: flowNodes,
edges: flowEdges,
updateNode,
removeNodes,
removeEdges,
fitView,
viewport,
onNodeDragStart,
onNodeDrag,
onNodeDragStop
} = useVueFlow()
/**
* 全局输入与对话状态
*/
const ideaInput = ref('')
const isLoading = ref(false)
const previewImageUrl = ref<string | null>(null)
const showResetConfirm = ref(false)
const showSummaryModal = ref(false)
const isSummarizing = ref(false)
const summaryContent = ref('')
const draggingNodeId = ref<string | null>(null)
const dragLastPositionByNodeId = new Map<string, { x: number; y: number }>()
const alignmentGuides = ref<{ x: number | null; y: number | null }>({ x: null, y: null })
/**
* 交互:按住 Space 启用“抓手拖拽画布”
* - isSpacePressed 用于在 UI 层展示手型光标
* - panOnDrag 控制 VueFlow 的拖拽行为(按 Space 时总是允许拖拽画布)
*/
const panOnDrag = ref(true)
const isSpacePressed = ref(false)
window.addEventListener('keydown', e => {
if (e.code === 'Space' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
isSpacePressed.value = true
panOnDrag.value = true
}
})
window.addEventListener('keyup', e => {
if (e.code === 'Space') {
isSpacePressed.value = false
}
})
/**
* 用拖拽节点作为“当前激活节点”的一种来源,便于高亮路径/聚焦
*/
onNodeDragStart(e => {
draggingNodeId.value = e.node.id
dragLastPositionByNodeId.set(e.node.id, {
x: e.node.position.x,
y: e.node.position.y
})
})
/**
* 处理节点拖拽事件 (联动移动子节点)
*/
const handleNodeDrag = (payload: any) => {
const node = payload?.node ?? payload
if (!node?.id || !node?.position) return
const draggedStoreNode = flowNodes.value.find(n => n.id === node.id)
if (!draggedStoreNode) return
if (config.snapToAlignment) {
const snapThreshold = 8
const getNodeSize = (n: any) => {
const width = n.dimensions?.width ?? n.measured?.width ?? 280
const height = n.dimensions?.height ?? n.measured?.height ?? 180
return { width, height }
}
const draggedSize = getNodeSize(draggedStoreNode)
const proposedX = node.position.x
const proposedY = node.position.y
const draggedAnchorsX = [proposedX, proposedX + draggedSize.width / 2, proposedX + draggedSize.width]
const draggedAnchorsY = [proposedY, proposedY + draggedSize.height / 2, proposedY + draggedSize.height]
let bestX: { delta: number; guide: number } | null = null
let bestY: { delta: number; guide: number } | null = null
for (const other of flowNodes.value) {
if (other.id === node.id) continue
if (other.hidden) continue
const otherSize = getNodeSize(other)
const otherX = other.position?.x ?? 0
const otherY = other.position?.y ?? 0
const otherAnchorsX = [otherX, otherX + otherSize.width / 2, otherX + otherSize.width]
const otherAnchorsY = [otherY, otherY + otherSize.height / 2, otherY + otherSize.height]
for (const ox of otherAnchorsX) {
for (const ax of draggedAnchorsX) {
const delta = ox - ax
const absDelta = Math.abs(delta)
if (absDelta <= snapThreshold && (!bestX || absDelta < Math.abs(bestX.delta))) {
bestX = { delta, guide: ox }
}
}
}
for (const oy of otherAnchorsY) {
for (const ay of draggedAnchorsY) {
const delta = oy - ay
const absDelta = Math.abs(delta)
if (absDelta <= snapThreshold && (!bestY || absDelta < Math.abs(bestY.delta))) {
bestY = { delta, guide: oy }
}
}
}
}
const snappedX = bestX ? proposedX + bestX.delta : proposedX
const snappedY = bestY ? proposedY + bestY.delta : proposedY
alignmentGuides.value = config.showAlignmentGuides ? { x: bestX?.guide ?? null, y: bestY?.guide ?? null } : { x: null, y: null }
if (snappedX !== proposedX || snappedY !== proposedY) {
draggedStoreNode.position = { x: snappedX, y: snappedY }
node.position = { x: snappedX, y: snappedY }
}
} else {
alignmentGuides.value = { x: null, y: null }
}
if (!config.hierarchicalDragging) return
const lastPosition = dragLastPositionByNodeId.get(node.id)
if (!lastPosition) {
const fallbackDelta = payload?.delta
dragLastPositionByNodeId.set(node.id, { x: node.position.x, y: node.position.y })
if (fallbackDelta && typeof fallbackDelta.x === 'number' && typeof fallbackDelta.y === 'number') {
const descendantIds = getDescendantIds(node.id)
if (descendantIds.size === 0) return
const selectedNodeIds = new Set(flowNodes.value.filter(n => n.selected).map(n => n.id))
descendantIds.forEach(id => {
if (!selectedNodeIds.has(id)) {
const targetNode = flowNodes.value.find(n => n.id === id)
if (targetNode) {
targetNode.position = {
x: targetNode.position.x + fallbackDelta.x,
y: targetNode.position.y + fallbackDelta.y
}
}
}
})
}
return
}
const dx = node.position.x - lastPosition.x
const dy = node.position.y - lastPosition.y
if (dx === 0 && dy === 0) return
dragLastPositionByNodeId.set(node.id, { x: node.position.x, y: node.position.y })
const descendantIds = getDescendantIds(node.id)
if (descendantIds.size === 0) return
// 获取当前所有选中的节点,避免重复位移
const selectedNodeIds = new Set(flowNodes.value.filter(n => n.selected).map(n => n.id))
// 批量更新子节点位置
descendantIds.forEach(id => {
if (!selectedNodeIds.has(id)) {
const targetNode = flowNodes.value.find(n => n.id === id)
if (targetNode) {
// 直接更新位置对象,确保 Vue 能够检测到深层变化
targetNode.position = {
x: targetNode.position.x + dx,
y: targetNode.position.y + dy
}
}
}
})
}
onNodeDrag(handleNodeDrag)
onNodeDragStop(e => {
draggingNodeId.value = null
if (e?.node?.id) {
dragLastPositionByNodeId.delete(e.node.id)
}
alignmentGuides.value = { x: null, y: null }
})
/**
* 当前激活节点 id
* 优先级:正在展开的节点 > 选中的节点 > 正在拖拽的节点
*/
const activeNodeId = computed(() => {
const expandingNode = flowNodes.value.find(n => n.data.isExpanding)
const selectedNode = flowNodes.value.find(n => n.selected)
return expandingNode?.id || selectedNode?.id || draggingNodeId.value
})
/**
* 获取节点的所有后代节点 ID (迭代实现,更健壮)
*/
const getDescendantIds = (nodeId: string): Set<string> => {
const descendants = new Set<string>()
const stack = [nodeId]
const edges = flowEdges.value
while (stack.length > 0) {
const currentId = stack.pop()!
for (const edge of edges) {
if (edge.source === currentId) {
if (!descendants.has(edge.target)) {
descendants.add(edge.target)
stack.push(edge.target)
}
}
}
}
return descendants
}
/**
* 删除指定节点的所有后代节点
* 用于在重新扩展某个节点时,清空其原有的子树
*/
const removeDescendants = (nodeId: string) => {
const descendantIds = getDescendantIds(nodeId)
if (descendantIds.size > 0) {
removeNodes(Array.from(descendantIds))
}
}
/**
* 当前激活路径(节点集合 + 边集合)
* - 向上:从激活节点回溯到根
* - 向下:包含激活节点的所有后代
* 用于:
* - 节点高亮/弱化
* - 边高亮/动画
*/
const activePath = computed(() => {
const nodeIds = new Set<string>()
const edgeIds = new Set<string>()
if (!activeNodeId.value) return { nodeIds, edgeIds }
const targetId = activeNodeId.value
nodeIds.add(targetId)
let currentId = targetId
while (currentId) {
const edge = flowEdges.value.find(e => e.target === currentId)
if (edge) {
edgeIds.add(edge.id)
nodeIds.add(edge.source)
currentId = edge.source
} else {
break
}
}
const descendantIds = getDescendantIds(targetId)
descendantIds.forEach(id => nodeIds.add(id))
flowEdges.value.forEach(edge => {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
edgeIds.add(edge.id)
}
})
return { nodeIds, edgeIds }
})
const config = reactive({
edgeColor: '#fed7aa',
edgeType: 'default',
backgroundVariant: BackgroundVariant.Lines,
showControls: true,
showMiniMap: true,
hierarchicalDragging: true,
snapToGrid: true,
snapGrid: [16, 16] as [number, number],
snapToAlignment: true,
showAlignmentGuides: true
})
const collapsedNodeIds = ref<string[]>([])
const isSubtreeCollapsed = (nodeId: string) => collapsedNodeIds.value.includes(nodeId)
const toggleSubtreeCollapse = (nodeId: string) => {
if (isSubtreeCollapsed(nodeId)) {
collapsedNodeIds.value = collapsedNodeIds.value.filter(id => id !== nodeId)
} else {
collapsedNodeIds.value = [...collapsedNodeIds.value, nodeId]
}
}
const applyCollapsedVisibility = () => {
const hiddenIds = new Set<string>()
const childrenCountById = new Map<string, number>()
for (const e of flowEdges.value) {
childrenCountById.set(e.source, (childrenCountById.get(e.source) ?? 0) + 1)
}
for (const id of collapsedNodeIds.value) {
const descendants = getDescendantIds(id)
descendants.forEach(d => hiddenIds.add(d))
}
setNodes(
flowNodes.value.map(n => {
const isHidden = hiddenIds.has(n.id)
const isCollapsed = isSubtreeCollapsed(n.id)
const hiddenDescendantCount = isCollapsed ? getDescendantIds(n.id).size : 0
const childrenCount = childrenCountById.get(n.id) ?? 0
const nextData =
n.data?.hiddenDescendantCount !== hiddenDescendantCount || n.data?.childrenCount !== childrenCount
? { ...n.data, hiddenDescendantCount, childrenCount }
: n.data
return {
...n,
hidden: isHidden,
data: nextData
}
})
)
setEdges(
flowEdges.value.map(e => ({
...e,
hidden: hiddenIds.has(e.source) || hiddenIds.has(e.target)
}))
)
}
watch([() => collapsedNodeIds.value.join(','), () => flowNodes.value.length, () => flowEdges.value.length], applyCollapsedVisibility, { immediate: true })
const lastAppliedStatus = ref('')
/**
* 根据激活路径与配置,动态更新边的样式(高亮/透明度/动画)
* - 通过 lastAppliedStatus 避免无效重复 setEdges
*/
watch(
[() => activeNodeId.value, () => config.edgeColor, () => config.edgeType, () => flowEdges.value.length, () => flowNodes.value.some(n => n.data.isExpanding)],
([, newColor, newType, , anyExpanding]) => {
const { edgeIds } = activePath.value
const edgeIdsStr = Array.from(edgeIds).sort().join(',')
const currentStatus = `${edgeIdsStr}-${newColor}-${newType}-${anyExpanding}`
if (lastAppliedStatus.value === currentStatus) return
lastAppliedStatus.value = currentStatus
setEdges(
flowEdges.value.map(edge => {
const isHighlighted = edgeIds.has(edge.id)
const isExpanding = !!flowNodes.value.find(n => n.id === edge.source)?.data.isExpanding
return {
...edge,
type: newType,
animated: isHighlighted || isExpanding,
style: {
...edge.style,
stroke: isHighlighted ? newColor : `${newColor}33`,
strokeWidth: isHighlighted ? 3 : 2,
transition: 'all 0.3s ease'
}
}
})
)
},
{ immediate: true }
)
/**
* 将网络/接口异常转换为用户可读的错误文案
*/
const getErrorMessage = (error: any) => {
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return t('common.error.cors')
}
if (error.status === 429) return t('common.error.rateLimit')
if (error.status === 400) return t('common.error.badRequest')
if (error.status >= 500) return t('common.error.serverError')
return error.message || t('common.error.unknown')
}
/**
* 视图:将根节点居中显示
*/
const centerRoot = () => {
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (rootNode) {
fitView({ nodes: [rootNode.id], padding: 2, duration: 800 })
}
}
/**
* 布局:从根节点开始按“横向树形”重新排布节点位置
* - 动态计算节点宽高与子树高度,确保在节点展开(回答/生图)后仍能整齐排布
*/
const resetLayout = () => {
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (!rootNode) return
const nodeGapX = 150 // 节点层级间的横向间距
const nodeGapY = 40 // 同级节点间的纵向间距
/**
* 获取节点的实际尺寸,优先使用已测量尺寸,否则使用默认值
*/
const getNodeSize = (node: any) => ({
width: node.dimensions?.width ?? node.measured?.width ?? 280,
height: node.dimensions?.height ?? node.measured?.height ?? 180
})
// 存储每个节点及其子树所需的总高度
const subtreeHeights = new Map<string, number>()
/**
* 第一遍遍历:递归计算每个节点及其子树占用的总高度
*/
const calculateSubtreeHeight = (nodeId: string): number => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node || node.hidden) return 0
const size = getNodeSize(node)
const children = flowEdges.value
.filter(e => e.source === nodeId)
.map(e => e.target)
.filter(id => {
const childNode = flowNodes.value.find(n => n.id === id)
return childNode && !childNode.hidden
})
if (children.length === 0) {
subtreeHeights.set(nodeId, size.height)
return size.height
}
let childrenTotalHeight = 0
children.forEach((childId, index) => {
childrenTotalHeight += calculateSubtreeHeight(childId)
if (index < children.length - 1) {
childrenTotalHeight += nodeGapY
}
})
// 节点自身高度与子树高度取较大值
const totalHeight = Math.max(size.height, childrenTotalHeight)
subtreeHeights.set(nodeId, totalHeight)
return totalHeight
}
// 开始计算
calculateSubtreeHeight(rootNode.id)
const visited = new Set<string>()
/**
* 第二遍遍历:根据计算出的子树高度,递归设置节点位置
* @param nodeId 当前节点 ID
* @param x 当前起始 X 坐标
* @param ySubtreeTop 当前子树顶部的 Y 坐标
*/
const layoutNode = (nodeId: string, x: number, ySubtreeTop: number) => {
if (visited.has(nodeId)) return
visited.add(nodeId)
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node || node.hidden) return
const size = getNodeSize(node)
const subtreeHeight = subtreeHeights.get(nodeId) || size.height
// 将节点放置在子树区域的垂直中心
node.position = {
x,
y: ySubtreeTop + (subtreeHeight - size.height) / 2
}
const children = flowEdges.value
.filter(e => e.source === nodeId)
.map(e => e.target)
.filter(id => {
const childNode = flowNodes.value.find(n => n.id === id)
return childNode && !childNode.hidden
})
if (children.length > 0) {
const nextX = x + size.width + nodeGapX
let currentY = ySubtreeTop
children.forEach(childId => {
const childSubtreeHeight = subtreeHeights.get(childId) || 0
layoutNode(childId, nextX, currentY)
currentY += childSubtreeHeight + nodeGapY
})
}
}
// 从根节点开始排版
layoutNode(rootNode.id, 50, 100)
// 延迟执行 fitView 确保位置更新已应用
setTimeout(() => {
fitView({ padding: 0.2, duration: 800 })
}, 100)
}
/**
* 导出:将当前树形结构导出为 Markdown
* - 以 root 为标题
* - 子节点按缩进列表输出
* - deepDive 生成的详细内容以引用块输出
*/
const exportMarkdown = () => {
if (flowNodes.value.length === 0) return
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (!rootNode) return
let markdown = `# ${rootNode.data.label}\n\n`
const buildMarkdown = (parentId: string, level: number) => {
const children = flowEdges.value
.filter(e => e.source === parentId)
.map(e => flowNodes.value.find(n => n.id === e.target))
.filter(n => n !== undefined)
children.forEach(child => {
const indent = ' '.repeat(level - 1)
markdown += `${indent}- ${child!.data.label}\n`
if (child!.data.detailedContent) {
const detailIndent = ' '.repeat(level)
markdown += `${detailIndent}> ${child!.data.detailedContent.replace(/\n/g, `\n${detailIndent}> `)}\n`
}
buildMarkdown(child!.id, level + 1)
})
}
buildMarkdown(rootNode.id, 1)
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.download = `thinkflow-${rootNode.data.label}-${Date.now()}.md`
link.href = url
link.click()
URL.revokeObjectURL(url)
}
/**
* 总结:基于当前所有节点信息生成一段总结文本
* - 结果展示在 SummaryModal
*/
const generateSummary = async () => {
if (flowNodes.value.length === 0) return
showSummaryModal.value = true
isSummarizing.value = true
summaryContent.value = ''
const nodesInfo = flowNodes.value.map(n => ({
label: n.data.label,
description: n.data.description,
type: n.data.type
}))
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [
{
role: 'user',
content: t('prompts.summaryPrompt', {
nodes: JSON.stringify(nodesInfo, null, 2)
})
}
]
})
})
if (!response.ok) throw new Error('Summary request failed')
const data = await response.json()
summaryContent.value = data.choices[0].message.content
} catch (error) {
console.error('Summary Generation Error:', error)
summaryContent.value = t('common.error.unknown')
} finally {
isSummarizing.value = false
}
}
/**
* 图片:为指定节点生成配图
* - 节点会进入 isImageLoading 状态
* - 成功后写入 imageUrl用于节点卡片与预览弹窗展示
*/
const generateNodeImage = async (nodeId: string, prompt: string) => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node || node.data.isImageLoading) return
updateNode(nodeId, ((n: any) => ({ ...n, selected: true, zIndex: 1000 })) as any)
updateNode(nodeId, { data: { ...node.data, isImageLoading: true, error: null } })
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.image : apiConfig.image
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const topic = node.data.label || prompt
const detail = node.data.description || ''
const path = findPathToNode(nodeId)
const context = path.length > 5 ? `... -> ${path.slice(-4).join(' -> ')}` : path.join(' -> ')
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
prompt: t('prompts.image', { topic, detail, context })
})
})
if (!response.ok) {
const error: any = new Error('Image request failed')
error.status = response.status
throw error
}
const data = await response.json()
const imageUrl = data.data[0].url
updateNode(nodeId, { data: { ...node.data, imageUrl, isImageLoading: false, error: null } })
} catch (error: any) {
console.error('Image Generation Error:', error)
updateNode(nodeId, { data: { ...node.data, isImageLoading: false, error: getErrorMessage(error) } })
}
}
/**
* 深挖:针对某个节点生成更详细的解释/拓展内容
* - 若已有 detailedContent 且未展开,则直接展开(避免重复请求)
*/
const deepDive = async (nodeId: string, topic: string) => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node) return
updateNode(nodeId, ((n: any) => ({ ...n, selected: true, zIndex: 1000 })) as any)
if (node.data.detailedContent && !node.data.isDetailExpanded) {
updateNode(nodeId, { data: { ...node.data, isDetailExpanded: true } })
return
}
if (node.data.isDeepDiving) return
updateNode(nodeId, { data: { ...node.data, isDeepDiving: true, isDetailExpanded: true, error: null } })
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [{ role: 'user', content: t('prompts.deepDivePrompt', { topic }) }]
})
})
if (!response.ok) {
const error: any = new Error('Deep dive request failed')
error.status = response.status
throw error
}
const data = await response.json()
const content = data.choices[0].message.content
updateNode(nodeId, { data: { ...node.data, detailedContent: content, isDeepDiving: false, error: null } })
} catch (error: any) {
console.error('Deep Dive Error:', error)
updateNode(nodeId, { data: { ...node.data, isDeepDiving: false, error: getErrorMessage(error) } })
}
}
/**
* 生成从根到指定节点的“上下文路径”文本,用于二次扩展时给模型更明确的上下文
*/
const findPathToNode = (nodeId: string): string[] => {
const path: string[] = []
let currentId = nodeId
while (currentId) {
const node = flowNodes.value.find(n => n.id === currentId)
if (node) {
path.unshift(`${node.data.label} (${node.data.description})`)
const edge = flowEdges.value.find(e => e.target === currentId)
currentId = edge ? edge.source : ''
} else {
break
}
}
return path
}
/**
* 将模型返回的子节点数组写入画布,并连边到 parentId
*/
const processSubNodes = (subNodes: any[], parentId: string, baseX: number, baseY: number) => {
subNodes.forEach((item: any, index: number) => {
const childId = `node-${Date.now()}-${index}`
const offsetX = 450
const offsetY = (index - (subNodes.length - 1) / 2) * 280
addNodes({
id: childId,
type: 'window',
position: { x: baseX + offsetX, y: baseY + offsetY },
data: {
label: item.text,
description: item.description,
type: 'child',
followUp: '',
isExpanding: false,
isImageLoading: false,
isTitleExpanded: false,
error: null
},
sourcePosition: Position.Right,
targetPosition: Position.Left
})
addEdges({
id: `e-${parentId}-${childId}`,
source: parentId,
target: childId,
animated: true,
type: config.edgeType,
style: { stroke: config.edgeColor, strokeWidth: 2 },
markerEnd: MarkerType.ArrowClosed
})
})
}
/**
* 生成/扩展节点
* - 无 parentNode创建 root 节点并生成第一层子节点
* - 有 parentNode基于选中节点生成下一层子节点支持 followUp 作为追加需求)
*
* 接口约定:
* - chat completion 返回 message.content 为 JSON 字符串
* - response_format: { type: 'json_object' } 用于提高 JSON 输出稳定性
*/
const expandIdea = async (param?: any, customInput?: string) => {
const parentNode = param && param.id ? param : undefined
const text = customInput || (parentNode ? parentNode.data.label : ideaInput.value)
if (!text || (parentNode ? parentNode.data.isExpanding : isLoading.value)) return
let currentParentId = parentNode?.id
if (!parentNode) {
isLoading.value = true
setNodes([])
setEdges([])
const rootId = 'root-' + Date.now()
currentParentId = rootId
addNodes({
id: rootId,
type: 'window',
position: { x: 50, y: 300 },
data: {
label: text,
description: t('node.coreIdea'),
type: 'root',
isExpanding: true,
isTitleExpanded: false,
followUp: '',
error: null
},
sourcePosition: Position.Right,
targetPosition: Position.Left
})
ideaInput.value = ''
} else {
const node = flowNodes.value.find(n => n.id === parentNode.id)
if (node) {
updateNode(parentNode.id, {
data: {
...node.data,
isExpanding: true,
isDetailExpanded: false,
error: null
}
})
}
}
const systemPrompt = t('prompts.system')
let userMessage = ''
if (parentNode) {
const path = findPathToNode(parentNode.id)
userMessage = `[${t('prompts.contextPath')}]: ${path.join(' -> ')}\n[${t('prompts.selectedNode')}]: ${parentNode.data.label}\n[${t('prompts.newRequirement')}]: ${customInput || t('prompts.continue')}`
} else {
userMessage = `${t('prompts.coreIdeaPrefix')}: ${text}`
}
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' },
temperature: 0.8
})
})
if (!response.ok) {
const error: any = new Error('AI request failed')
error.status = response.status
throw error
}
const data = await response.json()
const result = JSON.parse(data.choices[0].message.content)
// 如果是重新扩展已有节点,先清空其现有的所有后代节点
if (parentNode && currentParentId) {
removeDescendants(currentParentId)
}
const parentNodeObj = flowNodes.value.find(n => n.id === currentParentId)
const startX = parentNodeObj ? parentNodeObj.position.x : 50
const startY = parentNodeObj ? parentNodeObj.position.y : 300
processSubNodes(result.nodes, currentParentId, startX, startY)
if (!parentNode) {
setTimeout(() => {
const childEdges = flowEdges.value.filter(e => e.source === currentParentId)
const childIds = childEdges.map(e => e.target)
const nodesToFit = [currentParentId, ...childIds.slice(0, 3)]
fitView({
nodes: nodesToFit,
padding: 0.25,
duration: 1000
})
}, 100)
}
} catch (error: any) {
console.error('Expansion Error:', error)
const node = flowNodes.value.find(n => n.id === currentParentId)
if (node) {
updateNode(currentParentId, { data: { ...node.data, error: getErrorMessage(error) } })
}
} finally {
const node = flowNodes.value.find(n => n.id === currentParentId)
if (node) {
node.data.isExpanding = false
}
isLoading.value = false
}
}
/**
* 立即清空当前画布与输入,并关闭确认弹窗
*/
const executeReset = () => {
ideaInput.value = ''
setNodes([])
setEdges([])
showResetConfirm.value = false
}
/**
* 新会话入口
* - 若当前已有节点:弹出二次确认
* - 若为空:直接清空(等价 executeReset
*/
const startNewSession = () => {
if (flowNodes.value.length > 0) {
showResetConfirm.value = true
return
}
executeReset()
}
return {
apiConfig,
DEFAULT_CONFIG,
showSettings,
ideaInput,
isLoading,
previewImageUrl,
showResetConfirm,
showSummaryModal,
isSummarizing,
summaryContent,
panOnDrag,
isSpacePressed,
config,
flowNodes,
flowEdges,
activeNodeId,
activePath,
updateNode,
fitView,
resetLayout,
centerRoot,
handleNodeDrag,
alignmentGuides,
viewport,
toggleSubtreeCollapse,
isSubtreeCollapsed,
startNewSession,
executeReset,
generateSummary,
exportMarkdown,
generateNodeImage,
deepDive,
expandIdea
}
}