소스 검색

feat: 添加对话调试弹窗

jiaxing.liao 1 주 전
부모
커밋
4bb1ad36df

+ 2 - 0
apps/web/components.d.ts

@@ -82,6 +82,7 @@ declare module 'vue' {
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
     MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
     MessageList: typeof import('./src/components/Chat/MessageList.vue')['default']
+    ResizableDrawer: typeof import('./src/components/ResizableDrawer/index.vue')['default']
     RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
@@ -169,6 +170,7 @@ declare global {
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
   const MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
   const MessageList: typeof import('./src/components/Chat/MessageList.vue')['default']
+  const ResizableDrawer: typeof import('./src/components/ResizableDrawer/index.vue')['default']
   const RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
   const RouterLink: typeof import('vue-router')['RouterLink']
   const RouterView: typeof import('vue-router')['RouterView']

+ 7 - 2
apps/web/src/components/Chat/MessageList.vue

@@ -140,6 +140,8 @@
 							<span class="inline-error-text">{{ err }}</span>
 						</div>
 					</div>
+
+					<slot name="message-extra" :item="item" />
 					<SMarkdown
 						v-if="getDisplayText(item)"
 						:class="['msg-content-text', { 'msg-content-text--error': isErrorMessage(item) }]"
@@ -273,8 +275,8 @@ const SCROLL_STABILITY_MS = 10000
 
 const bubbleListItems = computed(() =>
 	props.messages.map((item) => ({
-		maxWidth: '800px',
-		...item
+		...item,
+		maxWidth: item.role === 'ai' ? 'min(920px, calc(100% - 56px))' : 'min(640px, calc(100% - 56px))'
 	}))
 )
 
@@ -661,6 +663,8 @@ const handleAddToKb = (message: BubbleMessage) => {
 	display: flex;
 	flex-direction: column;
 	gap: 12px;
+	width: 100%;
+	min-width: 0;
 
 	.msg-content-text {
 		font-size: 14px;
@@ -931,4 +935,5 @@ const handleAddToKb = (message: BubbleMessage) => {
 	line-height: 1.5;
 	word-break: break-all;
 }
+
 </style>

+ 5 - 2
apps/web/src/components/CodeEditor/CodeEditor.vue

@@ -583,11 +583,14 @@ defineExpose({
 			bottom: 2px;
 			left: 50%;
 			transform: translateX(-50%);
-			width: 18px;
+			width: 24px;
 			height: 4px;
-			background-color: var(--border-base);
+			background-color: var(--text-secondary);
 			border-radius: 8px;
 			cursor: ns-resize;
+			&:hover {
+				background-color: var(--text-primary);
+			}
 		}
 	}
 

+ 164 - 0
apps/web/src/components/ResizableDrawer/index.vue

@@ -0,0 +1,164 @@
+<script lang="ts" setup>
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+
+interface Props {
+	visible: boolean
+	defaultWidth?: number
+	minWidth?: number
+	maxWidthRatio?: number
+	top?: number
+	right?: number
+	bottom?: number
+	resizable?: boolean
+	zIndex?: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	visible: false,
+	defaultWidth: undefined,
+	minWidth: 420,
+	maxWidthRatio: 0.6,
+	top: 60,
+	right: 5,
+	bottom: 10,
+	resizable: true,
+	zIndex: 1000
+})
+
+const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
+const drawerWidth = ref(props.defaultWidth || props.minWidth)
+const resizeState = reactive({
+	startX: 0,
+	startWidth: props.minWidth,
+	isDragging: false
+})
+
+const maxDrawerWidth = computed(() => {
+	return Math.max(props.minWidth, Math.floor(viewportWidth.value * props.maxWidthRatio))
+})
+
+const clampDrawerWidth = (width: number) => {
+	return Math.min(Math.max(width, props.minWidth), maxDrawerWidth.value)
+}
+
+const rootStyle = computed(() => ({
+	top: `${props.top}px`,
+	right: `${props.right}px`,
+	bottom: `${props.bottom}px`,
+	width: `${drawerWidth.value}px`,
+	minWidth: `${props.minWidth}px`,
+	maxWidth: `${Math.floor(props.maxWidthRatio * 100)}vw`,
+	zIndex: `${props.zIndex}`
+}))
+
+const syncViewportWidth = () => {
+	viewportWidth.value = window.innerWidth
+	drawerWidth.value = clampDrawerWidth(drawerWidth.value)
+}
+
+const stopResize = () => {
+	if (!resizeState.isDragging) return
+	resizeState.isDragging = false
+	document.body.style.userSelect = ''
+	document.body.style.cursor = ''
+	window.removeEventListener('mousemove', onResize)
+	window.removeEventListener('mouseup', stopResize)
+}
+
+const onResize = (event: MouseEvent) => {
+	if (!resizeState.isDragging) return
+	const nextWidth = resizeState.startWidth + (resizeState.startX - event.clientX)
+	drawerWidth.value = clampDrawerWidth(nextWidth)
+}
+
+const onResizeStart = (event: MouseEvent) => {
+	if (!props.resizable) return
+	resizeState.startX = event.clientX
+	resizeState.startWidth = drawerWidth.value
+	resizeState.isDragging = true
+	document.body.style.userSelect = 'none'
+	document.body.style.cursor = 'ew-resize'
+	window.addEventListener('mousemove', onResize)
+	window.addEventListener('mouseup', stopResize)
+}
+
+watch(
+	() => [props.minWidth, props.maxWidthRatio, props.defaultWidth],
+	() => {
+		drawerWidth.value = clampDrawerWidth(drawerWidth.value || props.defaultWidth || props.minWidth)
+	},
+	{ immediate: true }
+)
+
+onMounted(() => {
+	window.addEventListener('resize', syncViewportWidth)
+	syncViewportWidth()
+})
+
+onBeforeUnmount(() => {
+	stopResize()
+	window.removeEventListener('resize', syncViewportWidth)
+})
+
+defineExpose({
+	setWidth: (width: number) => {
+		drawerWidth.value = clampDrawerWidth(width)
+	},
+	getWidth: () => drawerWidth.value
+})
+</script>
+
+<template>
+	<div
+		class="resizable-drawer shadow-2xl"
+		:class="{
+			'resizable-drawer--open': props.visible,
+			'resizable-drawer--resizing': resizeState.isDragging
+		}"
+		:style="rootStyle"
+	>
+		<div
+			v-if="props.resizable"
+			class="resizable-drawer__handle"
+			@mousedown.prevent="onResizeStart"
+		></div>
+		<slot />
+	</div>
+</template>
+
+<style lang="less" scoped>
+.resizable-drawer {
+	position: fixed;
+	background: var(--bg-base);
+	border-radius: 8px;
+	display: flex;
+	flex-direction: column;
+	border: 1px solid var(--border-light);
+	transform: translateX(110%);
+	transition: transform 0.25s ease;
+
+	&.resizable-drawer--resizing {
+		transition: none;
+	}
+}
+
+.resizable-drawer--open {
+	transform: translateX(0);
+}
+
+.resizable-drawer__handle {
+	position: absolute;
+	left: -8px;
+	top: 50%;
+	transform: translateY(-50%);
+	width: 3px;
+	height: 32px;
+	background-color: var(--text-secondary);
+	border-radius: 8px;
+	cursor: ew-resize;
+
+	&:hover {
+		background-color: var(--text-primary);
+	}
+}
+</style>

+ 69 - 12
apps/web/src/features/setter/Chat.vue

@@ -6,7 +6,11 @@
 			:loading="isRunning"
 			@retry="handleRetry"
 			:can-add-to-kb="false"
-		/>
+		>
+			<template #message-extra="{ item }">
+				<WorkflowTraceBubble v-if="props.showWorkflowTrace" :message="item" />
+			</template>
+		</MessageList>
 
 		<div class="setter-chat__footer">
 			<el-dialog
@@ -58,7 +62,9 @@ import { agent } from '@repo/api-service'
 import ChatInput from '@/components/Chat/ChatInput.vue'
 import MessageList from '@/components/Chat/MessageList.vue'
 import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
+import WorkflowTraceBubble from './WorkflowTraceBubble.vue'
 import { useRunnerStore } from '@/store/modules/runner.store'
+import { cloneDeep } from 'lodash-es'
 
 import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
 import type {
@@ -73,9 +79,22 @@ import type { IWorkflowNode } from '@repo/workflow'
 interface Props {
 	node?: IWorkflowNode
 	workflowId?: string
+	baseParams?: Record<string, any>
+	showWorkflowTrace?: boolean
+	validateBeforeSend?: () => boolean | Record<string, any> | Promise<boolean | Record<string, any>>
+	onFirstSend?: () => void
 }
 
-const props = defineProps<Props>()
+const props = withDefaults(defineProps<Props>(), {
+	baseParams: () => ({}),
+	showWorkflowTrace: false,
+	validateBeforeSend: undefined,
+	onFirstSend: undefined
+})
+
+const emit = defineEmits<{
+	'run-started': [nodeId: string]
+}>()
 
 const runnerStore = useRunnerStore()
 const messages = ref<BubbleMessage[]>([])
@@ -139,9 +158,20 @@ const createAiMessage = (): BubbleMessage => ({
 	variant: 'filled',
 	isMarkdown: true,
 	typing: { step: 3, interval: 25 },
-	isFog: true
+	isFog: true,
+	...(props.showWorkflowTrace
+		? {
+				workflowTraceVisible: true,
+				workflowTraceNodes: []
+			}
+		: {})
 })
 
+const syncWorkflowTraceToActiveMessage = () => {
+	if (!props.showWorkflowTrace || !activeAiMessage.value) return
+	activeAiMessage.value.workflowTraceNodes = cloneDeep(runnerStore.nodes)
+}
+
 const getFilesParam = () =>
 	attachments.value
 		.map((item) => ({
@@ -266,16 +296,19 @@ const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMes
 	const data = event.data || {}
 
 	switch (event.response_type) {
-		case 'agent_query':
 		case 'answer':
-		case 'message':
-		case 'delta':
 			message.assistantMessageId =
 				data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
 			if (event.content) {
 				message.answerText = `${message.answerText || ''}${event.content}`
 			}
 			break
+		case 'agent_query':
+		case 'message':
+		case 'delta':
+			message.assistantMessageId =
+				data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
+			break
 		case 'thinking': {
 			const thought = normalizeText(event.content || data.thought || data.content)
 			if (thought) {
@@ -300,9 +333,6 @@ const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMes
 		case 'complete':
 			break
 		default:
-			if (event.content) {
-				message.answerText = `${message.answerText || ''}${event.content}`
-			}
 			break
 	}
 
@@ -342,6 +372,15 @@ const handleSend = async (content?: string) => {
 	}
 	if (isRunning.value) return
 
+	let executeParams = cloneDeep(props.baseParams)
+	if (props.validateBeforeSend) {
+		const passed = await props.validateBeforeSend()
+		if (!passed) return
+		if (typeof passed === 'object') {
+			executeParams = cloneDeep(passed)
+		}
+	}
+
 	const files = getFilesParam()
 	messages.value.push(createUserMessage(query, attachments.value))
 	const aiMessage = createAiMessage()
@@ -359,9 +398,10 @@ const handleSend = async (content?: string) => {
 			start_node_id: props.node.id,
 			is_debugger: true,
 			responseType: 'ws',
+			query,
+			files: files.map((item) => item.id),
 			params: {
-				query,
-				files
+				...executeParams
 			}
 		})
 
@@ -373,6 +413,9 @@ const handleSend = async (content?: string) => {
 		}
 
 		runnerStore.startRunner(agentRunnerKey, props.node.id)
+		syncWorkflowTraceToActiveMessage()
+		props.onFirstSend?.()
+		emit('run-started', props.node.id)
 		handledChatMessageCount.value = 0
 	} catch (error) {
 		console.error('postAgentDoExecute error', error)
@@ -396,6 +439,10 @@ const handleRetry = (message: BubbleMessage) => {
 	handleSend(previous.content)
 }
 
+defineExpose({
+	scrollToBottom
+})
+
 watch(
 	() => runnerStore.agentChatMessages.length,
 	() => {
@@ -403,6 +450,15 @@ watch(
 	}
 )
 
+watch(
+	() => runnerStore.nodes.map((node) => ({ ...node })),
+	() => {
+		syncWorkflowTraceToActiveMessage()
+		scrollToBottom()
+	},
+	{ deep: true }
+)
+
 watch(
 	() => runnerStore.status,
 	(status) => {
@@ -433,7 +489,6 @@ watch(
 .setter-chat__footer {
 	flex-shrink: 0;
 	border-top: 1px solid var(--border-light);
-	background: var(--bg-container);
 }
 
 :deep(.chat-content) {
@@ -458,5 +513,7 @@ watch(
 	background-color: var(--bg-container);
 	border: none;
 	color: var(--text-primary);
+	width: 100%;
+	max-width: min(920px, calc(100vw - 220px));
 }
 </style>

+ 434 - 0
apps/web/src/features/ChatDrawer/WorkflowTraceBubble.vue

@@ -0,0 +1,434 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { Icon } from '@repo/ui'
+
+import { useI18n } from '@/composables/useI18n'
+import { nodeMap } from '@/nodes'
+import { getNodeDisplayName } from '@/nodes/i18n'
+
+import type { BubbleMessage } from '@/views/chat/types'
+import type { RunnerNodeState } from '@/store/modules/runner.store'
+
+const props = defineProps<{
+	message: BubbleMessage
+}>()
+
+const { t } = useI18n()
+
+const workflowTraceNodes = computed<RunnerNodeState[]>(() =>
+	Array.isArray(props.message.workflowTraceNodes) ? props.message.workflowTraceNodes : []
+)
+
+const shouldShowWorkflowTrace = computed(
+	() =>
+		props.message.role === 'ai' &&
+		(props.message.workflowTraceVisible || workflowTraceNodes.value.length > 0)
+)
+
+const isWorkflowTraceOpen = ref(false)
+
+const toggleWorkflowTrace = () => {
+	isWorkflowTraceOpen.value = !isWorkflowTraceOpen.value
+}
+
+const workflowExpandedNodeIds = computed<string[]>(() =>
+	Array.isArray(props.message.workflowExpandedNodeIds)
+		? props.message.workflowExpandedNodeIds.filter((item): item is string => typeof item === 'string')
+		: []
+)
+
+const isWorkflowNodeOpen = (nodeId: string) => workflowExpandedNodeIds.value.includes(nodeId)
+
+const handleWorkflowCollapseChange = (value: string | string[]) => {
+	props.message.workflowExpandedNodeIds = Array.isArray(value) ? value : value ? [value] : []
+}
+
+const workflowTraceStatus = computed(() => {
+	const nodes = workflowTraceNodes.value
+	if (nodes.some((node) => node.status === 'failed')) return 'failed'
+	if (nodes.some((node) => node.status === 'suspended')) return 'suspended'
+	if (nodes.some((node) => node.status === 'running')) return 'running'
+	if (nodes.length && nodes.every((node) => node.status === 'success')) return 'success'
+	return props.message.streamCompleted ? 'success' : 'running'
+})
+
+const workflowTraceStatusClass = computed(() => `is-${workflowTraceStatus.value}`)
+
+const workflowTraceStatusIcon = computed(() => {
+	const status = workflowTraceStatus.value
+	if (status === 'success') return 'lucide:check'
+	if (status === 'failed') return 'lucide:x'
+	if (status === 'suspended') return 'lucide:pause'
+	return 'lucide:loader'
+})
+
+const nodeStatusClass = (status?: string | null) => {
+	if (status === 'success') return 'is-success'
+	if (status === 'failed') return 'is-failed'
+	if (status === 'running') return 'is-running'
+	if (status === 'suspended') return 'is-suspended'
+	return 'is-idle'
+}
+
+const nodeStatusIcon = (status?: string | null) => {
+	if (status === 'success') return 'lucide:check'
+	if (status === 'failed') return 'lucide:x'
+	if (status === 'suspended') return 'lucide:pause'
+	if (status === 'running') return 'lucide:loader'
+	return 'lucide:circle'
+}
+
+const nodeIcon = (node: RunnerNodeState) => nodeMap[node.nodeType]?.icon || 'lucide:box'
+
+const isImageIcon = (icon?: string) => !!icon && icon.startsWith('data:image/')
+
+const nodeDisplayName = (node: RunnerNodeState) =>
+	node.nodeName ||
+	getNodeDisplayName(node.nodeType) ||
+	nodeMap[node.nodeType]?.displayName ||
+	node.nodeType ||
+	t('pages.runWorkflow.tracePanel.unnamedNode')
+
+const nodeUseTime = (node: RunnerNodeState) => {
+	const useTime = Number(node.track?.use_time ?? node.track?.useTime ?? 0)
+	if (Number.isFinite(useTime) && useTime > 0) {
+		return `${useTime} ms`
+	}
+	return '-'
+}
+
+const formatWorkflowValue = (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 v-if="shouldShowWorkflowTrace" class="workflow-trace-bubble">
+		<div
+			type="button"
+			class="workflow-trace-bubble__header"
+			@click.stop.prevent="toggleWorkflowTrace"
+		>
+			<span class="workflow-trace-bubble__status" :class="workflowTraceStatusClass">
+				<Icon :icon="workflowTraceStatusIcon" :width="12" />
+			</span>
+			<span class="workflow-trace-bubble__title">
+				{{ t('pages.runWorkflow.chatPanel.workflow') }}
+			</span>
+			<Icon
+				icon="lucide:chevron-down"
+				class="workflow-trace-bubble__chevron"
+				:class="{ 'is-open': isWorkflowTraceOpen }"
+				:width="18"
+			/>
+		</div>
+
+		<div v-if="isWorkflowTraceOpen" class="workflow-node-list">
+			<div v-if="!workflowTraceNodes.length" class="workflow-node-empty">
+				{{ t('pages.runWorkflow.chatPanel.nodeRunEmpty') }}
+			</div>
+			<el-collapse
+				v-else
+				class="workflow-node-collapse"
+				:model-value="workflowExpandedNodeIds"
+				@update:model-value="handleWorkflowCollapseChange"
+			>
+				<el-collapse-item
+					v-for="node in workflowTraceNodes"
+					:key="node.nodeId"
+					:name="node.nodeId"
+					class="workflow-node-card"
+				>
+					<template #title>
+						<div class="workflow-node-card__summary">
+							<Icon
+								icon="lucide:chevron-right"
+								class="workflow-node-card__chevron"
+								:class="{ 'is-open': isWorkflowNodeOpen(node.nodeId) }"
+								:width="14"
+							/>
+							<span class="workflow-node-card__icon">
+								<img v-if="isImageIcon(nodeIcon(node))" :src="nodeIcon(node)" alt="node icon" />
+								<Icon v-else :icon="nodeIcon(node)" :width="14" />
+							</span>
+							<span class="workflow-node-card__name">{{ nodeDisplayName(node) }}</span>
+							<span class="workflow-node-card__duration">{{ nodeUseTime(node) }}</span>
+							<span class="workflow-node-card__status" :class="nodeStatusClass(node.status)">
+								<Icon :icon="nodeStatusIcon(node.status)" :width="16" />
+							</span>
+						</div>
+					</template>
+
+					<div class="workflow-node-card__detail">
+						<div class="workflow-json-panel">
+							<div class="workflow-json-panel__title">
+								{{ t('pages.runWorkflow.tracePanel.input') }}
+							</div>
+							<CodeEditor
+								:model-value="formatWorkflowValue(node.track?.input_variable) || '-'"
+								language="json"
+								:tools="true"
+								:read-only="true"
+								:allow-change-language="false"
+								:height="180"
+							/>
+						</div>
+
+						<div class="workflow-json-panel">
+							<div class="workflow-json-panel__title">
+								{{ t('pages.runWorkflow.tracePanel.output') }}
+							</div>
+							<CodeEditor
+								:model-value="formatWorkflowValue(node.track?.output_variable) || '-'"
+								language="json"
+								:tools="true"
+								:read-only="true"
+								:allow-change-language="false"
+								:height="180"
+							/>
+						</div>
+					</div>
+				</el-collapse-item>
+			</el-collapse>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.workflow-trace-bubble {
+	box-sizing: border-box;
+	width: 100%;
+	padding: 12px 14px;
+	border-radius: 8px;
+	border: 1px solid #b9efd2;
+	background: #e8fbf1;
+	box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
+}
+
+.workflow-trace-bubble__header {
+	width: 100%;
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	padding: 0;
+	border: none;
+	background: transparent;
+	color: #24364b;
+	cursor: pointer;
+	text-align: left;
+}
+
+.workflow-trace-bubble__status,
+.workflow-node-card__status {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 18px;
+	height: 18px;
+	border-radius: 999px;
+	color: #fff;
+	background: #11a260;
+	flex-shrink: 0;
+}
+
+.workflow-trace-bubble__status.is-running,
+.workflow-node-card__status.is-running {
+	background: #2388ff;
+}
+
+.workflow-trace-bubble__status.is-failed,
+.workflow-node-card__status.is-failed {
+	background: #f04438;
+}
+
+.workflow-trace-bubble__status.is-suspended,
+.workflow-node-card__status.is-suspended {
+	background: #667085;
+}
+
+.workflow-trace-bubble__title {
+	font-size: 14px;
+	font-weight: 700;
+	color: #24364b;
+	line-height: 24px;
+}
+
+.workflow-trace-bubble__chevron {
+	margin-left: auto;
+	color: #667085;
+	transition: transform 0.16s ease;
+}
+
+.workflow-trace-bubble__chevron.is-open {
+	transform: rotate(180deg);
+}
+
+.workflow-node-list {
+	display: flex;
+	flex-direction: column;
+	gap: 6px;
+	margin-top: 14px;
+	width: 100%;
+}
+
+.workflow-node-collapse {
+	display: flex;
+	flex-direction: column;
+	gap: 6px;
+	border: none;
+}
+
+.workflow-node-empty {
+	padding: 14px 16px;
+	border-radius: 8px;
+	background: rgba(255, 255, 255, 0.82);
+	color: var(--text-tertiary);
+	line-height: 1.5;
+}
+
+.workflow-node-card {
+	box-sizing: border-box;
+	width: 100%;
+	border-radius: 8px;
+	background: #fff;
+	border: 1px solid #e6eaf0;
+	box-shadow: 0 2px 8px rgba(16, 24, 40, 0.08);
+	overflow: hidden;
+
+	:deep(.el-collapse-item__header) {
+		height: auto;
+		min-height: 48px;
+		padding: 0;
+		border: none;
+		background: #fff;
+		line-height: normal;
+	}
+
+	:deep(.el-collapse-item__arrow) {
+		display: none;
+	}
+
+	:deep(.el-collapse-item__wrap) {
+		border: none;
+	}
+
+	:deep(.el-collapse-item__content) {
+		padding-bottom: 0;
+	}
+}
+
+.workflow-node-card__summary {
+	box-sizing: border-box;
+	width: 100%;
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	min-height: 48px;
+	padding: 8px 12px;
+	border: none;
+	background: #fff;
+	cursor: pointer;
+	text-align: left;
+}
+
+.workflow-node-card__chevron {
+	color: #98a2b3;
+	transition: transform 0.16s ease;
+	flex-shrink: 0;
+}
+
+.workflow-node-card__chevron.is-open {
+	transform: rotate(90deg);
+}
+
+.workflow-node-card__icon {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 22px;
+	height: 22px;
+	border-radius: 8px;
+	background: #1677ff;
+	color: #fff;
+	flex-shrink: 0;
+}
+
+.workflow-node-card__icon img {
+	width: 18px;
+	height: 18px;
+	object-fit: contain;
+}
+
+.workflow-node-card__name {
+	min-width: 0;
+	flex: 1;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	font-size: 12px;
+	font-weight: 700;
+	color: #24364b;
+}
+
+.workflow-node-card__duration {
+	color: #667085;
+	font-size: 14px;
+	white-space: nowrap;
+}
+
+.workflow-node-card__status {
+	width: 18px;
+	height: 18px;
+}
+
+.workflow-node-card__status.is-idle {
+	background: #98a2b3;
+}
+
+.workflow-node-card__detail {
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+	padding: 0 8px 8px;
+	width: 100%;
+}
+
+.workflow-json-panel {
+	box-sizing: border-box;
+	width: 100%;
+	padding: 10px 12px;
+	border-radius: 8px;
+	background: #f1f4f8;
+	color: #24364b;
+}
+
+.workflow-json-panel__title {
+	margin-bottom: 8px;
+	font-size: 14px;
+	font-weight: 700;
+	color: #24364b;
+}
+
+.workflow-json-panel pre {
+	min-height: 96px;
+	max-height: 260px;
+	margin: 0;
+	padding: 8px 10px;
+	overflow: auto;
+	border-radius: 4px;
+	background: #eef2f7;
+	border-top: 2px solid #4b5563;
+	color: #003b8f;
+	font-size: 13px;
+	line-height: 1.55;
+	white-space: pre-wrap;
+	word-break: break-word;
+}
+</style>

+ 174 - 0
apps/web/src/features/ChatDrawer/index.vue

@@ -0,0 +1,174 @@
+<script lang="ts" setup>
+import { nextTick, ref, watch } from 'vue'
+
+import InputTab from '@/features/RunWorkflow/components/InputTab.vue'
+import Chat from './Chat.vue'
+import ResizableDrawer from '@/components/ResizableDrawer/index.vue'
+import { useI18n } from '@/composables/useI18n'
+import { Icon } from '@repo/ui'
+
+import type { IWorkflow, IWorkflowNode } from '@repo/workflow'
+import type { StartVariable } from '@/nodes/src/start'
+
+const props = withDefaults(
+	defineProps<{
+		visible: boolean
+		workflow: IWorkflow
+		startNode: IWorkflowNode | null
+		visibleVariables: StartVariable[]
+		inputValues: Record<string, any>
+		jsonDrafts: Record<string, string>
+		validationErrors: Record<string, string>
+		baseParams: Record<string, any>
+		isRunning: boolean
+	}>(),
+	{
+		visible: false,
+		startNode: null,
+		baseParams: () => ({})
+	}
+)
+
+const emit = defineEmits<{
+	'update:visible': [value: boolean]
+	'validate-send': [done: (params: Record<string, any> | false) => void]
+	'run-started': [nodeId: string]
+	cancel: []
+}>()
+
+const { t } = useI18n()
+const submitted = ref(false)
+const chatContainerRef = ref<InstanceType<typeof Chat>>()
+
+const closeDrawer = () => {
+	handleDrawerUpdate(false)
+}
+
+const handleValidateSend = () =>
+	new Promise<Record<string, any> | false>((resolve) => {
+		emit('validate-send', resolve)
+	})
+
+const handleDrawerUpdate = (value: boolean) => {
+	if (!value) {
+		handleCancel()
+	}
+	emit('update:visible', value)
+}
+
+const handleFirstSend = () => {
+	submitted.value = true
+}
+
+const handleCancel = () => {
+	emit('cancel')
+}
+
+watch(
+	() => props.visible,
+	(visible) => {
+		if (!visible) {
+			submitted.value = false
+		}
+	},
+	{ immediate: true }
+)
+
+defineExpose({
+	scrollToBottom: () => {
+		nextTick(() => {
+			chatContainerRef.value?.$el?.querySelector?.('.chat-content')?.scrollTo?.(0, 999999)
+		})
+	}
+})
+</script>
+
+<template>
+	<div class="chat-drawer">
+		<ResizableDrawer
+			:visible="visible"
+			:default-width="520"
+			:min-width="420"
+			:max-width-ratio="0.82"
+			:z-index="1002"
+			class="chat-drawer__panel"
+		>
+			<header class="chat-drawer__header">
+				<h4>{{ t('pages.runWorkflow.chatDialogTitle') }}</h4>
+				<Icon icon="lucide:x" height="24" width="24" class="cursor-pointer" @click="closeDrawer" />
+			</header>
+
+			<div class="chat-drawer__body">
+				<div v-if="!submitted" class="chat-drawer__form">
+					<el-card>
+						<InputTab
+							:start-node="startNode"
+							:visible-variables="visibleVariables"
+							:input-values="inputValues"
+							:json-drafts="jsonDrafts"
+							:validation-errors="validationErrors"
+							:is-running="isRunning"
+							:show-action-bar="false"
+							:paneStyle="{ height: 'auto' }"
+						/>
+					</el-card>
+				</div>
+
+				<div class="chat-drawer__chat">
+					<Chat
+						ref="chatContainerRef"
+						:node="startNode || undefined"
+						:workflow-id="workflow.id"
+						:base-params="baseParams"
+						:validate-before-send="handleValidateSend"
+						:on-first-send="handleFirstSend"
+						show-workflow-trace
+						@run-started="emit('run-started', $event)"
+					/>
+				</div>
+			</div>
+		</ResizableDrawer>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.chat-drawer {
+	z-index: 1002;
+}
+
+.chat-drawer__header {
+	height: 66px;
+	padding: 16px 16px 0;
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	color: var(--text-primary);
+
+	h4 {
+		margin: 0;
+		font-size: 15px;
+		font-weight: 600;
+	}
+}
+
+.chat-drawer__body {
+	flex: 1;
+	min-height: 0;
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+	overflow: hidden;
+}
+
+.chat-drawer__form {
+	padding: 12px;
+	flex-shrink: 0;
+	max-height: 60%;
+	overflow-y: auto;
+}
+
+.chat-drawer__chat {
+	flex: 1;
+	min-height: 0;
+}
+</style>

+ 5 - 2
apps/web/src/features/RunWorkflow/components/InputTab.vue

@@ -2,6 +2,7 @@
 import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
 import { useI18n } from '@/composables/useI18n'
 
+import type { CSSProperties } from 'vue'
 import type { IWorkflowNode } from '@repo/workflow'
 import type { StartVariable } from '@/nodes/src/start'
 
@@ -12,6 +13,8 @@ defineProps<{
 	jsonDrafts: Record<string, string>
 	validationErrors: Record<string, string>
 	isRunning: boolean
+	showActionBar?: boolean
+	paneStyle?: CSSProperties
 }>()
 
 defineEmits<{
@@ -27,7 +30,7 @@ const getSelectPlaceholder = (label?: string) =>
 </script>
 
 <template>
-	<div class="tab-pane tab-pane--fill">
+	<div class="tab-pane tab-pane--fill" :style="paneStyle">
 		<div v-if="!startNode" class="empty-state">{{ t('pages.runWorkflow.inputPanel.noStart') }}</div>
 		<div v-else-if="!visibleVariables.length" class="empty-state">
 			{{ t('pages.runWorkflow.inputPanel.noInputs') }}
@@ -120,7 +123,7 @@ const getSelectPlaceholder = (label?: string) =>
 			</el-form-item>
 		</el-form>
 
-		<div class="action-bar">
+		<div v-if="showActionBar !== false" class="action-bar">
 			<el-button type="primary" class="run-button" :loading="isRunning" @click="$emit('run')">
 				{{ t('pages.runWorkflow.inputPanel.run') }}
 			</el-button>

+ 39 - 126
apps/web/src/features/RunWorkflow/index.vue

@@ -10,6 +10,8 @@ import TriggerTab from './components/TriggerTab.vue'
 import ResultTab from './components/ResultTab.vue'
 import DetailTab from './components/DetailTab.vue'
 import TraceTab from './components/TraceTab.vue'
+import ResizableDrawer from '@/components/ResizableDrawer/index.vue'
+import { buildExecuteParams, createEmptyValue } from './utils'
 import {
 	useRunnerStore,
 	type NodeStatus,
@@ -45,7 +47,9 @@ const emit = defineEmits<{
 
 const { t, locale } = useI18n()
 const runnerStore = useRunnerStore()
-const activeTab = ref('input')
+type RunWorkflowTab = 'input' | 'trigger' | 'result' | 'detail' | 'trace'
+
+const activeTab = ref<RunWorkflowTab>('input')
 const executing = ref(false)
 const inputValues = reactive<Record<string, any>>({})
 const jsonDrafts = reactive<Record<string, string>>({})
@@ -126,7 +130,10 @@ const detailMeta = computed(() => [
 	{ label: t('pages.runWorkflow.metaStatus'), value: executionStatusText.value || '-' },
 	{ label: t('pages.runWorkflow.metaRunId'), value: currentExecution.value?.runnerKey || '-' },
 	{ label: t('pages.runWorkflow.metaStartedAt'), value: currentExecution.value?.startedAt || '-' },
-	{ label: t('pages.runWorkflow.metaFinishedAt'), value: currentExecution.value?.finishedAt || '-' },
+	{
+		label: t('pages.runWorkflow.metaFinishedAt'),
+		value: currentExecution.value?.finishedAt || '-'
+	},
 	{ label: t('pages.runWorkflow.metaDuration'), value: executionDurationText.value || '-' },
 	{ label: t('pages.runWorkflow.metaSteps'), value: `${traceNodes.value.length || 0}` }
 ])
@@ -308,76 +315,28 @@ const handleStopTriggerListening = () => {
 	runnerStore.stopRunner()
 }
 
-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] = t('pages.runWorkflow.invalidJson')
-					}
-					hasError = true
-					return
-				}
-			} else {
-				value = {}
-			}
-
-			if (!value || typeof value !== 'object' || Array.isArray(value)) {
-				if (!variable.is_hide) {
-					validationErrors[fieldName] = t('pages.runWorkflow.invalidJson')
-				}
-				hasError = true
-				return
-			}
-		}
-
-		if (!variable.is_hide && variable.is_require && isEmptyValue(value, variable.formType)) {
-			validationErrors[fieldName] = t('pages.runWorkflow.fieldRequired', {
-				name: 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] = t('pages.runWorkflow.fieldTooLong', {
-				name: variable.label || fieldName,
-				max: `${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(t('pages.runWorkflow.missingStartNode'))
 		return
 	}
 
-	const params = buildExecuteParams()
+	const params = buildExecuteParams({
+		startVariables: startVariables.value,
+		inputValues,
+		jsonDrafts,
+		validationErrors,
+		translateFieldRequired: (name) =>
+			t('pages.runWorkflow.fieldRequired', {
+				name
+			}),
+		translateInvalidJson: () => t('pages.runWorkflow.invalidJson'),
+		translateFieldTooLong: (name, max) =>
+			t('pages.runWorkflow.fieldTooLong', {
+				name,
+				max
+			})
+	})
 	if (!params) {
 		activeTab.value = 'input'
 		return
@@ -415,44 +374,6 @@ const handleRunWorkflow = async () => {
 	}
 }
 
-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 {
@@ -542,7 +463,14 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 
 <template>
 	<div class="runner">
-		<div class="drawer shadow-2xl" :class="{ 'drawer--open': props.visible }">
+		<ResizableDrawer
+			:visible="props.visible"
+			:default-width="520"
+			:min-width="520"
+			:max-width-ratio="0.82"
+			:z-index="1001"
+			class="drawer"
+		>
 			<header class="text-gray-800">
 				<div class="w-full flex items-center justify-between">
 					<h4 class="flex items-center">{{ t('pages.runWorkflow.drawerTitle') }}</h4>
@@ -569,7 +497,11 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							@run="handleRunWorkflow"
 						/>
 					</el-tab-pane>
-					<el-tab-pane v-if="showTriggerTab" :label="t('pages.runWorkflow.triggerTab')" name="trigger">
+					<el-tab-pane
+						v-if="showTriggerTab"
+						:label="t('pages.runWorkflow.triggerTab')"
+						name="trigger"
+					>
 						<TriggerTab
 							:node-type="activeNodeType"
 							:is-listening="isRunning"
@@ -620,7 +552,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 					</el-tab-pane>
 				</el-tabs>
 			</div>
-		</div>
+		</ResizableDrawer>
 	</div>
 </template>
 
@@ -628,25 +560,6 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 .runner {
 	z-index: 999;
 	font-size: 13px;
-	.drawer {
-		position: fixed;
-		top: 60px;
-		right: 5px;
-		bottom: 10px;
-		width: 420px;
-		background: var(--bg-base);
-		z-index: 1001;
-		border-radius: 8px;
-		display: flex;
-		flex-direction: column;
-		border: 1px solid var(--border-light);
-		transform: translateX(110%);
-		transition: transform 0.25s ease;
-	}
-
-	.drawer--open {
-		transform: translateX(0);
-	}
 
 	.drawer header {
 		height: 66px;

+ 135 - 0
apps/web/src/features/RunWorkflow/utils.ts

@@ -0,0 +1,135 @@
+import type { StartVariable } from '@/nodes/src/start'
+
+export const RUN_WORKFLOW_QUERY_VARIABLE = 'query'
+
+export 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 ''
+	}
+}
+
+export 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()
+}
+
+export function buildExecuteParams(options: {
+	startVariables: StartVariable[]
+	inputValues: Record<string, any>
+	jsonDrafts: Record<string, string>
+	validationErrors: Record<string, string>
+	validateVisibleRequired?: boolean
+	excludeNames?: string[]
+	translateFieldRequired: (name: string) => string
+	translateInvalidJson: () => string
+	translateFieldTooLong: (name: string, max: string) => string
+}) {
+	const {
+		startVariables,
+		inputValues,
+		jsonDrafts,
+		validationErrors,
+		validateVisibleRequired = true,
+		excludeNames = [],
+		translateFieldRequired,
+		translateInvalidJson,
+		translateFieldTooLong
+	} = options
+
+	Object.keys(validationErrors).forEach((key) => {
+		delete validationErrors[key]
+	})
+
+	const excluded = new Set(excludeNames)
+	const params: Record<string, any> = {}
+	let hasError = false
+
+	startVariables.forEach((variable) => {
+		const fieldName = variable.name
+		if (!fieldName || excluded.has(fieldName)) return
+
+		let value = structuredClone(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] = translateInvalidJson()
+					}
+					hasError = true
+					return
+				}
+			} else {
+				value = {}
+			}
+
+			if (!value || typeof value !== 'object' || Array.isArray(value)) {
+				if (!variable.is_hide) {
+					validationErrors[fieldName] = translateInvalidJson()
+				}
+				hasError = true
+				return
+			}
+		}
+
+		if (
+			validateVisibleRequired &&
+			!variable.is_hide &&
+			variable.is_require &&
+			isEmptyValue(value, variable.formType)
+		) {
+			validationErrors[fieldName] = translateFieldRequired(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] = translateFieldTooLong(
+				variable.label || fieldName,
+				`${variable.max_length}`
+			)
+			hasError = true
+			return
+		}
+
+		params[fieldName] = value
+	})
+
+	return hasError ? null : params
+}

+ 9 - 110
apps/web/src/features/setter/index.vue

@@ -1,11 +1,11 @@
 <script lang="ts" setup>
-import { computed, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue'
+import { computed, provide, ref, watch } from 'vue'
 import { Icon, Input } from '@repo/ui'
 import { useDebounceFn } from '@vueuse/core'
 import { agent } from '@repo/api-service'
 
-import Chat from './Chat.vue'
 import NodeLog from './NodeLog.vue'
+import ResizableDrawer from '@/components/ResizableDrawer/index.vue'
 import { nodeMap } from '@/nodes'
 import { useI18n } from '@/composables/useI18n'
 
@@ -49,10 +49,6 @@ const nodeInfo = computed(() => {
 		: undefined
 })
 
-const isLlmNode = computed(
-	() => node.value?.nodeType === 'llm' || node.value?.data?.nodeType === 'llm'
-)
-
 const isImageIcon = computed(() => {
 	return !!nodeInfo.value?.icon && nodeInfo.value.icon.startsWith('data:image/')
 })
@@ -76,51 +72,6 @@ const remark = ref(node.value?.remark || '')
 const nodeVars = ref<NodeVar[]>([])
 const currentTab = ref<'setting' | 'last-run'>(props.activeTab || 'setting')
 const MIN_DRAWER_WIDTH = 470
-const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
-const drawerWidth = ref(MIN_DRAWER_WIDTH)
-const resizeState = reactive({
-	startX: 0,
-	startWidth: MIN_DRAWER_WIDTH,
-	isDragging: false
-})
-
-const maxDrawerWidth = computed(() => {
-	return Math.max(MIN_DRAWER_WIDTH, Math.floor(viewportWidth.value * 0.6))
-})
-
-const clampDrawerWidth = (width: number) => {
-	return Math.min(Math.max(width, MIN_DRAWER_WIDTH), maxDrawerWidth.value)
-}
-
-const syncViewportWidth = () => {
-	viewportWidth.value = window.innerWidth
-	drawerWidth.value = clampDrawerWidth(drawerWidth.value)
-}
-
-const stopResize = () => {
-	if (!resizeState.isDragging) return
-	resizeState.isDragging = false
-	document.body.style.userSelect = ''
-	document.body.style.cursor = ''
-	window.removeEventListener('mousemove', onResize)
-	window.removeEventListener('mouseup', stopResize)
-}
-
-const onResize = (event: MouseEvent) => {
-	if (!resizeState.isDragging) return
-	const nextWidth = resizeState.startWidth + (resizeState.startX - event.clientX)
-	drawerWidth.value = clampDrawerWidth(nextWidth)
-}
-
-const onResizeStart = (event: MouseEvent) => {
-	resizeState.startX = event.clientX
-	resizeState.startWidth = drawerWidth.value
-	resizeState.isDragging = true
-	document.body.style.userSelect = 'none'
-	document.body.style.cursor = 'ew-resize'
-	window.addEventListener('mousemove', onResize)
-	window.addEventListener('mouseup', stopResize)
-}
 
 const onUpdateName = () => {
 	if (name.value !== node.value?.name && name.value.trim() !== '') {
@@ -156,31 +107,18 @@ watch(
 	}
 )
 
-onMounted(() => {
-	window.addEventListener('resize', syncViewportWidth)
-	syncViewportWidth()
-})
-
-onBeforeUnmount(() => {
-	stopResize()
-	window.removeEventListener('resize', syncViewportWidth)
-})
-
 provide('nodeVars', nodeVars)
 </script>
 
 <template>
 	<div class="setter">
-		<div
-			class="drawer shadow-2xl"
-			:class="{
-				'drawer--open': props.visible && setter,
-				'drawer--resizing': resizeState.isDragging
-			}"
-			:style="{ width: `${drawerWidth}px`, maxWidth: '60vw' }"
+		<ResizableDrawer
+			:visible="props.visible && !!setter"
+			:min-width="MIN_DRAWER_WIDTH"
+			:max-width-ratio="0.6"
+			:z-index="1000"
+			class="drawer"
 		>
-			<!-- Resize handle -->
-			<div class="resize-handle" @mousedown.prevent="onResizeStart"></div>
 			<header class="text-gray-800">
 				<div class="w-full flex items-center justify-between">
 					<h4 class="flex items-center">
@@ -251,12 +189,9 @@ provide('nodeVars', nodeVars)
 							<NodeLog :node="node" :active="props.visible && currentTab === 'last-run'" />
 						</div>
 					</el-tab-pane>
-					<el-tab-pane v-if="isLlmNode" label="AI对话" name="chat">
-						<Chat :node="node" :workflow-id="props.workflow.id" />
-					</el-tab-pane>
 				</el-tabs>
 			</div>
-		</div>
+		</ResizableDrawer>
 	</div>
 </template>
 
@@ -264,42 +199,6 @@ provide('nodeVars', nodeVars)
 .setter {
 	z-index: 998;
 
-	.drawer {
-		position: fixed;
-		top: 60px;
-		right: 5px;
-		bottom: 10px;
-		min-width: 420px;
-		background: var(--bg-base);
-		z-index: 1000;
-		border-radius: 8px;
-		display: flex;
-		flex-direction: column;
-		border: 1px solid var(--border-light);
-		transform: translateX(110%);
-		transition: transform 0.25s ease;
-
-		&.drawer--resizing {
-			transition: none;
-		}
-
-		.resize-handle {
-			position: absolute;
-			left: -4px;
-			top: 50%;
-			transform: translateY(-50%);
-			width: 3px;
-			height: 32px;
-			background-color: var(--border-base);
-			border-radius: 8px;
-			cursor: ew-resize;
-		}
-	}
-
-	.drawer--open {
-		transform: translateX(0);
-	}
-
 	.drawer header {
 		height: 66px;
 		padding: 16px;

+ 23 - 2
apps/web/src/features/toolbar/index.vue

@@ -17,6 +17,10 @@
 			/>
 		</el-tooltip>
 
+		<el-tooltip :content="t('pages.toolbar.env')" placement="left">
+			<IconButton icon="eos-icons:env" icon-color="#666" square @click="showEnvDialog = true" />
+		</el-tooltip>
+
 		<el-popover
 			ref="popoverRef"
 			width="240px"
@@ -47,8 +51,13 @@
 			</template>
 		</el-popover>
 
-		<el-tooltip :content="t('pages.toolbar.env')" placement="left">
-			<IconButton icon="eos-icons:env" icon-color="#666" square @click="showEnvDialog = true" />
+		<el-tooltip :content="t('pages.toolbar.chat')" placement="left">
+			<IconButton
+				icon="lucide:message-circle"
+				:class="{ 'is-disabled': !props.canChat }"
+				square
+				@click="handleChatClick"
+			/>
 		</el-tooltip>
 
 		<AgentEnvDialog v-model="showEnvDialog" @change="handleEnvChange" :value="envVars" />
@@ -76,6 +85,7 @@ const props = defineProps<{
 		name: string
 		nodeType: string
 	}[]
+	canChat?: boolean
 }>()
 
 const emit = defineEmits<{
@@ -88,6 +98,7 @@ const emit = defineEmits<{
 		}[]
 	): void
 	(e: 'run', id?: string): void
+	(e: 'chat'): void
 	(e: 'create:node', value: { type: string } | string): void
 }>()
 
@@ -111,6 +122,11 @@ function handleRunClick() {
 	}
 }
 
+function handleChatClick() {
+	if (!props.canChat) return
+	emit('chat')
+}
+
 function handleSelectRunNode(id: string) {
 	popoverRef.value?.hide()
 	emit('run', id)
@@ -159,4 +175,9 @@ function handleSelectRunNode(id: string) {
 	font-size: 12px;
 	color: var(--text-tertiary);
 }
+
+:deep(.is-disabled) {
+	opacity: 0.45;
+	cursor: not-allowed;
+}
 </style>

+ 12 - 1
apps/web/src/i18n/locales/en-us.ts

@@ -1302,6 +1302,7 @@ export default {
 		toolbar: {
 			nodes: 'Nodes',
 			note: 'Note',
+			chat: 'Chat',
 			env: 'Environment Variables',
 			runEntry: 'Choose run entry',
 			envDialog: {
@@ -1369,17 +1370,27 @@ export default {
 			error: 'Error',
 			suspended: 'Suspended',
 			drawerTitle: 'Run Workflow',
+			chatDialogTitle: 'Chat Run',
+			chatTab: 'Chat',
 			inputTab: 'Input',
 			triggerTab: 'Trigger',
 			resultTab: 'Result',
 			detailTab: 'Details',
 			traceTab: 'Trace',
+			chatPanel: {
+				workflow: 'Workflow',
+				nodeRuns: 'Node Runs',
+				nodeCount: '{count} nodes',
+				dataProcess: 'Data Processing',
+				nodeRunEmpty: 'Node execution progress will appear here after you send a message.'
+			},
 			inputPanel: {
 				noStart: 'No start node was found, so this workflow cannot run.',
 				noInputs: 'The start node has no configured user inputs, so you can run it directly.',
 				enter: 'Please enter ',
 				select: 'Please select ',
-				run: 'Run Workflow'
+				run: 'Run Workflow',
+				completeRequired: 'Please complete the user input form first'
 			},
 			triggerPanel: {
 				listening: 'Listening for trigger events...',

+ 12 - 1
apps/web/src/i18n/locales/zh-cn.ts

@@ -1207,6 +1207,7 @@ export default {
 		toolbar: {
 			nodes: '节点',
 			note: '注释',
+			chat: '对话',
 			env: '环境变量',
 			runEntry: '选择运行入口',
 			envDialog: {
@@ -1273,17 +1274,27 @@ export default {
 			error: '异常',
 			suspended: '挂起',
 			drawerTitle: '运行工作流',
+			chatDialogTitle: '对话运行',
+			chatTab: '对话',
 			inputTab: '输入',
 			triggerTab: '触发',
 			resultTab: '结果',
 			detailTab: '详情',
 			traceTab: '追踪',
+			chatPanel: {
+				workflow: '工作流',
+				nodeRuns: '节点运行',
+				nodeCount: '共 {count} 个节点',
+				dataProcess: '数据处理',
+				nodeRunEmpty: '发起对话后,这里会实时显示节点执行过程。'
+			},
 			inputPanel: {
 				noStart: '缺少开始节点,当前工作流无法运行。',
 				noInputs: '当前开始节点没有配置用户输入项,可以直接运行。',
 				enter: '请输入',
 				select: '请选择',
-				run: '开始运行'
+				run: '开始运行',
+				completeRequired: '请先补全用户输入表单'
 			},
 			triggerPanel: {
 				listening: '正在监听触发器事件...',

+ 143 - 1
apps/web/src/views/editor/NodeView.vue

@@ -31,8 +31,10 @@
 			<Toolbar
 				@create:node="handleNodeCreate"
 				@run="handleRunAgent"
+				@chat="handleOpenWorkflowChat"
 				:env-vars="workflow?.env_variables || []"
 				:run-nodes="toolbarRunNodes"
+				:can-chat="canRunWorkflowChat"
 				@change-env-vars="handleChangeEnvVars"
 			/>
 		</Workflow>
@@ -46,6 +48,20 @@
 		:initial-tab="runWorkflowInitialTab"
 		@run-started="handleWorkflowRunStarted"
 	/>
+	<ChatDrawer
+		v-model:visible="workflowChatVisible"
+		:workflow="workflow"
+		:start-node="workflowChatStartNode"
+		:visible-variables="workflowChatVisibleVariables"
+		:input-values="workflowChatInputValues"
+		:json-drafts="workflowChatJsonDrafts"
+		:validation-errors="workflowChatValidationErrors"
+		:base-params="workflowChatBaseParams"
+		:is-running="workflowChatRunning"
+		@validate-send="handleWorkflowChatValidateSend"
+		@run-started="handleWorkflowChatRunStarted"
+		@cancel="handleWorkflowChatCancel"
+	/>
 	<Setter
 		:id="nodeID"
 		:workflow="workflow"
@@ -106,8 +122,10 @@
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { agent } from '@repo/api-service'
+import { cloneDeep, isEqual } from 'lodash-es'
 
 import RunWorkflow from '@/features/RunWorkflow/index.vue'
+import ChatDrawer from '@/features/ChatDrawer/index.vue'
 import NodeLibary from '@/features/nodeLibary/index.vue'
 import Toolbar from '@/features/toolbar/index.vue'
 import Setter from '@/features/setter/index.vue'
@@ -115,9 +133,9 @@ import { nodeMap } from '@/nodes'
 import { getNodeDisplayName } from '@/nodes/i18n'
 import { useI18n } from '@/composables/useI18n'
 import { useRunnerStore } from '@/store/modules/runner.store'
+import { buildExecuteParams, createEmptyValue } from '@/features/RunWorkflow/utils'
 import { Workflow, useDragAndDrop } from '@repo/workflow'
 import { useDebounceFn } from '@vueuse/core'
-import { isEqual } from 'lodash-es'
 import { Icon } from '@repo/ui'
 
 import type {
@@ -128,6 +146,7 @@ import type {
 	IWorkflowNode,
 	XYPosition
 } from '@repo/workflow'
+import type { StartVariable } from '@/nodes/src/start'
 
 interface Props {
 	workflow: IWorkflow
@@ -175,6 +194,11 @@ const closeRunWorkflowOnSubmit = ref(false)
 const runWorkflowInputOnly = ref(false)
 const runWorkflowNodeId = ref('')
 const runWorkflowInitialTab = ref<'input' | 'trigger' | 'result' | 'detail' | 'trace'>('input')
+const workflowChatVisible = ref(false)
+const workflowChatInputValues = ref<Record<string, any>>({})
+const workflowChatJsonDrafts = ref<Record<string, string>>({})
+const workflowChatValidationErrors = ref<Record<string, string>>({})
+const workflowChatBaseParams = ref<Record<string, any>>({})
 const setterVisible = ref(false)
 const nodeID = ref('')
 const setterActiveTab = ref<'setting' | 'last-run'>('setting')
@@ -387,6 +411,34 @@ const getNodeTypeById = (id?: string) => {
 	return (node as any)?.nodeType || (node as any)?.data?.nodeType || ''
 }
 
+const getStartVariables = (node?: IWorkflowNode | null): StartVariable[] => {
+	const variables = (node as any)?.data?.variables
+	return Array.isArray(variables) ? variables : []
+}
+
+const workflowChatStartNode = computed<IWorkflowNode | null>(() => {
+	return (
+		(props.workflow?.nodes || []).find((node) => {
+			const nodeType = (node as any)?.nodeType || (node as any)?.data?.nodeType
+			return nodeType === 'start'
+		}) || null
+	)
+})
+
+const workflowChatStartVariables = computed<StartVariable[]>(() =>
+	getStartVariables(workflowChatStartNode.value)
+)
+
+const workflowChatVisibleVariables = computed(() =>
+	workflowChatStartVariables.value.filter((item) => !item.is_hide)
+)
+
+const canRunWorkflowChat = computed(() => !!workflowChatStartNode.value)
+
+const workflowChatRunning = computed(
+	() => runnerStore.status === 'connecting' || runnerStore.status === 'running'
+)
+
 const nodeLibaryParentType = computed(() => getNodeTypeById(peddingHandlePayload.value?.parentId))
 
 const toolbarRunNodes = computed(() => {
@@ -611,6 +663,96 @@ const handleRunAgent = (id?: string) => {
 	handleRunNode(targetNode.id)
 }
 
+function formatJsonDraft(value: unknown, fallback = '{}') {
+	if (value === undefined || value === null || value === '') return fallback
+	try {
+		return JSON.stringify(value, null, 2)
+	} catch {
+		return fallback
+	}
+}
+
+function resetWorkflowChatValidation() {
+	workflowChatValidationErrors.value = {}
+}
+
+function initializeWorkflowChatInputValues() {
+	const values: Record<string, any> = {}
+	const drafts: Record<string, string> = {}
+
+	workflowChatStartVariables.value.forEach((variable) => {
+		const initialValue = cloneDeep(
+			variable.default_value !== undefined
+				? variable.default_value
+				: createEmptyValue(variable.formType)
+		)
+
+		if (variable.formType === 'json_object') {
+			values[variable.name] =
+				initialValue && typeof initialValue === 'object' && !Array.isArray(initialValue)
+					? initialValue
+					: {}
+			drafts[variable.name] = formatJsonDraft(values[variable.name], '{}')
+			return
+		}
+
+		values[variable.name] = initialValue
+	})
+
+	workflowChatInputValues.value = values
+	workflowChatJsonDrafts.value = drafts
+}
+
+const buildWorkflowChatParams = () => {
+	const params = buildExecuteParams({
+		startVariables: workflowChatStartVariables.value,
+		inputValues: workflowChatInputValues.value,
+		jsonDrafts: workflowChatJsonDrafts.value,
+		validationErrors: workflowChatValidationErrors.value,
+		translateFieldRequired: (name) =>
+			t('pages.runWorkflow.fieldRequired', {
+				name
+			}),
+		translateInvalidJson: () => t('pages.runWorkflow.invalidJson'),
+		translateFieldTooLong: (name, max) =>
+			t('pages.runWorkflow.fieldTooLong', {
+				name,
+				max
+			})
+	})
+
+	if (!params) {
+		ElMessage.warning(t('pages.runWorkflow.inputPanel.completeRequired'))
+		return false
+	}
+
+	return params
+}
+
+const handleOpenWorkflowChat = () => {
+	if (!workflowChatStartNode.value) {
+		ElMessage.warning(t('pages.nodeView.messages.missingTrigger'))
+		return
+	}
+
+	initializeWorkflowChatInputValues()
+	resetWorkflowChatValidation()
+	workflowChatBaseParams.value = {}
+	workflowChatVisible.value = true
+}
+
+const handleWorkflowChatValidateSend = (done: (params: Record<string, any> | false) => void) => {
+	done(buildWorkflowChatParams())
+}
+
+const handleWorkflowChatRunStarted = (id: string) => {
+	runWorkflowNodeId.value = id
+}
+
+const handleWorkflowChatCancel = () => {
+	runnerStore.stopRunner()
+}
+
 const createStickyNoteNode = (position: XYPosition = { x: 600, y: 300 }) => {
 	props.workflow?.nodes.push({
 		appAgentId: props.workflow.id,

+ 9 - 0
packages/api-service/schema/agent.openapi.json

@@ -4566,6 +4566,15 @@
 									"params": {
 										"type": "object",
 										"properties": {}
+									},
+									"query": {
+										"type": "string"
+									},
+									"files": {
+										"type": "array",
+										"items": {
+											"type": "string"
+										}
 									}
 								},
 								"required": ["appAgentId", "start_node_id", "is_debugger", "responseType", "params"]

+ 2 - 0
packages/api-service/servers/api/agent.ts

@@ -100,6 +100,8 @@ export async function postAgentDoExecute(
     is_debugger: boolean
     responseType: string
     params: Record<string, any>
+    query?: string
+    files?: string[]
   },
   options?: { [key: string]: any }
 ) {