mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-04-25 11:18:06 +00:00
Merge pull request #532 from LaansDole/feat/tooltip-enhancement
Feat: tooltip enhancement
This commit is contained in:
commit
4a6ee25d79
351
frontend/src/components/RichTooltip.vue
Normal file
351
frontend/src/components/RichTooltip.vue
Normal file
@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="tooltip-wrapper"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown.space.prevent="handleKeyboardActivate"
|
||||
@keydown.enter.prevent="handleKeyboardActivate"
|
||||
@keydown.escape="handleDismiss"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<teleport to="body">
|
||||
<transition name="tooltip-fade">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
ref="tooltipRef"
|
||||
class="rich-tooltip"
|
||||
:class="[`tooltip-${placement}`, { 'tooltip-keyboard-active': keyboardActive }]"
|
||||
:style="tooltipStyle"
|
||||
role="tooltip"
|
||||
:aria-hidden="!isVisible"
|
||||
@mouseenter="handleTooltipMouseEnter"
|
||||
@mouseleave="handleTooltipMouseLeave"
|
||||
>
|
||||
<div class="tooltip-content">
|
||||
<h4 v-if="content.title" class="tooltip-title">{{ content.title }}</h4>
|
||||
<p class="tooltip-description">{{ content.description }}</p>
|
||||
<ul v-if="content.examples && content.examples.length" class="tooltip-examples">
|
||||
<li v-for="(example, index) in content.examples" :key="index">{{ example }}</li>
|
||||
</ul>
|
||||
<a
|
||||
v-if="content.learnMoreUrl"
|
||||
:href="content.learnMoreUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="tooltip-learn-more"
|
||||
@click="handleLearnMore"
|
||||
>
|
||||
Learn More →
|
||||
</a>
|
||||
</div>
|
||||
<div class="tooltip-arrow" :data-placement="placement"></div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (value) => {
|
||||
return value && typeof value.description === 'string'
|
||||
}
|
||||
},
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 500
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const isVisible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const tooltipStyle = ref({})
|
||||
const hoverTimeout = ref(null)
|
||||
const mouseInTooltip = ref(false)
|
||||
const keyboardActive = ref(false)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
keyboardActive.value = false
|
||||
clearTimeout(hoverTimeout.value)
|
||||
hoverTimeout.value = setTimeout(() => {
|
||||
showTooltip()
|
||||
}, props.delay)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(hoverTimeout.value)
|
||||
// Delay hiding to allow moving into tooltip
|
||||
setTimeout(() => {
|
||||
if (!mouseInTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
// Don't auto-show on focus, wait for keyboard activation
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (keyboardActive.value) {
|
||||
hideTooltip()
|
||||
keyboardActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyboardActivate = () => {
|
||||
keyboardActive.value = true
|
||||
showTooltip()
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
hideTooltip()
|
||||
keyboardActive.value = false
|
||||
}
|
||||
|
||||
const handleTooltipMouseEnter = () => {
|
||||
mouseInTooltip.value = true
|
||||
}
|
||||
|
||||
const handleTooltipMouseLeave = () => {
|
||||
mouseInTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
// Keep tooltip open when clicking learn more
|
||||
// The link will open in new tab
|
||||
}
|
||||
|
||||
const showTooltip = async () => {
|
||||
isVisible.value = true
|
||||
await nextTick()
|
||||
calculatePosition()
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
isVisible.value = false
|
||||
tooltipStyle.value = {}
|
||||
}
|
||||
|
||||
const calculatePosition = () => {
|
||||
if (!tooltipRef.value || !wrapperRef.value) return
|
||||
|
||||
const wrapper = wrapperRef.value.getBoundingClientRect()
|
||||
const tooltip = tooltipRef.value.getBoundingClientRect()
|
||||
const viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}
|
||||
|
||||
const offset = 12 // Distance from target element
|
||||
let top = 0
|
||||
let left = 0
|
||||
let actualPlacement = props.placement
|
||||
|
||||
// Calculate initial position based on preferred placement
|
||||
switch (props.placement) {
|
||||
case 'top':
|
||||
top = wrapper.top - tooltip.height - offset
|
||||
left = wrapper.left + (wrapper.width / 2) - (tooltip.width / 2)
|
||||
break
|
||||
case 'bottom':
|
||||
top = wrapper.bottom + offset
|
||||
left = wrapper.left + (wrapper.width / 2) - (tooltip.width / 2)
|
||||
break
|
||||
case 'left':
|
||||
top = wrapper.top + (wrapper.height / 2) - (tooltip.height / 2)
|
||||
left = wrapper.left - tooltip.width - offset
|
||||
break
|
||||
case 'right':
|
||||
top = wrapper.top + (wrapper.height / 2) - (tooltip.height / 2)
|
||||
left = wrapper.right + offset
|
||||
break
|
||||
}
|
||||
|
||||
// Adjust if tooltip would go off-screen
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
} else if (left + tooltip.width > viewport.width - 10) {
|
||||
left = viewport.width - tooltip.width - 10
|
||||
}
|
||||
|
||||
if (top < 10) {
|
||||
// If it would go above viewport, flip to bottom
|
||||
if (props.placement === 'top') {
|
||||
top = wrapper.bottom + offset
|
||||
actualPlacement = 'bottom'
|
||||
} else {
|
||||
top = 10
|
||||
}
|
||||
} else if (top + tooltip.height > viewport.height - 10) {
|
||||
// If it would go below viewport, flip to top
|
||||
if (props.placement === 'bottom') {
|
||||
top = wrapper.top - tooltip.height - offset
|
||||
actualPlacement = 'top'
|
||||
} else {
|
||||
top = viewport.height - tooltip.height - 10
|
||||
}
|
||||
}
|
||||
|
||||
tooltipStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to scroll and zoom events to dismiss tooltip
|
||||
const handleScroll = () => {
|
||||
if (isVisible.value && !keyboardActive.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (isVisible.value) {
|
||||
calculatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// wrapperRef is now set via template ref, no need to query for it
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(hoverTimeout.value)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rich-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
max-width: 320px;
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
color: #f2f2f2;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-keyboard-active {
|
||||
outline: 2px solid #60b7ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tooltip-description {
|
||||
margin: 0 0 8px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.tooltip-examples {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
color: #c0c0c0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tooltip-examples li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tooltip-learn-more {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: #60b7ff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tooltip-learn-more:hover {
|
||||
color: #99ffeb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: inherit;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip-top .tooltip-arrow {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip-bottom .tooltip-arrow {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip-left .tooltip-arrow {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip-right .tooltip-arrow {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-active,
|
||||
.tooltip-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.tooltip-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -21,6 +21,13 @@
|
||||
</label>
|
||||
<p class="setting-desc">Automatically expand message content in the chat view.</p>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="localConfig.ENABLE_HELP_TOOLTIPS">
|
||||
Enable help tooltips
|
||||
</label>
|
||||
<p class="setting-desc">Show contextual help tooltips throughout the workflow interface.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="cancel-button" @click="close">Cancel</button>
|
||||
@ -44,7 +51,8 @@ const props = defineProps({
|
||||
|
||||
const localConfig = reactive({
|
||||
AUTO_SHOW_ADVANCED: false,
|
||||
AUTO_EXPAND_MESSAGES: false
|
||||
AUTO_EXPAND_MESSAGES: false,
|
||||
ENABLE_HELP_TOOLTIPS: true
|
||||
})
|
||||
|
||||
watch(() => props.isVisible, (newVal) => {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import RichTooltip from './RichTooltip.vue'
|
||||
import { helpContent } from '../utils/helpContent.js'
|
||||
import { configStore } from '../utils/configStore.js'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@ -12,10 +15,19 @@ const props = defineProps({
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const shouldShowTooltip = computed(() => configStore.ENABLE_HELP_TOOLTIPS)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="start-node" :style="{ opacity: data.opacity ?? 1 }">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.startNode" placement="right">
|
||||
<div class="start-node" :style="{ opacity: data.opacity ?? 1 }">
|
||||
<div class="start-node-bubble" title="Start Node"></div>
|
||||
<!-- Provide source handle at right -->
|
||||
<Handle id="source" type="source" :position="Position.Right" class="start-node-handle" />
|
||||
</div>
|
||||
</RichTooltip>
|
||||
<div v-else class="start-node" :style="{ opacity: data.opacity ?? 1 }">
|
||||
<div class="start-node-bubble" title="Start Node"></div>
|
||||
<!-- Provide source handle at right -->
|
||||
<Handle id="source" type="source" :position="Position.Right" class="start-node-handle" />
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
import { computed, ref, nextTick, watch } from 'vue'
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, MarkerType } from '@vue-flow/core'
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
import RichTooltip from './RichTooltip.vue'
|
||||
import { getEdgeHelp } from '../utils/helpContent.js'
|
||||
import { configStore } from '../utils/configStore.js'
|
||||
|
||||
const { findNode } = useVueFlow()
|
||||
|
||||
@ -562,6 +565,44 @@ const labelStyle = computed(() => {
|
||||
|
||||
return s
|
||||
})
|
||||
|
||||
const edgeHelpContent = computed(() => getEdgeHelp(props.data))
|
||||
|
||||
// Compute tooltip position (geometric midpoint of edge path)
|
||||
const tooltipX = computed(() => {
|
||||
const sourceNode = findNode(props.source)
|
||||
const targetNode = findNode(props.target)
|
||||
const isSelfLoop = props.source === props.target
|
||||
const isLeftwardEdge = props.targetX < props.sourceX
|
||||
|
||||
// For self-loops and leftward edges with arc paths, use arc midpoint calculation
|
||||
if (isSelfLoop || isLeftwardEdge) {
|
||||
// Return labelX for arcs (already computed as arc midpoint)
|
||||
return labelX.value
|
||||
}
|
||||
|
||||
// For bezier paths, compute midpoint at t=0.5
|
||||
return (props.sourceX + props.targetX) / 2
|
||||
})
|
||||
|
||||
const tooltipY = computed(() => {
|
||||
const sourceNode = findNode(props.source)
|
||||
const targetNode = findNode(props.target)
|
||||
const isSelfLoop = props.source === props.target
|
||||
const isLeftwardEdge = props.targetX < props.sourceX
|
||||
|
||||
// For self-loops and leftward edges with arc paths, use arc midpoint calculation
|
||||
if (isSelfLoop || isLeftwardEdge) {
|
||||
// Return labelY for arcs (already computed as arc midpoint)
|
||||
return labelY.value
|
||||
}
|
||||
|
||||
// For bezier paths, compute midpoint at t=0.5
|
||||
return (props.sourceY + props.targetY) / 2
|
||||
})
|
||||
|
||||
const shouldShowTooltip = computed(() => configStore.ENABLE_HELP_TOOLTIPS)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -614,6 +655,23 @@ const labelStyle = computed(() => {
|
||||
:animated="false"
|
||||
class="nodrag nopan"
|
||||
/>
|
||||
<!-- Tooltip-enabled hover area at edge midpoint -->
|
||||
<EdgeLabelRenderer v-if="shouldShowTooltip">
|
||||
<RichTooltip :content="edgeHelpContent" placement="top">
|
||||
<div
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${tooltipX}px, ${tooltipY}px)`,
|
||||
pointerEvents: 'all',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer'
|
||||
}"
|
||||
class="edge-tooltip-trigger"
|
||||
/>
|
||||
</RichTooltip>
|
||||
</EdgeLabelRenderer>
|
||||
<EdgeLabelRenderer v-if="edgeLabel">
|
||||
<div
|
||||
:key="edgeLabelKey"
|
||||
@ -649,4 +707,13 @@ const labelStyle = computed(() => {
|
||||
.animated-label {
|
||||
animation: label-pulse var(--label-anim-duration) infinite linear;
|
||||
}
|
||||
|
||||
.edge-tooltip-trigger {
|
||||
background: transparent;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.edge-tooltip-trigger:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,6 +3,9 @@ import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { getNodeStyles } from '../utils/colorUtils.js'
|
||||
import { spriteFetcher } from '../utils/spriteFetcher.js'
|
||||
import RichTooltip from './RichTooltip.vue'
|
||||
import { getNodeHelp } from '../utils/helpContent.js'
|
||||
import { configStore } from '../utils/configStore.js'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@ -37,6 +40,10 @@ const nodeDescription = computed(() => props.data?.description || '')
|
||||
const isActive = computed(() => props.isActive)
|
||||
const dynamicStyles = computed(() => getNodeStyles(nodeType.value))
|
||||
|
||||
const nodeHelpContent = computed(() => getNodeHelp(nodeType.value))
|
||||
|
||||
const shouldShowTooltip = computed(() => configStore.ENABLE_HELP_TOOLTIPS && nodeHelpContent.value)
|
||||
|
||||
// Compute the current sprite path based on active state and walking frame
|
||||
const currentSprite = computed(() => {
|
||||
if (!props.sprite) return ''
|
||||
@ -83,7 +90,43 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-node-container">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="nodeHelpContent" placement="top">
|
||||
<div class="workflow-node-container">
|
||||
<div v-if="props.sprite" class="workflow-node-sprite">
|
||||
<img :src="currentSprite" :alt="`${nodeId} sprite`" class="node-sprite-image" />
|
||||
</div>
|
||||
<div
|
||||
class="workflow-node"
|
||||
:class="{ 'workflow-node-active': isActive }"
|
||||
:data-type="nodeType"
|
||||
:style="dynamicStyles"
|
||||
@mouseenter="$emit('hover', nodeId)"
|
||||
@mouseleave="$emit('leave', nodeId)"
|
||||
>
|
||||
<div class="workflow-node-header">
|
||||
<span class="workflow-node-type">{{ nodeType }}</span>
|
||||
<span class="workflow-node-id">{{ nodeId }}</span>
|
||||
</div>
|
||||
<div v-if="nodeDescription" class="workflow-node-description">
|
||||
{{ nodeDescription }}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="workflow-node-handle"
|
||||
/>
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
class="workflow-node-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RichTooltip>
|
||||
<div v-else class="workflow-node-container">
|
||||
<div v-if="props.sprite" class="workflow-node-sprite">
|
||||
<img :src="currentSprite" :alt="`${nodeId} sprite`" class="node-sprite-image" />
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import markdownItAnchor from 'markdown-it-anchor'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const renderedContent = ref('')
|
||||
const currentLang = ref('en') // 'zh' for Chinese, 'en' for English
|
||||
const markdownBody = ref(null)
|
||||
@ -28,6 +31,23 @@ md.use(markdownItAnchor, {
|
||||
|
||||
const getTutorialFile = () => (currentLang.value === 'en' ? '/tutorial-en.md' : '/tutorial-zh.md')
|
||||
|
||||
const scrollToHash = () => {
|
||||
// Wait for DOM to update, then scroll to hash if present
|
||||
nextTick(() => {
|
||||
if (route.hash) {
|
||||
// Remove the '#' from the hash
|
||||
const targetId = route.hash.slice(1)
|
||||
const targetElement = document.getElementById(targetId)
|
||||
|
||||
if (targetElement) {
|
||||
setTimeout(() => {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addCopyButtons = () => {
|
||||
nextTick(() => {
|
||||
const container = markdownBody.value
|
||||
@ -71,6 +91,7 @@ const loadTutorial = async () => {
|
||||
|
||||
renderedContent.value = md.render(text)
|
||||
addCopyButtons()
|
||||
scrollToHash()
|
||||
} else {
|
||||
console.error('Failed to fetch tutorial markdown')
|
||||
}
|
||||
@ -117,6 +138,7 @@ onMounted(() => {
|
||||
box-shadow: 0 4px 32px 0 rgba(0, 255, 255, 0.08), 0 0 0 2px #00eaff33;
|
||||
border: 1.5px solid #00eaff33;
|
||||
transition: box-shadow 0.3s;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
@ -175,6 +197,23 @@ onMounted(() => {
|
||||
color: #00eaff;
|
||||
text-shadow: 0 0 8px #00eaff44;
|
||||
letter-spacing: 0.02em;
|
||||
scroll-margin-top: 20px; /* Offset for hash scroll target */
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
/* Highlight targeted heading */
|
||||
:deep(.markdown-body h1:target),
|
||||
:deep(.markdown-body h2:target),
|
||||
:deep(.markdown-body h3:target),
|
||||
:deep(.markdown-body h4:target),
|
||||
:deep(.markdown-body h5:target),
|
||||
:deep(.markdown-body h6:target) {
|
||||
background: rgba(0, 234, 255, 0.15);
|
||||
padding: 8px 12px;
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 16px rgba(0, 234, 255, 0.3);
|
||||
}
|
||||
|
||||
:deep(.markdown-body h1) { font-size: 2.2em; border-bottom: 1px solid #00eaff33; padding-bottom: 0.3em; }
|
||||
|
||||
@ -87,7 +87,16 @@
|
||||
>
|
||||
<!-- Pane context menu -->
|
||||
<template v-if="contextMenuType === 'pane'">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createNode" placement="right">
|
||||
<div
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); openCreateNodeModal(); }"
|
||||
>
|
||||
Create Node
|
||||
</div>
|
||||
</RichTooltip>
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); openCreateNodeModal(); }"
|
||||
>
|
||||
@ -97,13 +106,31 @@
|
||||
|
||||
<!-- Node context menu -->
|
||||
<template v-else-if="contextMenuType === 'node'">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.copyNode" placement="right">
|
||||
<div
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); onCopyNodeFromContext(); }"
|
||||
>
|
||||
Copy Node
|
||||
</div>
|
||||
</RichTooltip>
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); onCopyNodeFromContext(); }"
|
||||
>
|
||||
Copy Node
|
||||
</div>
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.deleteNode" placement="right">
|
||||
<div
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); onDeleteNodeFromContext(); }"
|
||||
>
|
||||
Delete Node
|
||||
</div>
|
||||
</RichTooltip>
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); onDeleteNodeFromContext(); }"
|
||||
>
|
||||
@ -113,7 +140,16 @@
|
||||
|
||||
<!-- Edge context menu -->
|
||||
<template v-else-if="contextMenuType === 'edge'">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.deleteEdge" placement="right">
|
||||
<div
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); onDeleteEdgeFromContext(); }"
|
||||
>
|
||||
Delete Edge
|
||||
</div>
|
||||
</RichTooltip>
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
@click.stop="() => { hideContextMenu(); onDeleteEdgeFromContext(); }"
|
||||
>
|
||||
@ -141,13 +177,28 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'graph'" class="editor-actions">
|
||||
<button @click="openCreateNodeModal" class="glass-button">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createNodeButton" placement="bottom">
|
||||
<button @click="openCreateNodeModal" class="glass-button">
|
||||
<span>Create Node</span>
|
||||
</button>
|
||||
</RichTooltip>
|
||||
<button v-else @click="openCreateNodeModal" class="glass-button">
|
||||
<span>Create Node</span>
|
||||
</button>
|
||||
<button @click="openConfigureGraphModal" class="glass-button">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.configureGraph" placement="bottom">
|
||||
<button @click="openConfigureGraphModal" class="glass-button">
|
||||
<span>Configure Graph</span>
|
||||
</button>
|
||||
</RichTooltip>
|
||||
<button v-else @click="openConfigureGraphModal" class="glass-button">
|
||||
<span>Configure Graph</span>
|
||||
</button>
|
||||
<button @click="goToLaunch" class="launch-button-primary">
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.launch" placement="bottom">
|
||||
<button @click="goToLaunch" class="launch-button-primary">
|
||||
<span>Launch</span>
|
||||
</button>
|
||||
</RichTooltip>
|
||||
<button v-else @click="goToLaunch" class="launch-button-primary">
|
||||
<span>Launch</span>
|
||||
</button>
|
||||
|
||||
@ -166,11 +217,26 @@
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="showMenu" class="menu-dropdown">
|
||||
<div @click="openRenameWorkflowModal" class="menu-item">Rename Workflow</div>
|
||||
<div @click="openCopyWorkflowModal" class="menu-item">Copy Workflow</div>
|
||||
<div @click="openManageVarsModal" class="menu-item">Manage Variables</div>
|
||||
<div @click="openManageMemoriesModal" class="menu-item">Manage Memories</div>
|
||||
<div @click="openCreateEdgeModal" class="menu-item">Create Edge</div>
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.renameWorkflow" placement="left">
|
||||
<div @click="openRenameWorkflowModal" class="menu-item">Rename Workflow</div>
|
||||
</RichTooltip>
|
||||
<div v-else @click="openRenameWorkflowModal" class="menu-item">Rename Workflow</div>
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.copyWorkflow" placement="left">
|
||||
<div @click="openCopyWorkflowModal" class="menu-item">Copy Workflow</div>
|
||||
</RichTooltip>
|
||||
<div v-else @click="openCopyWorkflowModal" class="menu-item">Copy Workflow</div>
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.manageVariables" placement="left">
|
||||
<div @click="openManageVarsModal" class="menu-item">Manage Variables</div>
|
||||
</RichTooltip>
|
||||
<div v-else @click="openManageVarsModal" class="menu-item">Manage Variables</div>
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.manageMemories" placement="left">
|
||||
<div @click="openManageMemoriesModal" class="menu-item">Manage Memories</div>
|
||||
</RichTooltip>
|
||||
<div v-else @click="openManageMemoriesModal" class="menu-item">Manage Memories</div>
|
||||
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createEdge" placement="left">
|
||||
<div @click="openCreateEdgeModal" class="menu-item">Create Edge</div>
|
||||
</RichTooltip>
|
||||
<div v-else @click="openCreateEdgeModal" class="menu-item">Create Edge</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@ -258,7 +324,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, watch, nextTick, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
@ -270,8 +336,13 @@ import WorkflowNode from '../components/WorkflowNode.vue'
|
||||
import WorkflowEdge from '../components/WorkflowEdge.vue'
|
||||
import StartNode from '../components/StartNode.vue'
|
||||
import FormGenerator from '../components/FormGenerator.vue'
|
||||
import RichTooltip from '../components/RichTooltip.vue'
|
||||
import yaml from 'js-yaml'
|
||||
import { fetchYaml, fetchVueGraph, postVuegraphs, updateYaml, postYamlNameChange, postYamlCopy } from '../utils/apiFunctions'
|
||||
import { helpContent } from '../utils/helpContent.js'
|
||||
import { configStore } from '../utils/configStore.js'
|
||||
|
||||
const shouldShowTooltip = computed(() => configStore.ENABLE_HELP_TOOLTIPS)
|
||||
|
||||
const props = defineProps({
|
||||
workflowName: {
|
||||
|
||||
@ -25,7 +25,24 @@ const routes = [
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
|
||||
if (to.hash) {
|
||||
return {
|
||||
el: to.hash,
|
||||
behavior: 'smooth',
|
||||
// Add a small delay to ensure the element exists
|
||||
top: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise scroll to top
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@ -4,7 +4,8 @@ const CONFIG_KEY = 'agent_config_settings'
|
||||
|
||||
const defaultSettings = {
|
||||
AUTO_SHOW_ADVANCED: false,
|
||||
AUTO_EXPAND_MESSAGES: false
|
||||
AUTO_EXPAND_MESSAGES: false,
|
||||
ENABLE_HELP_TOOLTIPS: true
|
||||
}
|
||||
|
||||
// Initialize state from localStorage
|
||||
|
||||
234
frontend/src/utils/helpContent.js
Normal file
234
frontend/src/utils/helpContent.js
Normal file
@ -0,0 +1,234 @@
|
||||
export const helpContent = {
|
||||
// Start Node Help
|
||||
startNode: {
|
||||
title: "Start Node",
|
||||
description: "The entry point for your workflow. All nodes connected to the Start node will run in parallel when the workflow launches.",
|
||||
examples: [
|
||||
"Connect multiple nodes to start them simultaneously",
|
||||
"The first nodes to execute receive your initial input"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#2-create-nodes"
|
||||
},
|
||||
|
||||
// Workflow Node Types
|
||||
workflowNode: {
|
||||
agent: {
|
||||
title: "Agent Node",
|
||||
description: "An AI agent that can reason, generate content, and use tools. Agents receive messages and produce responses based on their configuration.",
|
||||
examples: [
|
||||
"Content generation (writing, coding, analysis)",
|
||||
"Decision making and routing",
|
||||
"Tool usage (search, file operations, API calls)"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#agent-node"
|
||||
},
|
||||
human: {
|
||||
title: "Human Node",
|
||||
description: "Pauses workflow execution and waits for human input. Use this to review content, make decisions, or provide feedback.",
|
||||
examples: [
|
||||
"Review and approve generated content",
|
||||
"Provide additional instructions or corrections",
|
||||
"Choose between workflow paths"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#human-node"
|
||||
},
|
||||
python: {
|
||||
title: "Python Node",
|
||||
description: "Executes Python code on your local environment. The code runs in the workspace directory and can access uploaded files.",
|
||||
examples: [
|
||||
"Data processing and analysis",
|
||||
"Running generated code",
|
||||
"File manipulation"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#python-node"
|
||||
},
|
||||
passthrough: {
|
||||
title: "Passthrough Node",
|
||||
description: "Passes messages to the next node without modification. Useful for workflow organization and filtering outputs in loops.",
|
||||
examples: [
|
||||
"Preserve initial context in loops",
|
||||
"Filter redundant outputs",
|
||||
"Organize workflow structure"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#passthrough-node"
|
||||
},
|
||||
literal: {
|
||||
title: "Literal Node",
|
||||
description: "Outputs fixed text, ignoring all input. Use this to inject instructions or context at specific points in the workflow.",
|
||||
examples: [
|
||||
"Add fixed instructions before a node",
|
||||
"Inject context or constraints",
|
||||
"Provide test data"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#literal-node"
|
||||
},
|
||||
loop_counter: {
|
||||
title: "Loop Counter Node",
|
||||
description: "Limits loop iterations. Only produces output when the maximum count is reached, helping control infinite loops.",
|
||||
examples: [
|
||||
"Prevent runaway loops",
|
||||
"Set maximum revision cycles",
|
||||
"Control iterative processes"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#loop-counter-node"
|
||||
},
|
||||
subgraph: {
|
||||
title: "Subgraph Node",
|
||||
description: "Embeds another workflow as a reusable module. Enables modular design and workflow composition.",
|
||||
examples: [
|
||||
"Reuse common patterns across workflows",
|
||||
"Break complex workflows into manageable pieces",
|
||||
"Share workflows between teams"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#subgraph-node"
|
||||
},
|
||||
unknown: {
|
||||
title: "Workflow Node",
|
||||
description: "A node in your workflow. Click to view and edit its configuration.",
|
||||
learnMoreUrl: "/tutorial#2-create-nodes"
|
||||
}
|
||||
},
|
||||
|
||||
// Workflow Edge Help
|
||||
edge: {
|
||||
basic: {
|
||||
title: "Connection",
|
||||
description: "Connects two nodes to control information flow and execution order. The upstream node's output becomes the downstream node's input.",
|
||||
examples: [
|
||||
"Data flows from source to target",
|
||||
"Target executes after source completes"
|
||||
],
|
||||
learnMoreUrl: "/tutorial#what-is-an-edge"
|
||||
},
|
||||
trigger: {
|
||||
enabled: {
|
||||
description: "This connection triggers the downstream node to execute.",
|
||||
},
|
||||
disabled: {
|
||||
description: "This connection passes data but does NOT trigger execution. The downstream node only runs if triggered by another edge.",
|
||||
}
|
||||
},
|
||||
condition: {
|
||||
hasCondition: {
|
||||
description: "This connection has a condition. It only activates when the condition evaluates to true.",
|
||||
learnMoreUrl: "/tutorial#edge-condition"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Context Menu Actions
|
||||
contextMenu: {
|
||||
createNode: {
|
||||
description: "Create a new node in your workflow. Choose from Agent, Human, Python, and other node types.",
|
||||
},
|
||||
copyNode: {
|
||||
description: "Duplicate this node with all its settings. The copy will have a blank ID that you must fill in.",
|
||||
},
|
||||
deleteNode: {
|
||||
description: "Remove this node and all its connections from the workflow.",
|
||||
},
|
||||
deleteEdge: {
|
||||
description: "Remove this connection between nodes.",
|
||||
},
|
||||
createNodeButton: {
|
||||
description: "Open the node creation form. You can also right-click the canvas to create a node at a specific position.",
|
||||
},
|
||||
configureGraph: {
|
||||
description: "Configure workflow-level settings like name, description, and global variables.",
|
||||
},
|
||||
launch: {
|
||||
description: "Run your workflow with a task prompt. The workflow will execute and show you the results.",
|
||||
},
|
||||
createEdge: {
|
||||
description: "Create a connection between nodes. You can also drag from a node's handle to create connections visually.",
|
||||
},
|
||||
manageVariables: {
|
||||
description: "Define global variables (like API keys) that all nodes can access using ${VARIABLE_NAME} syntax.",
|
||||
},
|
||||
manageMemories: {
|
||||
description: "Configure memory modules for long-term information storage and retrieval across workflow runs.",
|
||||
},
|
||||
renameWorkflow: {
|
||||
description: "Change the name of this workflow file.",
|
||||
},
|
||||
copyWorkflow: {
|
||||
description: "Create a duplicate of this entire workflow with a new name.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get help content by key path
|
||||
* @param {string} key - Dot-separated path to content (e.g., 'workflowNode.agent')
|
||||
* @returns {Object|null} Help content object or null if not found
|
||||
*/
|
||||
export function getHelpContent(key) {
|
||||
const keys = key.split('.')
|
||||
let content = helpContent
|
||||
|
||||
for (const k of keys) {
|
||||
if (content && typeof content === 'object' && k in content) {
|
||||
content = content[k]
|
||||
} else {
|
||||
// Return null for missing content instead of fallback
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we return an object with at least a description
|
||||
if (typeof content === 'string') {
|
||||
return { description: content }
|
||||
}
|
||||
|
||||
return content || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node-specific help content based on node type
|
||||
* @param {string} nodeType - The type of node (agent, human, python, etc.)
|
||||
* @returns {Object|null} Help content for that node type, or null for unknown types
|
||||
*/
|
||||
export function getNodeHelp(nodeType) {
|
||||
if (!nodeType) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = nodeType.toLowerCase()
|
||||
const content = getHelpContent(`workflowNode.${type}`)
|
||||
|
||||
// Return null for unknown types (when content lookup fails)
|
||||
// This prevents showing tooltips for custom/user-defined node types
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Get edge help content based on edge properties
|
||||
* @param {Object} edgeData - The edge data object
|
||||
* @returns {Object} Combined help content for the edge
|
||||
*/
|
||||
export function getEdgeHelp(edgeData) {
|
||||
const base = { ...helpContent.edge.basic }
|
||||
|
||||
// Add trigger information
|
||||
const trigger = edgeData?.trigger !== undefined ? edgeData.trigger : true
|
||||
if (!trigger) {
|
||||
base.description += " " + helpContent.edge.trigger.disabled.description
|
||||
}
|
||||
|
||||
// Add condition information
|
||||
if (edgeData?.condition) {
|
||||
base.description += " " + helpContent.edge.condition.hasCondition.description
|
||||
if (!base.learnMoreUrl) {
|
||||
base.learnMoreUrl = helpContent.edge.condition.hasCondition.learnMoreUrl
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export default {
|
||||
helpContent,
|
||||
getHelpContent,
|
||||
getNodeHelp,
|
||||
getEdgeHelp
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user