|
|
@@ -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>
|