jiaxing.liao 1 місяць тому
батько
коміт
207f7aed2e

+ 1 - 1
apps/web/src/features/RunWorkflow/components/DetailTab.vue

@@ -133,7 +133,7 @@ const outputEditorValue = computed(() => props.detailOutputText || '-')
 
 .summary-value {
 	margin-top: 6px;
-	font-size: 16px;
+	font-size: 14px;
 	font-weight: 700;
 	color: #344054;
 	word-break: break-word;

+ 33 - 36
apps/web/src/features/RunWorkflow/components/ResultTab.vue

@@ -3,7 +3,6 @@ import { computed } from 'vue'
 import CodeEditor from '@/nodes/_base/CodeEditor.vue'
 
 const props = defineProps<{
-	hasOutputNode: boolean
 	hasExecutionData: boolean
 	isRunning: boolean
 	finalResultText: string
@@ -15,42 +14,40 @@ const editorValue = computed(() => props.finalResultText || '-')
 
 <template>
 	<div class="tab-pane tab-pane--scroll">
-		<template v-if="props.hasOutputNode">
-			<div v-if="!hasExecutionData" class="empty-state">无结果</div>
-			<div v-else-if="isRunning && !finalResultText" class="running-state">
-				<div class="running-state__header">
-					<div class="running-state__signal">
-						<span></span>
-						<span></span>
-						<span></span>
-					</div>
-					<div>
-						<div class="running-state__title">Workflow Running</div>
-						<div class="running-state__desc">正在执行,请等待...</div>
-					</div>
+		<div v-if="!hasExecutionData" class="empty-state">无结果</div>
+		<div v-else-if="isRunning && !finalResultText" class="running-state">
+			<div class="running-state__header">
+				<div class="running-state__signal">
+					<span></span>
+					<span></span>
+					<span></span>
 				</div>
-				<div class="running-state__skeleton">
-					<div class="skeleton-line skeleton-line--lg"></div>
-					<div class="skeleton-line"></div>
-					<div class="skeleton-line"></div>
-					<div class="skeleton-line skeleton-line--sm"></div>
+				<div>
+					<div class="running-state__title">Workflow Running</div>
+					<div class="running-state__desc">正在执行,请等待...</div>
 				</div>
 			</div>
-			<div v-else class="panel" :class="{ 'panel--running': isRunning }">
-				<div class="section-title">输出</div>
-				<div class="editor-shell">
-					<div v-if="isRunning" class="editor-shell__scanner"></div>
-					<CodeEditor
-						:model-value="editorValue"
-						language="json"
-						:tools="true"
-						:read-only="true"
-						:allow-change-language="false"
-						:height="300"
-					/>
-				</div>
+			<div class="running-state__skeleton">
+				<div class="skeleton-line skeleton-line--lg"></div>
+				<div class="skeleton-line"></div>
+				<div class="skeleton-line"></div>
+				<div class="skeleton-line skeleton-line--sm"></div>
+			</div>
+		</div>
+		<div v-else class="panel" :class="{ 'panel--running': isRunning }">
+			<div class="section-title">输出</div>
+			<div class="editor-shell">
+				<div v-if="isRunning" class="editor-shell__scanner"></div>
+				<CodeEditor
+					:model-value="editorValue"
+					language="json"
+					:tools="true"
+					:read-only="true"
+					:allow-change-language="false"
+					:height="300"
+				/>
 			</div>
-		</template>
+		</div>
 	</div>
 </template>
 
@@ -139,14 +136,14 @@ const editorValue = computed(() => props.finalResultText || '-')
 }
 
 .running-state__title {
-	font-size: 15px;
+	font-size: 14px;
 	font-weight: 600;
 	color: #1d4ed8;
 }
 
 .running-state__desc {
 	margin-top: 4px;
-	font-size: 13px;
+	font-size: 12px;
 	color: #5b6b8a;
 }
 
@@ -212,7 +209,7 @@ const editorValue = computed(() => props.finalResultText || '-')
 
 .section-title {
 	margin-right: auto;
-	font-size: 15px;
+	font-size: 14px;
 	font-weight: 600;
 	color: #344054;
 	margin-bottom: 12px;

+ 178 - 0
apps/web/src/features/RunWorkflow/components/TriggerTab.vue

@@ -0,0 +1,178 @@
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { IconButton, Icon } from '@repo/ui'
+
+const props = defineProps<{
+	nodeType: string
+	isListening: boolean
+	webhookDebugUrl: string
+	nextScheduleRunText: string
+}>()
+
+const emit = defineEmits<{
+	(e: 'stop'): void
+}>()
+
+const isScheduleTrigger = computed(() => props.nodeType === 'trigger-schedule')
+const isWebhookTrigger = computed(() => props.nodeType === 'trigger-webhook')
+const titleText = computed(() => (props.isListening ? '正在监听触发器事件...' : '触发器监听已停止'))
+const descriptionText = computed(() => {
+	if (isScheduleTrigger.value) {
+		return props.isListening ? '正在监听计划触发事件。' : '计划触发监听已停止。'
+	}
+
+	if (isWebhookTrigger.value) {
+		return props.isListening
+			? '您现在可以向 HTTP Webhook 触发器端点发送测试请求以模拟事件触发,或将其用作实时事件调试的回调 URL。'
+			: 'Webhook 触发监听已停止。'
+	}
+
+	return ''
+})
+
+const iconName = computed(() =>
+	isScheduleTrigger.value ? 'lucide:calendar-clock' : 'lucide:webhook'
+)
+
+const iconGradient = computed(() =>
+	isScheduleTrigger.value
+		? 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)'
+		: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
+)
+
+const copyWebhookUrl = async () => {
+	if (!props.webhookDebugUrl) return
+	try {
+		await navigator.clipboard.writeText(props.webhookDebugUrl)
+		ElMessage.success('测试 URL 已复制')
+	} catch {
+		ElMessage.error('复制失败')
+	}
+}
+</script>
+
+<template>
+	<div class="trigger-tab">
+		<div class="trigger-hero">
+			<div class="trigger-hero__icon" :style="{ background: iconGradient }">
+				<Icon :icon="iconName" color="#fff" width="28" height="28" />
+			</div>
+
+			<div class="trigger-hero__title">{{ titleText }}</div>
+			<div class="trigger-hero__desc">{{ descriptionText }}</div>
+
+			<div v-if="isScheduleTrigger && nextScheduleRunText" class="trigger-hero__meta">
+				下一次计划运行时间:{{ nextScheduleRunText }}
+			</div>
+
+			<div v-if="isWebhookTrigger && webhookDebugUrl" class="trigger-hero__url-row">
+				<span class="trigger-hero__url-label">测试运行时,请始终使用此 URL</span>
+				<button type="button" class="trigger-hero__url" @click="copyWebhookUrl">
+					{{ webhookDebugUrl }}
+				</button>
+			</div>
+
+			<el-button class="mt-12px" type="primary" v-if="isListening" @click="emit('stop')">
+				<Icon icon="lucide:stop-circle" color="#ddd" width="18" height="18" />
+				<span class="ml-4px">停止</span>
+			</el-button>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.trigger-tab {
+	height: 100%;
+	padding: 24px 16px 16px;
+	overflow-y: auto;
+}
+
+.trigger-hero {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-start;
+}
+
+.trigger-hero__icon {
+	width: 60px;
+	height: 60px;
+	border-radius: 18px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.trigger-hero__title {
+	margin-top: 20px;
+	font-size: 16px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.trigger-hero__desc {
+	margin-top: 10px;
+	font-size: 13px;
+	line-height: 1.7;
+	color: #667085;
+}
+
+.trigger-hero__meta {
+	margin-top: 4px;
+	font-size: 13px;
+	line-height: 1.7;
+	color: #667085;
+}
+
+.trigger-hero__url-row {
+	margin-top: 24px;
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	width: 100%;
+}
+
+.trigger-hero__url-label {
+	font-size: 13px;
+	color: #667085;
+}
+
+.trigger-hero__url {
+	width: 100%;
+	padding: 12px 14px;
+	border: 1px solid #e4e7ec;
+	border-radius: 12px;
+	background: #f8fafc;
+	font-size: 13px;
+	line-height: 1.6;
+	color: #101828;
+	text-align: left;
+	word-break: break-all;
+	cursor: pointer;
+}
+
+.trigger-hero__url:hover {
+	border-color: #3b82f6;
+	background: #eff6ff;
+}
+
+.trigger-hero__stop {
+	margin-top: 28px;
+	display: inline-flex;
+	align-items: center;
+	gap: 8px;
+	padding: 12px 18px;
+	border: 0;
+	border-radius: 14px;
+	background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
+	color: #fff;
+	font-size: 14px;
+	font-weight: 600;
+	cursor: pointer;
+	box-shadow: 0 12px 24px rgba(37, 99, 235, 0.24);
+}
+
+.trigger-hero__stop:hover {
+	filter: brightness(1.03);
+}
+</style>

+ 187 - 34
apps/web/src/features/RunWorkflow/index.vue

@@ -6,6 +6,7 @@ import { cloneDeep } from 'lodash-es'
 import { agent } from '@repo/api-service'
 
 import InputTab from './components/InputTab.vue'
+import TriggerTab from './components/TriggerTab.vue'
 import ResultTab from './components/ResultTab.vue'
 import DetailTab from './components/DetailTab.vue'
 import TraceTab from './components/TraceTab.vue'
@@ -24,12 +25,16 @@ interface Props {
 	visible: boolean
 	closeOnRun?: boolean
 	inputOnly?: boolean
+	activeNodeId?: string
+	initialTab?: 'input' | 'trigger' | 'result' | 'detail' | 'trace'
 }
 
 const props = withDefaults(defineProps<Props>(), {
 	visible: false,
 	closeOnRun: false,
-	inputOnly: false
+	inputOnly: false,
+	activeNodeId: '',
+	initialTab: 'input'
 })
 
 const emit = defineEmits<{
@@ -55,6 +60,25 @@ const startNode = computed<IWorkflowNode | null>(() => {
 	)
 })
 
+const activeNode = computed<IWorkflowNode | null>(() => {
+	if (props.activeNodeId) {
+		return props.workflow?.nodes?.find((node) => node.id === props.activeNodeId) || null
+	}
+
+	return startNode.value
+})
+
+const activeNodeType = computed(() => {
+	return ((activeNode.value as any)?.nodeType ||
+		(activeNode.value as any)?.data?.nodeType ||
+		'') as string
+})
+
+const showInputTab = computed(() => activeNodeType.value === 'start')
+const showTriggerTab = computed(() =>
+	['trigger-schedule', 'trigger-webhook'].includes(activeNodeType.value)
+)
+
 const startVariables = computed<StartVariable[]>(() => {
 	const variables = (startNode.value as any)?.data?.variables
 	return Array.isArray(variables) ? variables : []
@@ -72,27 +96,6 @@ const currentExecution = computed<RunnerExecution | null>(() => {
 })
 
 const traceNodes = computed(() => currentExecution.value?.nodes || runnerStore.nodes || [])
-const outputNodeIds = computed(() => {
-	return new Set(
-		(props.workflow?.nodes || [])
-			.filter((node) => {
-				const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
-				return nodeType === 'end'
-			})
-			.map((node) => node.id)
-	)
-})
-const hasOutputNode = computed(() => outputNodeIds.value.size > 0)
-const outputNodeState = computed(() => {
-	const nodes = [...traceNodes.value]
-	return (
-		nodes.reverse().find((node) => {
-			return outputNodeIds.value.has(node.nodeId) || node.nodeType === 'end'
-		}) || null
-	)
-})
-const outputNodeResultValue = computed(() => outputNodeState.value?.track?.output_variable ?? null)
-const outputNodeResultText = computed(() => formatDisplayValue(outputNodeResultValue.value))
 
 const hasExecutionData = computed(
 	() => !!currentExecution.value || !!submittedParams.value || !!runnerStore.agentResult
@@ -127,7 +130,7 @@ const detailMeta = computed(() => [
 ])
 
 const finalResultValue = computed(() =>
-	extractFinalResult(runnerStore.agentResult || currentExecution.value?.result)
+	extractFinalResult(runnerStore.agentResult?.data?.result || currentExecution.value?.result)
 )
 
 const finalResultText = computed(() => formatDisplayValue(finalResultValue.value))
@@ -136,6 +139,111 @@ const detailOutputText = computed(() => formatDisplayValue(finalResultValue.valu
 const detailStatusClassName = computed(() =>
 	statusClass(currentExecution.value?.status || runnerStore.status)
 )
+const triggerWebhookDebugUrl = computed(() => {
+	return `${(activeNode.value as any)?.data?.webhook_debug_url || ''}`.trim()
+})
+const triggerScheduleNextRunText = computed(() => {
+	if (activeNodeType.value !== 'trigger-schedule') return ''
+
+	const data = (activeNode.value as any)?.data || {}
+	const visualConfig = data.visual_config || {}
+	const frequency = `${data.frequency || 'daily'}`
+	if (data.mode === 'cron' && `${data.cron_expression || ''}`.trim()) {
+		return `${data.cron_expression || ''}`.trim()
+	}
+	const now = new Date()
+	const createCandidate = (base: Date, hour: number, minute: number) => {
+		const candidate = new Date(base)
+		candidate.setHours(hour, minute, 0, 0)
+		return candidate
+	}
+	const parseTime = (value?: string) => {
+		const [hourText = '0', minuteText = '0'] = `${value || '00:00'}`.split(':')
+		return {
+			hour: Math.min(23, Math.max(0, Number(hourText))),
+			minute: Math.min(59, Math.max(0, Number(minuteText)))
+		}
+	}
+	const getLastDayOfMonth = (date: Date) => {
+		return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
+	}
+
+	let nextRun: Date | null = null
+	if (frequency === 'hourly') {
+		const minute = Math.min(59, Math.max(0, Number(visualConfig.minute ?? 0)))
+		const cursor = new Date(now)
+		cursor.setSeconds(0, 0)
+		cursor.setMinutes(minute, 0, 0)
+		if (cursor <= now) {
+			cursor.setHours(cursor.getHours() + 1)
+			cursor.setMinutes(minute, 0, 0)
+		}
+		nextRun = cursor
+	} else if (frequency === 'weekly') {
+		const { hour, minute } = parseTime(visualConfig.time)
+		const weekdayMap: Record<string, number> = {
+			sun: 0,
+			mon: 1,
+			tue: 2,
+			wed: 3,
+			thu: 4,
+			fri: 5,
+			sat: 6
+		}
+		const weekdays = new Set(
+			(Array.isArray(visualConfig.weekdays) ? visualConfig.weekdays : ['sun'])
+				.map((item: string) => weekdayMap[item])
+				.filter((item: number) => Number.isInteger(item))
+		)
+		const cursor = new Date(now)
+		cursor.setHours(0, 0, 0, 0)
+		for (let dayOffset = 0; dayOffset < 370; dayOffset += 1) {
+			const candidate = createCandidate(cursor, hour, minute)
+			if (weekdays.has(candidate.getDay()) && candidate > now) {
+				nextRun = candidate
+				break
+			}
+			cursor.setDate(cursor.getDate() + 1)
+		}
+	} else if (frequency === 'monthly') {
+		const { hour, minute } = parseTime(visualConfig.time)
+		const monthlyDays = Array.isArray(visualConfig.monthly_days) ? visualConfig.monthly_days : [1]
+		const cursor = new Date(now)
+		cursor.setHours(0, 0, 0, 0)
+		for (let dayOffset = 0; dayOffset < 740; dayOffset += 1) {
+			const candidate = createCandidate(cursor, hour, minute)
+			const currentDate = candidate.getDate()
+			const isLastDay = currentDate === getLastDayOfMonth(candidate)
+			const matched = monthlyDays.some((item: number | 'last') =>
+				item === 'last' ? isLastDay : Number(item) === currentDate
+			)
+			if (matched && candidate > now) {
+				nextRun = candidate
+				break
+			}
+			cursor.setDate(cursor.getDate() + 1)
+		}
+	} else {
+		const { hour, minute } = parseTime(visualConfig.time)
+		const candidate = createCandidate(now, hour, minute)
+		if (candidate <= now) {
+			candidate.setDate(candidate.getDate() + 1)
+		}
+		nextRun = candidate
+	}
+
+	if (!nextRun) return `${data.cron_expression || ''}`.trim()
+
+	return new Intl.DateTimeFormat('en-US', {
+		weekday: 'long',
+		month: 'long',
+		day: 'numeric',
+		year: 'numeric',
+		hour: 'numeric',
+		minute: '2-digit',
+		hour12: true
+	}).format(nextRun)
+})
 
 const initializeInputValues = () => {
 	startVariables.value.forEach((variable) => {
@@ -166,10 +274,20 @@ const resetValidation = () => {
 }
 
 watch(
-	() => props.visible,
-	(visible) => {
+	() => [props.visible, props.initialTab, showInputTab.value, showTriggerTab.value],
+	([visible]) => {
 		if (visible) {
-			activeTab.value = 'input'
+			if (props.initialTab === 'trigger' && showTriggerTab.value) {
+				activeTab.value = 'trigger'
+			} else if (props.initialTab === 'input' && showInputTab.value) {
+				activeTab.value = 'input'
+			} else if (showTriggerTab.value) {
+				activeTab.value = 'trigger'
+			} else if (showInputTab.value) {
+				activeTab.value = 'input'
+			} else {
+				activeTab.value = 'detail'
+			}
 			resetValidation()
 			initializeInputValues()
 			return
@@ -184,6 +302,10 @@ const closeDrawer = () => {
 	emit('update:visible', false)
 }
 
+const handleStopTriggerListening = () => {
+	runnerStore.stopRunner()
+}
+
 const buildExecuteParams = () => {
 	resetValidation()
 	const params: Record<string, any> = {}
@@ -428,7 +550,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 
 			<div class="content">
 				<el-tabs v-model="activeTab" class="runner-tabs">
-					<el-tab-pane label="输入" name="input">
+					<el-tab-pane v-if="showInputTab" label="输入" name="input">
 						<InputTab
 							:start-node="startNode"
 							:visible-variables="visibleVariables"
@@ -439,16 +561,34 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							@run="handleRunWorkflow"
 						/>
 					</el-tab-pane>
-					<el-tab-pane v-if="!props.inputOnly" label="结果" name="result">
+					<el-tab-pane v-if="showTriggerTab" label="触发" name="trigger">
+						<TriggerTab
+							:node-type="activeNodeType"
+							:is-listening="isRunning"
+							:webhook-debug-url="triggerWebhookDebugUrl"
+							:next-schedule-run-text="triggerScheduleNextRunText"
+							@stop="handleStopTriggerListening"
+						/>
+					</el-tab-pane>
+					<el-tab-pane
+						v-if="!props.inputOnly"
+						label="结果"
+						name="result"
+						:disabled="runnerStore.status === 'idle'"
+					>
 						<ResultTab
-							:has-output-node="hasOutputNode"
 							:has-execution-data="hasExecutionData"
 							:is-running="isRunning"
-							:final-result-text="outputNodeResultText"
-							:final-result-value="outputNodeResultValue"
+							:final-result-text="finalResultText"
+							:final-result-value="finalResultValue"
 						/>
 					</el-tab-pane>
-					<el-tab-pane v-if="!props.inputOnly" label="详情" name="detail">
+					<el-tab-pane
+						v-if="!props.inputOnly"
+						label="详情"
+						name="detail"
+						:disabled="runnerStore.status === 'idle'"
+					>
 						<DetailTab
 							:has-execution-data="hasExecutionData"
 							:status-class-name="detailStatusClassName"
@@ -462,7 +602,12 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							:detail-output-text="detailOutputText"
 						/>
 					</el-tab-pane>
-					<el-tab-pane v-if="!props.inputOnly" label="追踪" name="trace">
+					<el-tab-pane
+						v-if="!props.inputOnly"
+						label="追踪"
+						name="trace"
+						:disabled="runnerStore.status === 'idle'"
+					>
 						<TraceTab :trace-nodes="traceNodes" />
 					</el-tab-pane>
 				</el-tabs>
@@ -473,6 +618,8 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 
 <style lang="less" scoped>
 .runner {
+	z-index: 999;
+	font-size: 13px;
 	.drawer {
 		position: fixed;
 		top: 60px;
@@ -480,7 +627,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 		bottom: 10px;
 		width: 420px;
 		background: #fff;
-		z-index: 1000;
+		z-index: 1001;
 		border-radius: 8px;
 		display: flex;
 		flex-direction: column;
@@ -499,6 +646,8 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 
 		h4 {
 			margin: 0;
+			font-size: 15px;
+			font-weight: 600;
 		}
 	}
 
@@ -518,6 +667,10 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 		margin-bottom: 0;
 	}
 
+	:deep(.el-tabs__item) {
+		font-size: 13px;
+	}
+
 	:deep(.el-tabs__nav-scroll) {
 		padding-left: 20px;
 	}

+ 1 - 0
apps/web/src/features/setter/index.vue

@@ -184,6 +184,7 @@ provide('nodeVars', nodeVars)
 </template>
 <style lang="less" scoped>
 .setter {
+	z-index: 998;
 	/* Drawer 主体 */
 	.drawer {
 		position: fixed;

+ 93 - 11
apps/web/src/features/toolbar/index.vue

@@ -17,15 +17,35 @@
 			/>
 		</el-tooltip>
 
-		<el-tooltip content="运行" placement="left">
-			<IconButton
-				icon="lucide:play"
-				icon-color="#fff"
-				type="success"
-				square
-				@click="$emit('run')"
-			/>
-		</el-tooltip>
+		<el-popover
+			ref="popoverRef"
+			width="240px"
+			trigger="click"
+			:disabled="props.runNodes.length <= 1"
+		>
+			<div v-if="props.runNodes.length > 1" class="run-selector">
+				<div class="run-selector__title">选择运行入口</div>
+				<button
+					v-for="node in props.runNodes"
+					:key="node.id"
+					type="button"
+					class="run-selector__item"
+					@click="handleSelectRunNode(node.id)"
+				>
+					<span class="run-selector__name">{{ node.name }}</span>
+					<span class="run-selector__type">{{ node.nodeType }}</span>
+				</button>
+			</div>
+			<template #reference>
+				<IconButton
+					icon="lucide:play"
+					icon-color="#fff"
+					type="success"
+					square
+					@click="handleRunClick"
+				/>
+			</template>
+		</el-popover>
 
 		<el-tooltip content="环境变量" placement="left">
 			<IconButton icon="eos-icons:env" icon-color="#666" square @click="showEnvDialog = true" />
@@ -41,12 +61,19 @@ import { ref } from 'vue'
 import NodeLibary from '@/features/nodeLibary/index.vue'
 import AgentEnvDialog from './AgentEnvDialog.vue'
 
+import type { PopoverInstance } from 'element-plus'
+
 const props = defineProps<{
 	envVars: {
 		name: string
 		value: string
 		type: 'string' | 'number' | 'boolean' | 'object' | 'array'
 	}[]
+	runNodes: {
+		id: string
+		name: string
+		nodeType: string
+	}[]
 }>()
 
 const emit = defineEmits<{
@@ -58,11 +85,12 @@ const emit = defineEmits<{
 			type: 'string' | 'number' | 'boolean' | 'object' | 'array'
 		}[]
 	): void
-	(e: 'run'): void
+	(e: 'run', id?: string): void
 	(e: 'create:node', value: { type: string } | string): void
 }>()
 
 const showEnvDialog = ref(false)
+const popoverRef = ref<PopoverInstance>()
 
 function handleEnvChange(
 	payload: {
@@ -73,6 +101,60 @@ function handleEnvChange(
 ) {
 	emit('changeEnvVars', payload)
 }
+
+function handleRunClick() {
+	if (props.runNodes.length <= 1) {
+		emit('run', props.runNodes[0]?.id)
+		return
+	}
+}
+
+function handleSelectRunNode(id: string) {
+	popoverRef.value?.hide()
+	emit('run', id)
+}
 </script>
 
-<style scoped></style>
+<style scoped>
+.run-selector {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.run-selector__title {
+	font-size: 13px;
+	font-weight: 600;
+	color: #344054;
+}
+
+.run-selector__item {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	width: 100%;
+	padding: 10px 12px;
+	border: 1px solid #e4e7ec;
+	border-radius: 10px;
+	background: #fff;
+	cursor: pointer;
+	text-align: left;
+}
+
+.run-selector__item:hover {
+	border-color: #2563eb;
+	background: #f8fbff;
+}
+
+.run-selector__name {
+	font-size: 13px;
+	font-weight: 600;
+	color: #344054;
+}
+
+.run-selector__type {
+	font-size: 12px;
+	color: #667085;
+}
+</style>

+ 0 - 18
apps/web/src/nodes/src/start/setter.vue

@@ -473,24 +473,6 @@ const dialogRules: FormRules<StartVariableEditor> = {
 			}
 		}
 	],
-	max_length: [
-		{
-			trigger: 'blur',
-			validator: (_rule, value: number, callback) => {
-				if (!supportsMaxLength(dialogForm.formType)) {
-					callback()
-					return
-				}
-
-				if (!value || value < 1) {
-					callback(new Error('最大长度需要大于 0'))
-					return
-				}
-
-				callback()
-			}
-		}
-	],
 	options: [
 		{
 			trigger: 'change',

+ 81 - 16
apps/web/src/views/editor/NodeView.vue

@@ -25,6 +25,7 @@
 				@create:node="handleNodeCreate"
 				@run="handleRunAgent"
 				:env-vars="workflow?.env_variables || []"
+				:run-nodes="toolbarRunNodes"
 				@change-env-vars="handleChangeEnvVars"
 			/>
 		</Workflow>
@@ -34,6 +35,8 @@
 		:workflow="workflow"
 		:close-on-run="closeRunWorkflowOnSubmit"
 		:input-only="runWorkflowInputOnly"
+		:active-node-id="runWorkflowNodeId"
+		:initial-tab="runWorkflowInitialTab"
 		@run-started="handleWorkflowRunStarted"
 	/>
 	<Setter
@@ -111,10 +114,11 @@ const libaryRefferenceRef = ref<HTMLElement>()
 const runVisible = ref(false)
 const closeRunWorkflowOnSubmit = ref(false)
 const runWorkflowInputOnly = ref(false)
+const runWorkflowNodeId = ref('')
+const runWorkflowInitialTab = ref<'input' | 'trigger' | 'result' | 'detail' | 'trace'>('input')
 const setterVisible = ref(false)
 const nodeID = ref('')
 const setterActiveTab = ref<'setting' | 'last-run'>('setting')
-const pendingSetterInit = new Set<string>()
 const displayNodeExecutionStatus = ref<Record<string, CanvasExecutionStatus>>({})
 const peddingHandlePayload = ref<{
 	by?: 'node' | 'edge'
@@ -237,6 +241,22 @@ const workflowWithExecutionState = computed(() => {
 	} as IWorkflow
 })
 
+const toolbarRunNodes = computed(() => {
+	return (props.workflow?.nodes || [])
+		.filter((node) => {
+			const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
+			return ['start', 'trigger-schedule', 'trigger-webhook'].includes(nodeType || '')
+		})
+		.map((node) => {
+			const nodeType = ((node as any)?.nodeType || (node as any)?.data?.nodeType || '') as string
+			return {
+				id: node.id,
+				name: node.name || nodeMap[nodeType as keyof typeof nodeMap]?.displayName || nodeType,
+				nodeType
+			}
+		})
+})
+
 const { onDragOver, onDrop, onDragLeave } = useDragAndDrop({
 	id: props.workflow.id,
 	addNodes: (node) => {
@@ -316,6 +336,23 @@ const openSetter = (id: string, tab: 'setting' | 'last-run' = 'setting') => {
 	setterVisible.value = true
 }
 
+const openRunWorkflow = async (
+	nodeId: string,
+	initialTab: 'input' | 'trigger' | 'result' | 'detail' | 'trace',
+	options?: {
+		closeOnRun?: boolean
+		inputOnly?: boolean
+	}
+) => {
+	runWorkflowNodeId.value = nodeId
+	runWorkflowInitialTab.value = initialTab
+	closeRunWorkflowOnSubmit.value = !!options?.closeOnRun
+	runWorkflowInputOnly.value = !!options?.inputOnly
+	runVisible.value = false
+	await nextTick()
+	runVisible.value = true
+}
+
 // 从节点级操作入口直接运行指定节点。
 const handleRunNode = async (id: string) => {
 	if (!props.workflow?.id) {
@@ -327,11 +364,33 @@ const handleRunNode = async (id: string) => {
 	const nodeType = (targetNode as any)?.nodeType || (targetNode as any)?.data?.nodeType
 
 	if (nodeType === 'start') {
-		closeRunWorkflowOnSubmit.value = true
-		runWorkflowInputOnly.value = true
-		runVisible.value = false
-		await nextTick()
-		runVisible.value = true
+		await openRunWorkflow(id, 'input', {
+			closeOnRun: true,
+			inputOnly: true
+		})
+		return
+	}
+
+	if (['trigger-schedule', 'trigger-webhook'].includes(nodeType || '')) {
+		try {
+			const response = await agent.postAgentDoExecute({
+				appAgentId: props.workflow.id,
+				start_node_id: id,
+				is_debugger: true,
+				responseType: 'ws',
+				params: {}
+			})
+			const agentRunnerKey = response?.result
+			if (agentRunnerKey) {
+				runnerStore.startRunner(agentRunnerKey)
+				await openRunWorkflow(id, 'trigger')
+				return
+			}
+			ElMessage.error('运行节点失败')
+		} catch (error) {
+			console.error('postDoTestNodeRunner error', error)
+			ElMessage.error('运行节点失败')
+		}
 		return
 	}
 
@@ -361,20 +420,26 @@ const handleRunNode = async (id: string) => {
 /**
  * 运行智能体
  */
-const handleRunAgent = () => {
-	const startNode = props.workflow?.nodes?.find((node) => {
-		const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
-		return nodeType === 'start'
-	})
+const handleRunAgent = (id?: string) => {
+	const targetNode =
+		(id && props.workflow?.nodes?.find((node) => node.id === id)) ||
+		(props.workflow?.nodes || []).find((node) => {
+			const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
+			return ['start', 'trigger-schedule', 'trigger-webhook'].includes(nodeType || '')
+		})
 
-	if (!props.workflow?.id || !startNode?.id) {
-		ElMessage.warning('缺少开始节点')
+	if (!props.workflow?.id || !targetNode?.id) {
+		ElMessage.warning('缺少可运行的触发节点')
 		return
 	}
 
-	closeRunWorkflowOnSubmit.value = false
-	runWorkflowInputOnly.value = false
-	runVisible.value = true
+	const nodeType = (targetNode as any)?.nodeType || (targetNode as any)?.data?.nodeType
+	if (nodeType === 'start') {
+		openRunWorkflow(targetNode.id, 'input')
+		return
+	}
+
+	handleRunNode(targetNode.id)
 }
 
 /**