Parcourir la source

feat: 添加智能体运行弹窗内容

jiaxing.liao il y a 1 mois
Parent
commit
7e830fe1cf

+ 0 - 90
apps/web/src/components/RunWorkflow/index.vue

@@ -1,90 +0,0 @@
-<!--
- * @Author: liuJie
- * @Date: 2026-01-24 21:25:04
- * @LastEditors: liuJie
- * @LastEditTime: 2026-01-24 22:01:08
- * @Describe: 运行工作流
--->
-<script lang="ts" setup>
-import { ElButton } from 'element-plus'
-import { Icon } from '@iconify/vue'
-const props = withDefaults(
-	defineProps<{
-		visible: boolean
-	}>(),
-	{
-		visible: false
-	}
-)
-const emit = defineEmits<{
-	'update:visible': [value: boolean]
-	run: []
-}>()
-</script>
-<template>
-	<div class="runWorkflow">
-		<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible }">
-			<header>
-				<h4>运行工作流</h4>
-				<Icon
-					icon="lucide:x"
-					height="24"
-					width="24"
-					@click="emit('update:visible', false)"
-					class="cursor-pointer"
-				></Icon>
-			</header>
-
-			<!-- Drawer content -->
-			<div class="content">在此处配置运行参数(如果有)。</div>
-
-			<footer>
-				<ElButton type="success" size="large" class="w-full" @click="emit('run')"> 运行 </ElButton>
-			</footer>
-		</div>
-	</div>
-</template>
-<style lang="less" scoped>
-.runWorkflow {
-	/* Drawer 主体 */
-	.drawer {
-		position: fixed;
-		top: 100px;
-		right: 5px;
-		bottom: 10px;
-		width: 420px;
-		background: #fff;
-		z-index: 1000;
-		border-radius: 8px;
-		display: flex;
-		flex-direction: column;
-		border: 1px solid #e4e4e4;
-
-		/* 初始隐藏状态 */
-		transform: translateX(110%);
-		transition: transform 0.25s ease;
-	}
-
-	/* 显示状态 */
-	.drawer--open {
-		transform: translateX(0);
-	}
-
-	/* Header */
-	.drawer header {
-		height: 56px;
-		padding: 0 16px;
-		border-bottom: 1px solid #eee;
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-	}
-
-	/* 内容区 */
-	.drawer .content {
-		flex: 1;
-		padding: 16px;
-		overflow-y: auto;
-	}
-}
-</style>

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

@@ -0,0 +1,208 @@
+<script lang="ts" setup>
+import { computed } from 'vue'
+import CodeEditor from '@/nodes/_base/CodeEditor.vue'
+
+const props = defineProps<{
+	hasExecutionData: boolean
+	statusClassName: string
+	executionStatusText: string
+	executionDurationText: string
+	totalTokenText: string
+	detailMeta: Array<{ label: string; value: string }>
+	submittedParams: unknown
+	detailInputText: string
+	finalResultValue: unknown
+	detailOutputText: string
+}>()
+
+const inputEditorValue = computed(() => props.detailInputText || '-')
+const outputEditorValue = computed(() => props.detailOutputText || '-')
+</script>
+
+<template>
+	<div class="tab-pane tab-pane--scroll">
+		<div v-if="!hasExecutionData" class="empty-state">暂无运行详情</div>
+		<div v-else class="detail-layout">
+			<div class="summary-card" :class="statusClassName">
+				<div class="summary-item">
+					<div class="summary-label">状态</div>
+					<div class="summary-value">{{ executionStatusText }}</div>
+				</div>
+				<div class="summary-item">
+					<div class="summary-label">运行时间</div>
+					<div class="summary-value">{{ executionDurationText }}</div>
+				</div>
+				<!-- <div class="summary-item">
+					<div class="summary-label">总 TOKEN 数</div>
+					<div class="summary-value">{{ totalTokenText }}</div>
+				</div> -->
+			</div>
+
+			<div class="panel">
+				<div class="section-title">输入</div>
+				<CodeEditor
+					:model-value="inputEditorValue"
+					language="json"
+					:tools="true"
+					:read-only="true"
+					:allow-change-language="false"
+					:height="220"
+				/>
+			</div>
+
+			<div class="panel">
+				<div class="section-title">输出</div>
+				<CodeEditor
+					:model-value="outputEditorValue"
+					language="json"
+					:tools="true"
+					:read-only="true"
+					:allow-change-language="false"
+					:height="220"
+				/>
+			</div>
+
+			<div class="meta-list">
+				<div v-for="item in detailMeta" :key="item.label" class="meta-row">
+					<span class="meta-label">{{ item.label }}</span>
+					<span class="meta-value">{{ item.value }}</span>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.tab-pane {
+	height: 100%;
+	min-height: 0;
+	margin-top: 16px;
+}
+
+.tab-pane--scroll {
+	padding: 0 16px 16px;
+	overflow-y: auto;
+}
+
+.empty-state {
+	padding: 24px 16px;
+	border-radius: 12px;
+	background: #f8fafc;
+	color: #667085;
+	line-height: 1.6;
+}
+
+.detail-layout {
+	display: flex;
+	flex-direction: column;
+}
+
+.summary-card {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 12px;
+	padding: 14px;
+	border-radius: 8px;
+	border: 1px solid #d0d5dd;
+	background: linear-gradient(135deg, #f8fafc, #eef2ff);
+}
+
+.summary-card.is-success {
+	border-color: #32d583;
+	background: linear-gradient(135deg, #f0fdf4, #ecfdf3);
+}
+
+.summary-card.is-running {
+	border-color: #fdb022;
+	background: linear-gradient(135deg, #fffaeb, #fef3c7);
+}
+
+.summary-card.is-failed {
+	border-color: #f97066;
+	background: linear-gradient(135deg, #fef2f2, #fee4e2);
+}
+
+.summary-item {
+	min-width: 0;
+}
+
+.summary-label {
+	font-size: 12px;
+	color: #667085;
+}
+
+.summary-value {
+	margin-top: 6px;
+	font-size: 16px;
+	font-weight: 700;
+	color: #344054;
+	word-break: break-word;
+}
+
+.panel {
+	padding: 14px;
+	border-radius: 16px;
+	background: linear-gradient(180deg, #f8fafc 0%, #f4f7fb 100%);
+	border: 1px solid #e4e7ec;
+	box-shadow:
+		0 8px 24px rgba(15, 23, 42, 0.04),
+		inset 0 1px 0 rgba(255, 255, 255, 0.75);
+}
+
+.summary-card + .panel,
+.panel + .panel,
+.panel + .meta-list {
+	margin-top: 14px;
+	margin-bottom: 24px;
+}
+
+.panel-header {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 10px;
+}
+
+.section-title {
+	margin-right: auto;
+	font-size: 14px;
+	font-weight: 600;
+	color: #344054;
+	margin-bottom: 12px;
+}
+
+.editor-shell {
+	border-radius: 14px;
+	overflow: hidden;
+	background: #fff;
+	border: 1px solid #d0d5dd;
+}
+
+.meta-list {
+	padding: 8px 2px 4px;
+}
+
+.meta-row {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 16px;
+	padding: 10px 0;
+	border-bottom: 1px solid #eaecf0;
+	font-size: 12px;
+}
+
+.meta-row:last-child {
+	border-bottom: 0;
+}
+
+.meta-label {
+	color: #667085;
+}
+
+.meta-value {
+	color: #344054;
+	text-align: right;
+	word-break: break-all;
+}
+</style>

+ 162 - 0
apps/web/src/features/RunWorkflow/components/InputTab.vue

@@ -0,0 +1,162 @@
+<script lang="ts" setup>
+import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
+import CodeEditor from '@/nodes/_base/CodeEditor.vue'
+
+import type { IWorkflowNode } from '@repo/workflow'
+import type { StartVariable } from '@/nodes/src/start'
+
+defineProps<{
+	startNode: IWorkflowNode | null
+	visibleVariables: StartVariable[]
+	inputValues: Record<string, any>
+	jsonDrafts: Record<string, string>
+	validationErrors: Record<string, string>
+	isRunning: boolean
+}>()
+
+defineEmits<{
+	run: []
+}>()
+</script>
+
+<template>
+	<div class="tab-pane tab-pane--fill">
+		<div v-if="!startNode" class="empty-state">缺少开始节点,当前工作流无法运行。</div>
+		<div v-else-if="!visibleVariables.length" class="empty-state">
+			当前开始节点没有配置用户输入项,可以直接运行。
+		</div>
+
+		<el-form v-else label-position="top" class="input-form">
+			<el-form-item
+				v-for="variable in visibleVariables"
+				:key="variable.name"
+				:label="variable.label || variable.name"
+				:required="variable.is_require"
+				:error="validationErrors[variable.name]"
+			>
+				<el-input
+					v-if="variable.formType === 'text-input'"
+					v-model="inputValues[variable.name]"
+					:maxlength="variable.max_length"
+					:placeholder="`请输入${variable.label || variable.name}`"
+					clearable
+				/>
+
+				<el-input
+					v-else-if="variable.formType === 'text-area'"
+					v-model="inputValues[variable.name]"
+					type="textarea"
+					:rows="4"
+					:maxlength="variable.max_length"
+					:placeholder="`请输入${variable.label || variable.name}`"
+				/>
+
+				<el-select
+					v-else-if="variable.formType === 'select'"
+					v-model="inputValues[variable.name]"
+					class="w-full"
+					:placeholder="`请选择${variable.label || variable.name}`"
+				>
+					<el-option
+						v-for="option in variable.options || []"
+						:key="option"
+						:label="option"
+						:value="option"
+					/>
+				</el-select>
+
+				<el-input-number
+					v-else-if="variable.formType === 'number'"
+					v-model="inputValues[variable.name]"
+					:controls-position="'right'"
+					class="w-full"
+				/>
+
+				<div v-else-if="variable.formType === 'checkbox'" class="switch-line">
+					<el-switch v-model="inputValues[variable.name]" />
+					<span>{{ inputValues[variable.name] ? 'true' : 'false' }}</span>
+				</div>
+
+				<FileUploadInput
+					v-else-if="variable.formType === 'file'"
+					v-model="inputValues[variable.name]"
+					:file-types="variable.file_types || []"
+					:file-extensions="variable.file_extensions || []"
+					:allow-link-input="variable.allow_link_input"
+				/>
+
+				<FileUploadInput
+					v-else-if="variable.formType === 'file-list'"
+					v-model="inputValues[variable.name]"
+					multiple
+					:file-types="variable.file_types || []"
+					:file-extensions="variable.file_extensions || []"
+					:allow-link-input="variable.allow_link_input"
+				/>
+
+				<CodeEditor
+					v-else-if="variable.formType === 'json_object'"
+					v-model="jsonDrafts[variable.name]"
+					:tools="false"
+					language="json"
+					:height="180"
+				/>
+
+				<el-input
+					v-else
+					v-model="inputValues[variable.name]"
+					:placeholder="`请输入${variable.label || variable.name}`"
+					clearable
+				/>
+			</el-form-item>
+		</el-form>
+
+		<div class="action-bar">
+			<el-button type="primary" class="run-button" :loading="isRunning" @click="$emit('run')">
+				开始运行
+			</el-button>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.tab-pane {
+	height: 100%;
+	min-height: 0;
+	margin-top: 16px;
+}
+
+.tab-pane--fill {
+	display: flex;
+	flex-direction: column;
+	padding: 0 16px 16px;
+	overflow-y: auto;
+}
+
+.empty-state {
+	padding: 24px 16px;
+	border-radius: 12px;
+	background: #f8fafc;
+	color: #667085;
+	line-height: 1.6;
+}
+
+.action-bar {
+	margin-top: 16px;
+}
+
+.run-button {
+	width: 100%;
+}
+
+.switch-line {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	color: #344054;
+}
+
+:deep(.el-input-number.w-full) {
+	width: 100%;
+}
+</style>

+ 89 - 0
apps/web/src/features/RunWorkflow/components/ResultTab.vue

@@ -0,0 +1,89 @@
+<script lang="ts" setup>
+import { computed } from 'vue'
+import CodeEditor from '@/nodes/_base/CodeEditor.vue'
+
+const props = defineProps<{
+	hasExecutionData: boolean
+	isRunning: boolean
+	finalResultText: string
+	finalResultValue: unknown
+}>()
+
+const editorValue = computed(() => props.finalResultText || '-')
+</script>
+
+<template>
+	<div class="tab-pane tab-pane--scroll">
+		<div v-if="!hasExecutionData" class="empty-state">暂无运行结果</div>
+		<div v-else-if="isRunning && !finalResultText" class="empty-state">工作流运行中...</div>
+		<div v-else class="panel">
+			<div class="panel-header">
+				<div class="section-title">输出结果</div>
+				<el-button text @click="$emit('copy', finalResultValue)">复制</el-button>
+			</div>
+			<div class="editor-shell">
+				<CodeEditor
+					:model-value="editorValue"
+					language="json"
+					:tools="true"
+					:read-only="true"
+					:allow-change-language="false"
+					:height="300"
+				/>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.tab-pane {
+	height: 100%;
+	min-height: 0;
+	margin-top: 16px;
+}
+
+.tab-pane--scroll {
+	padding: 0 16px 16px;
+	overflow-y: auto;
+}
+
+.empty-state {
+	padding: 24px 16px;
+	border-radius: 12px;
+	background: #f8fafc;
+	color: #667085;
+	line-height: 1.6;
+}
+
+.panel {
+	padding: 14px;
+	border-radius: 16px;
+	background: linear-gradient(180deg, #f8fafc 0%, #f4f7fb 100%);
+	border: 1px solid #e4e7ec;
+	box-shadow:
+		0 8px 24px rgba(15, 23, 42, 0.04),
+		inset 0 1px 0 rgba(255, 255, 255, 0.75);
+}
+
+.panel-header {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 10px;
+	background-color: transparent !important;
+}
+
+.section-title {
+	margin-right: auto;
+	font-size: 15px;
+	font-weight: 600;
+	color: #344054;
+}
+
+.editor-shell {
+	border-radius: 14px;
+	overflow: hidden;
+	background: #fff;
+	border: 1px solid #d0d5dd;
+}
+</style>

+ 251 - 0
apps/web/src/features/RunWorkflow/components/TraceTab.vue

@@ -0,0 +1,251 @@
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+import { Icon } from '@repo/ui'
+import { nodeMap } from '@/nodes'
+import CodeEditor from '@/nodes/_base/CodeEditor.vue'
+import type { RunnerNodeState } from '@/store/modules/runner.store'
+
+const props = defineProps<{
+	traceNodes: RunnerNodeState[]
+}>()
+
+defineEmits<{
+	copy: [value: unknown]
+}>()
+
+const traceExpanded = ref<string[]>([])
+
+watch(
+	() => props.traceNodes,
+	(nodes) => {
+		traceExpanded.value = traceExpanded.value.filter((item) =>
+			nodes.some((node) => node.nodeId === item)
+		)
+	},
+	{ deep: true }
+)
+
+const nodes = computed(() => props.traceNodes || [])
+
+function statusClass(status?: string | null) {
+	if (status === 'success' || status === 'finished') return 'is-success'
+	if (status === 'failed' || status === 'error') return 'is-failed'
+	if (status === 'running' || status === 'connecting') return 'is-running'
+	return 'is-idle'
+}
+
+function nodeIcon(node: RunnerNodeState) {
+	return nodeMap[node.nodeType]?.icon || 'lucide:circle'
+}
+
+function nodeDisplayName(node: RunnerNodeState) {
+	return node.nodeName || nodeMap[node.nodeType]?.displayName || node.nodeType || '未命名节点'
+}
+
+function nodeUseTime(node: RunnerNodeState) {
+	const useTime = Number(node.track?.use_time ?? node.track?.useTime ?? 0)
+	if (Number.isFinite(useTime) && useTime > 0) {
+		return `${useTime} ms`
+	}
+	return node.lastUpdateTime || '-'
+}
+
+function traceDetail(node: RunnerNodeState) {
+	if (!node.track || typeof node.track !== 'object') return null
+	const { input_variable, output_variable, ...rest } = node.track
+	return rest
+}
+
+function formatDisplayValue(value: unknown) {
+	if (value === undefined || value === null || value === '') return ''
+	if (typeof value === 'string') return value
+	try {
+		return JSON.stringify(value, null, 2)
+	} catch {
+		return String(value)
+	}
+}
+</script>
+
+<template>
+	<div class="tab-pane tab-pane--scroll">
+		<div v-if="!nodes.length" class="empty-state">暂无节点执行记录</div>
+		<el-collapse v-else v-model="traceExpanded" class="trace-list">
+			<el-card v-for="node in nodes" :key="node.nodeId">
+				<el-collapse-item :name="node.nodeId">
+					<template #title>
+						<div class="trace-title">
+							<div class="trace-title__left">
+								<div class="trace-icon">
+									<Icon :icon="nodeIcon(node)" :size="18" />
+								</div>
+								<span class="trace-name">{{ nodeDisplayName(node) }}</span>
+							</div>
+							<div class="trace-title__right">
+								<span class="trace-time">{{ nodeUseTime(node) }}</span>
+								<span class="status-dot" :class="statusClass(node.status)"></span>
+							</div>
+						</div>
+					</template>
+
+					<div class="trace-body">
+						<div class="panel">
+							<div class="section-title">输入</div>
+							<CodeEditor
+								:model-value="formatDisplayValue(node.track?.input_variable) || '-'"
+								language="json"
+								:tools="true"
+								:read-only="true"
+								:allow-change-language="false"
+								:height="180"
+							/>
+						</div>
+
+						<div class="panel">
+							<div class="section-title">输出</div>
+							<CodeEditor
+								:model-value="formatDisplayValue(node.track?.output_variable) || '-'"
+								language="json"
+								:tools="true"
+								:read-only="true"
+								:allow-change-language="false"
+								:height="180"
+							/>
+						</div>
+					</div>
+				</el-collapse-item>
+			</el-card>
+		</el-collapse>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.tab-pane {
+	height: 100%;
+	min-height: 0;
+	margin-top: 16px;
+}
+
+.tab-pane--scroll {
+	padding: 0 16px 16px;
+	overflow-y: auto;
+}
+
+.empty-state {
+	padding: 24px 16px;
+	border-radius: 12px;
+	background: #f8fafc;
+	color: #667085;
+	line-height: 1.6;
+}
+
+.trace-list {
+	border-top: 0;
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.trace-title {
+	width: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	padding-right: 8px;
+}
+
+.trace-title__left,
+.trace-title__right {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	min-width: 0;
+}
+
+.trace-icon {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 28px;
+	height: 28px;
+	border-radius: 8px;
+	background: #eaf2ff;
+	color: #296dff;
+	flex-shrink: 0;
+}
+
+.trace-name {
+	color: #344054;
+	font-weight: 600;
+}
+
+.trace-time {
+	color: #667085;
+	font-size: 12px;
+	white-space: nowrap;
+}
+
+.status-dot {
+	width: 10px;
+	height: 10px;
+	border-radius: 999px;
+	flex-shrink: 0;
+}
+
+.status-dot.is-success {
+	background: #12b76a;
+}
+
+.status-dot.is-running {
+	background: #f79009;
+}
+
+.status-dot.is-failed {
+	background: #f04438;
+}
+
+.status-dot.is-idle {
+	background: #98a2b3;
+}
+
+.trace-body {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+	padding-top: 8px;
+}
+
+.panel {
+	padding: 14px;
+	border-radius: 16px;
+	background: linear-gradient(180deg, #f8fafc 0%, #f4f7fb 100%);
+	border: 1px solid #e4e7ec;
+	box-shadow:
+		0 8px 24px rgba(15, 23, 42, 0.04),
+		inset 0 1px 0 rgba(255, 255, 255, 0.75);
+
+	.section-title {
+		margin-right: auto;
+		font-size: 14px;
+		font-weight: 600;
+		color: #344054;
+		margin-bottom: 12px;
+	}
+}
+
+:deep(.el-collapse-item__header) {
+	padding: 0;
+	height: auto;
+	min-height: 54px;
+	border: none;
+}
+
+:deep(.el-collapse-item__content) {
+	padding: 0;
+}
+
+:deep(.el-collapse-item__wrap) {
+	border: none;
+}
+</style>

+ 504 - 0
apps/web/src/features/RunWorkflow/index.vue

@@ -0,0 +1,504 @@
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Icon } from '@repo/ui'
+import { cloneDeep } from 'lodash-es'
+import { agent } from '@repo/api-service'
+
+import InputTab from './components/InputTab.vue'
+import ResultTab from './components/ResultTab.vue'
+import DetailTab from './components/DetailTab.vue'
+import TraceTab from './components/TraceTab.vue'
+import {
+	useRunnerStore,
+	type NodeStatus,
+	type RunnerExecution,
+	type RunnerStatus
+} from '@/store/modules/runner.store'
+
+import type { IWorkflow, IWorkflowNode } from '@repo/workflow'
+import type { StartVariable } from '@/nodes/src/start'
+
+interface Props {
+	workflow: IWorkflow
+	visible: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	visible: false
+})
+
+const emit = defineEmits<{
+	'update:visible': [visible: boolean]
+}>()
+
+const runnerStore = useRunnerStore()
+const activeTab = ref('input')
+const executing = ref(false)
+const inputValues = reactive<Record<string, any>>({})
+const jsonDrafts = reactive<Record<string, string>>({})
+const validationErrors = reactive<Record<string, string>>({})
+const submittedParams = ref<Record<string, any> | null>(null)
+
+const startNode = computed<IWorkflowNode | null>(() => {
+	const nodes = props.workflow?.nodes || []
+	return (
+		nodes.find((node) => {
+			const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
+			return nodeType === 'start'
+		}) || null
+	)
+})
+
+const startVariables = computed<StartVariable[]>(() => {
+	const variables = (startNode.value as any)?.data?.variables
+	return Array.isArray(variables) ? variables : []
+})
+
+const visibleVariables = computed(() => startVariables.value.filter((item) => !item.is_hide))
+
+const currentExecution = computed<RunnerExecution | null>(() => {
+	if (runnerStore.currentRunnerKey) {
+		return (
+			runnerStore.executions.find((item) => item.runnerKey === runnerStore.currentRunnerKey) || null
+		)
+	}
+	return runnerStore.executions[0] || null
+})
+
+const traceNodes = computed(() => currentExecution.value?.nodes || runnerStore.nodes || [])
+
+const hasExecutionData = computed(
+	() => !!currentExecution.value || !!submittedParams.value || !!runnerStore.agentResult
+)
+
+const isRunning = computed(
+	() => executing.value || runnerStore.status === 'connecting' || runnerStore.status === 'running'
+)
+
+const executionStatusText = computed(() =>
+	statusText(currentExecution.value?.status || runnerStore.status)
+)
+
+const executionDurationText = computed(() =>
+	formatExecutionDuration(
+		currentExecution.value,
+		runnerStore.agentResult || currentExecution.value?.result
+	)
+)
+
+const totalTokenText = computed(
+	() => `${extractTokenCount(runnerStore.agentResult || currentExecution.value?.result)} Tokens`
+)
+
+const detailMeta = computed(() => [
+	{ label: '状态', value: executionStatusText.value || '-' },
+	{ label: '运行 ID', value: currentExecution.value?.runnerKey || '-' },
+	{ label: '开始时间', value: currentExecution.value?.startedAt || '-' },
+	{ label: '结束时间', value: currentExecution.value?.finishedAt || '-' },
+	{ label: '运行时间', value: executionDurationText.value || '-' },
+	{ label: '运行步数', value: `${traceNodes.value.length || 0}` }
+])
+
+const finalResultValue = computed(() =>
+	extractFinalResult(runnerStore.agentResult || currentExecution.value?.result)
+)
+
+const finalResultText = computed(() => formatDisplayValue(finalResultValue.value))
+const detailInputText = computed(() => formatDisplayValue(submittedParams.value))
+const detailOutputText = computed(() => formatDisplayValue(finalResultValue.value))
+const detailStatusClassName = computed(() =>
+	statusClass(currentExecution.value?.status || runnerStore.status)
+)
+
+const initializeInputValues = () => {
+	startVariables.value.forEach((variable) => {
+		const initialValue = cloneDeep(
+			variable.default_value !== undefined
+				? variable.default_value
+				: createEmptyValue(variable.formType)
+		)
+
+		if (variable.formType === 'json_object') {
+			inputValues[variable.name] =
+				initialValue && typeof initialValue === 'object' && !Array.isArray(initialValue)
+					? initialValue
+					: {}
+			jsonDrafts[variable.name] = formatJsonDraft(inputValues[variable.name], '{}')
+			return
+		}
+
+		inputValues[variable.name] = initialValue
+		delete jsonDrafts[variable.name]
+	})
+}
+
+const resetValidation = () => {
+	Object.keys(validationErrors).forEach((key) => {
+		delete validationErrors[key]
+	})
+}
+
+watch(
+	() => props.visible,
+	(visible) => {
+		if (visible) {
+			activeTab.value = 'input'
+			resetValidation()
+			initializeInputValues()
+			return
+		}
+
+		resetValidation()
+	},
+	{ immediate: true }
+)
+
+const closeDrawer = () => {
+	emit('update:visible', false)
+}
+
+const buildExecuteParams = () => {
+	resetValidation()
+	const params: Record<string, any> = {}
+	let hasError = false
+
+	startVariables.value.forEach((variable) => {
+		const fieldName = variable.name
+		let value = cloneDeep(inputValues[fieldName])
+
+		if (variable.formType === 'json_object') {
+			const draft = `${jsonDrafts[fieldName] || ''}`.trim()
+			if (draft) {
+				try {
+					value = JSON.parse(draft)
+				} catch {
+					if (!variable.is_hide) {
+						validationErrors[fieldName] = '请输入合法的 JSON 对象'
+					}
+					hasError = true
+					return
+				}
+			} else {
+				value = {}
+			}
+
+			if (!value || typeof value !== 'object' || Array.isArray(value)) {
+				if (!variable.is_hide) {
+					validationErrors[fieldName] = '请输入合法的 JSON 对象'
+				}
+				hasError = true
+				return
+			}
+		}
+
+		if (!variable.is_hide && variable.is_require && isEmptyValue(value, variable.formType)) {
+			validationErrors[fieldName] = `${variable.label || fieldName}不能为空`
+			hasError = true
+			return
+		}
+
+		if (
+			!variable.is_hide &&
+			['text-input', 'text-area'].includes(variable.formType) &&
+			variable.max_length &&
+			typeof value === 'string' &&
+			value.length > variable.max_length
+		) {
+			validationErrors[fieldName] =
+				`${variable.label || fieldName}长度不能超过 ${variable.max_length}`
+			hasError = true
+			return
+		}
+
+		params[fieldName] = value
+	})
+
+	return hasError ? null : params
+}
+
+const handleRunWorkflow = async () => {
+	if (!props.workflow?.id || !startNode.value?.id) {
+		ElMessage.warning('缺少开始节点')
+		return
+	}
+
+	const params = buildExecuteParams()
+	if (!params) {
+		activeTab.value = 'input'
+		return
+	}
+
+	submittedParams.value = params
+	activeTab.value = 'result'
+	executing.value = true
+
+	try {
+		const response = await agent.postAgentDoExecute({
+			appAgentId: props.workflow.id,
+			start_node_id: startNode.value.id,
+			is_debugger: true,
+			responseType: 'ws',
+			params
+		})
+
+		const agentRunnerKey = response?.result
+		if (agentRunnerKey) {
+			runnerStore.startRunner(agentRunnerKey)
+			return
+		}
+
+		ElMessage.error('运行启动失败')
+	} catch (error) {
+		console.error('postAgentDoExecute error', error)
+		ElMessage.error('运行失败')
+	} finally {
+		executing.value = false
+	}
+}
+
+function createEmptyValue(formType: string) {
+	switch (formType) {
+		case 'number':
+			return 0
+		case 'checkbox':
+			return false
+		case 'file':
+			return {}
+		case 'file-list':
+			return []
+		case 'json_object':
+			return {}
+		default:
+			return ''
+	}
+}
+
+function isEmptyValue(value: unknown, formType: string) {
+	if (formType === 'checkbox') return false
+	if (formType === 'number') {
+		return value === null || value === undefined || Number.isNaN(Number(value))
+	}
+	if (formType === 'file-list') return !Array.isArray(value) || value.length === 0
+	if (formType === 'file') {
+		if (!value || Array.isArray(value) || typeof value !== 'object') return true
+		return !Object.keys(value as Record<string, unknown>).length
+	}
+	if (formType === 'json_object') {
+		return (
+			!value ||
+			typeof value !== 'object' ||
+			Array.isArray(value) ||
+			!Object.keys(value as Record<string, unknown>).length
+		)
+	}
+	return !`${value ?? ''}`.trim()
+}
+
+function formatJsonDraft(value: unknown, fallback = '{}') {
+	if (value === undefined || value === null || value === '') return fallback
+	try {
+		return JSON.stringify(value, null, 2)
+	} catch {
+		return fallback
+	}
+}
+
+function formatDisplayValue(value: unknown) {
+	if (value === undefined || value === null || value === '') return ''
+	if (typeof value === 'string') return value
+	try {
+		return JSON.stringify(value, null, 2)
+	} catch {
+		return String(value)
+	}
+}
+
+function extractFinalResult(value: any) {
+	if (!value) return null
+	if (value.output_variable !== undefined) return value.output_variable
+	if (value.result !== undefined && typeof value.result !== 'string') return value.result
+	return value
+}
+
+function pickNumber(value: any, paths: string[]) {
+	for (const path of paths) {
+		const result = path.split('.').reduce((acc, key) => acc?.[key], value)
+		const num = Number(result)
+		if (Number.isFinite(num) && num >= 0) {
+			return num
+		}
+	}
+	return 0
+}
+
+function extractTokenCount(value: any) {
+	return pickNumber(value, [
+		'total_tokens',
+		'totalToken',
+		'token_count',
+		'usage.total_tokens',
+		'usage.totalToken'
+	])
+}
+
+function formatExecutionDuration(execution: RunnerExecution | null, result: any) {
+	const rawDuration = pickNumber(result, [
+		'use_time',
+		'useTime',
+		'elapsed',
+		'elapsed_ms',
+		'duration'
+	])
+	if (rawDuration > 0) {
+		return rawDuration > 10 ? `${(rawDuration / 1000).toFixed(3)}s` : `${rawDuration.toFixed(3)}s`
+	}
+
+	const startedAt = execution?.startedAt ? new Date(execution.startedAt).getTime() : 0
+	const finishedAt = execution?.finishedAt ? new Date(execution.finishedAt).getTime() : 0
+	if (startedAt && finishedAt && finishedAt >= startedAt) {
+		return `${((finishedAt - startedAt) / 1000).toFixed(3)}s`
+	}
+
+	return isRunning.value ? '运行中' : '-'
+}
+
+function statusText(status?: NodeStatus | RunnerStatus | null) {
+	if (status === 'running') return '运行中'
+	if (status === 'success' || status === 'finished') return 'SUCCESS'
+	if (status === 'failed') return 'FAILED'
+	if (status === 'error') return 'ERROR'
+	if (status === 'connecting') return '连接中'
+	return '未运行'
+}
+
+function statusClass(status?: NodeStatus | RunnerStatus | null) {
+	if (status === 'success' || status === 'finished') return 'is-success'
+	if (status === 'failed' || status === 'error') return 'is-failed'
+	if (status === 'running' || status === 'connecting') return 'is-running'
+	return 'is-idle'
+}
+</script>
+
+<template>
+	<div class="runner">
+		<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible }">
+			<header class="text-gray-800">
+				<div class="w-full flex items-center justify-between">
+					<h4 class="flex items-center">运行工作流</h4>
+					<Icon
+						icon="lucide:x"
+						height="24"
+						width="24"
+						@click="closeDrawer"
+						class="cursor-pointer"
+					/>
+				</div>
+			</header>
+
+			<div class="content">
+				<el-tabs v-model="activeTab" class="runner-tabs">
+					<el-tab-pane label="输入" name="input">
+						<InputTab
+							:start-node="startNode"
+							:visible-variables="visibleVariables"
+							:input-values="inputValues"
+							:json-drafts="jsonDrafts"
+							:validation-errors="validationErrors"
+							:is-running="isRunning"
+							@run="handleRunWorkflow"
+						/>
+					</el-tab-pane>
+					<el-tab-pane label="结果" name="result">
+						<ResultTab
+							:has-execution-data="hasExecutionData"
+							:is-running="isRunning"
+							:final-result-text="finalResultText"
+							:final-result-value="finalResultValue"
+						/>
+					</el-tab-pane>
+					<el-tab-pane label="详情" name="detail">
+						<DetailTab
+							:has-execution-data="hasExecutionData"
+							:status-class-name="detailStatusClassName"
+							:execution-status-text="executionStatusText"
+							:execution-duration-text="executionDurationText"
+							:total-token-text="totalTokenText"
+							:detail-meta="detailMeta"
+							:submitted-params="submittedParams"
+							:detail-input-text="detailInputText"
+							:final-result-value="finalResultValue"
+							:detail-output-text="detailOutputText"
+						/>
+					</el-tab-pane>
+					<el-tab-pane label="追踪" name="trace">
+						<TraceTab :trace-nodes="traceNodes" />
+					</el-tab-pane>
+				</el-tabs>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.runner {
+	.drawer {
+		position: fixed;
+		top: 60px;
+		right: 5px;
+		bottom: 10px;
+		width: 420px;
+		background: #fff;
+		z-index: 1000;
+		border-radius: 8px;
+		display: flex;
+		flex-direction: column;
+		border: 1px solid #e4e4e4;
+		transform: translateX(110%);
+		transition: transform 0.25s ease;
+	}
+
+	.drawer--open {
+		transform: translateX(0);
+	}
+
+	.drawer header {
+		height: 66px;
+		padding: 16px 16px 0;
+
+		h4 {
+			margin: 0;
+		}
+	}
+
+	.drawer .content {
+		flex: 1;
+		min-height: 0;
+		overflow: hidden;
+	}
+
+	:deep(.runner-tabs) {
+		height: 100%;
+		display: flex;
+		flex-direction: column;
+	}
+
+	:deep(.el-tabs__header) {
+		margin-bottom: 0;
+	}
+
+	:deep(.el-tabs__nav-scroll) {
+		padding-left: 20px;
+	}
+
+	:deep(.el-tabs__content) {
+		flex: 1;
+		min-height: 0;
+		overflow: hidden;
+	}
+
+	:deep(.el-tab-pane) {
+		height: 100%;
+	}
+}
+</style>

+ 19 - 40
apps/web/src/views/editor/NodeView.vue

@@ -23,13 +23,13 @@
 		>
 			<Toolbar
 				@create:node="handleNodeCreate"
-				@run="handleRunSelectedNode"
+				@run="handleRunAgent"
 				:env-vars="workflow?.env_variables || []"
 				@change-env-vars="handleChangeEnvVars"
 			/>
 		</Workflow>
 	</div>
-	<RunWorkflow v-model:visible="runVisible" @run="handleRunSelectedNode" />
+	<RunWorkflow v-model:visible="runVisible" :workflow="workflow" />
 	<Setter
 		:id="nodeID"
 		:workflow="workflow"
@@ -54,7 +54,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { agent } from '@repo/api-service'
 
-import RunWorkflow from '@/components/RunWorkflow/index.vue'
+import RunWorkflow from '@/features/RunWorkflow/index.vue'
 import NodeLibary from '@/features/nodeLibary/index.vue'
 import Toolbar from '@/features/toolbar/index.vue'
 import Setter from '@/features/setter/index.vue'
@@ -305,29 +305,17 @@ const isPendingCreate = (node: any) => {
 	return !!(node as any)?.__pendingCreate
 }
 
-// 运行当前选中的节点,可由画布或配置面板触发
-const handleRunSelectedNode = async () => {
+// 从节点级操作入口直接运行指定节点
+const handleRunNode = async (id: string) => {
 	if (!props.workflow?.id) {
 		ElMessage.warning('请先选择需要运行的节点')
 		return
 	}
 
-	if (!nodeID.value) {
-		const selectedNode = props.workflow?.nodes?.find((node) => (node as any)?.selected)
-		if (selectedNode?.id) {
-			nodeID.value = selectedNode.id
-		}
-	}
-
-	if (!nodeID.value) {
-		ElMessage.warning('请选择需要运行的节点')
-		return
-	}
-
 	try {
 		const response = await agent.postAgentDoExecute({
 			appAgentId: props.workflow.id,
-			start_node_id: nodeID.value,
+			start_node_id: id,
 			is_debugger: true,
 			responseType: 'ws',
 			params: {}
@@ -336,36 +324,27 @@ const handleRunSelectedNode = async () => {
 		if (agentRunnerKey) {
 			runnerStore.startRunner(agentRunnerKey)
 		}
-		runVisible.value = false
 	} catch (error) {
 		console.error('postDoTestNodeRunner error', error)
-		ElMessage.error('节点测试失败')
+		ElMessage.error('运行节点失败')
 	}
 }
 
-// 从节点级操作入口直接运行指定节点。
-const handleRunNode = async (id: string) => {
-	if (!props.workflow?.id) {
-		ElMessage.warning('请先选择需要运行的节点')
+/**
+ * 运行智能体
+ */
+const handleRunAgent = () => {
+	const startNode = props.workflow?.nodes?.find((node) => {
+		const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
+		return nodeType === 'start'
+	})
+
+	if (!props.workflow?.id || !startNode?.id) {
+		ElMessage.warning('缺少开始节点')
 		return
 	}
 
-	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)
-		}
-	} catch (error) {
-		console.error('postDoTestNodeRunner error', error)
-		ElMessage.error('运行节点失败')
-	}
+	runVisible.value = true
 }
 
 /**