Kaynağa Gözat

feat: 新增知识检索,优化功能

jiaxing.liao 2 hafta önce
ebeveyn
işleme
a92f95b465

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

@@ -78,6 +78,7 @@ declare module 'vue' {
     ElTree: typeof import('element-plus/es')['ElTree']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
+    FMarkdown: typeof import('./src/components/Chat/FMarkdown.vue')['default']
     MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
     MessageList: typeof import('./src/components/Chat/MessageList.vue')['default']
     RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
@@ -162,6 +163,7 @@ declare global {
   const ElTree: typeof import('element-plus/es')['ElTree']
   const ElUpload: typeof import('element-plus/es')['ElUpload']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
+  const FMarkdown: typeof import('./src/components/Chat/FMarkdown.vue')['default']
   const MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
   const MessageList: typeof import('./src/components/Chat/MessageList.vue')['default']
   const RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']

+ 14 - 0
apps/web/src/components/Chat/ChatInput.vue

@@ -3,6 +3,7 @@
 		<Sender
 			v-model="modelValue"
 			variant="updown"
+			ref="senderRef"
 			submitType="cmdOrCtrlEnter"
 			:auto-size="{ minRows: 2, maxRows: 5 }"
 			clearable
@@ -12,6 +13,11 @@
 			:loading="loading"
 			@cancel="emit('cancel')"
 		>
+			<!-- 头部插槽 -->
+			<template #header>
+				<slot name="header" />
+			</template>
+
 			<!-- 插槽内容 -->
 			<template #prefix>
 				<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
@@ -19,8 +25,10 @@
 				</div>
 			</template>
 
+			<!-- 动作插槽 -->
 			<template #action-list>
 				<div style="display: flex; align-items: center; gap: 8px">
+					<slot name="action" />
 					<el-button round color="#626aef" @click="emit('submit', modelValue)">
 						<el-icon>
 							<Promotion />
@@ -59,6 +67,8 @@ const { t } = useI18n()
 
 const modelValue = defineModel<string>()
 
+const senderRef = ref<InstanceType<typeof Sender>>()
+
 const props = withDefaults(
 	defineProps<{
 		loading: boolean
@@ -77,6 +87,10 @@ const emit = defineEmits<{
 const getImageSrc = (file: WorkflowUploadFile) => `/File/GetImage?fileId=${file.path || file.id}`
 
 const attachments = computed(() => props.attachments)
+
+defineExpose({
+	getInstance: () => senderRef.value
+})
 </script>
 
 <style lang="less" scoped>

+ 137 - 41
apps/web/src/components/Chat/MessageList.vue

@@ -1,9 +1,20 @@
 <template>
 	<div class="chat-content">
 		<div v-if="messages.length > 0" ref="scrollContainerRef" class="item-list">
-			<Bubble v-for="item in messages" :key="item.id || item.key" :content="item.content" :placement="item.placement"
-				:loading="item.loading" :shape="item.shape" :variant="item.variant" :is-markdown="item.isMarkdown"
-				:is-fog="item.isFog" :typing="item.typing" class="message-bubble" maxWidth="800px">
+			<Bubble
+				v-for="item in messages"
+				:key="item.id || item.key"
+				:content="item.content"
+				:placement="item.placement"
+				:loading="item.loading"
+				:shape="item.shape"
+				:variant="item.variant"
+				:is-markdown="item.isMarkdown"
+				:is-fog="item.isFog"
+				:typing="item.typing"
+				class="message-bubble"
+				maxWidth="800px"
+			>
 				<template #avatar>
 					<div class="avatar-wrapper">
 						<Icon v-if="item.role === 'ai'" icon="mingcute:ai-line" :width="22" />
@@ -13,36 +24,50 @@
 
 				<template #header>
 					<div class="header-wrapper">
-						<div class="header-name">
-						</div>
+						<div class="header-name"></div>
 					</div>
 				</template>
 
 				<template #content>
 					<div class="msg-content-wrapper">
 						<div v-if="item.role === 'ai' && hasStructuredBlocks(item)" class="structured-blocks">
-							<Thinking v-if="shouldShowThinking(item)" :content="getThinkingText(item)"
-								:status="getThinkingStatus(item)" :model-value="!item.streamCompleted" auto-collapse max-width="100%"
-								background-color="var(--bg-container)" color="var(--text-primary)" />
+							<Thinking
+								v-if="shouldShowThinking(item)"
+								:content="getThinkingText(item)"
+								:status="getThinkingStatus(item)"
+								:model-value="isThinkingOpen(item)"
+								auto-collapse
+								max-width="100%"
+								background-color="var(--bg-container)"
+								color="var(--text-primary)"
+							/>
 
 							<div v-if="getToolCalls(item).length" class="structured-block">
 								<div class="structured-block__head">
 									<span class="structured-block__title">{{ t('pages.chat.toolCallTitle') }}</span>
-									<el-tag size="small" type="warning" effect="plain">{{ getToolCalls(item).length }}</el-tag>
+									<el-tag size="small" type="warning" effect="plain">{{
+										getToolCalls(item).length
+									}}</el-tag>
 								</div>
 								<div class="tool-list">
-									<div v-for="(toolCall, index) in getToolCalls(item)"
-										:key="`${item.id || item.key}-tool-call-${index}`" class="tool-card">
+									<div
+										v-for="(toolCall, index) in getToolCalls(item)"
+										:key="`${item.id || item.key}-tool-call-${index}`"
+										class="tool-card"
+									>
 										<div class="tool-card__head">
 											<div class="tool-card__meta">
 												<el-tag size="small" type="warning" effect="dark">
 													{{ getToolName(toolCall.toolName) }}
 												</el-tag>
-												<span v-if="toolCall.toolCallId" class="tool-card__id">{{ toolCall.toolCallId }}</span>
+												<span v-if="toolCall.toolCallId" class="tool-card__id">{{
+													toolCall.toolCallId
+												}}</span>
 											</div>
 										</div>
-										<pre
-											class="tool-card__content">{{ formatStructuredValue(toolCall.arguments || toolCall.content) }}</pre>
+										<pre class="tool-card__content">{{
+											formatStructuredValue(toolCall.arguments || toolCall.content)
+										}}</pre>
 									</div>
 								</div>
 							</div>
@@ -50,18 +75,29 @@
 							<div v-if="getToolResults(item).length" class="structured-block">
 								<div class="structured-block__head">
 									<span class="structured-block__title">{{ t('pages.chat.toolResultTitle') }}</span>
-									<el-tag size="small" type="success" effect="plain">{{ getToolResults(item).length }}</el-tag>
+									<el-tag size="small" type="success" effect="plain">{{
+										getToolResults(item).length
+									}}</el-tag>
 								</div>
 								<div class="tool-list">
-									<div v-for="(toolResult, index) in getToolResults(item)"
-										:key="`${item.id || item.key}-tool-result-${index}`" class="tool-card">
+									<div
+										v-for="(toolResult, index) in getToolResults(item)"
+										:key="`${item.id || item.key}-tool-result-${index}`"
+										class="tool-card"
+									>
 										<div class="tool-card__head">
 											<div class="tool-card__meta">
-												<el-tag :type="toolResult.success === false ? 'danger' : 'success'" size="small" effect="dark">
+												<el-tag
+													:type="toolResult.success === false ? 'danger' : 'success'"
+													size="small"
+													effect="dark"
+												>
 													{{ toolResult.success === false ? t('pages.chat.failedTag') : 'OK' }}
 												</el-tag>
 												<span class="tool-card__name">{{ getToolName(toolResult.toolName) }}</span>
-												<span v-if="toolResult.toolCallId" class="tool-card__id">{{ toolResult.toolCallId }}</span>
+												<span v-if="toolResult.toolCallId" class="tool-card__id">{{
+													toolResult.toolCallId
+												}}</span>
 											</div>
 											<div v-if="toolResult.durationMs !== undefined" class="tool-card__duration">
 												{{ toolResult.durationMs }}ms
@@ -69,39 +105,72 @@
 										</div>
 										<div v-if="hasToolResultThought(toolResult)" class="tool-card__section">
 											<div class="tool-card__label">{{ t('pages.chat.thinkTitle') }}</div>
-											<pre class="tool-card__content">{{ getToolResultThought(toolResult) }}</pre>
+											<pre
+												class="tool-card__content"
+											><XMarkdown :markdown="getToolResultThought(toolResult)"/></pre>
 										</div>
 										<div v-if="hasToolResultOutput(toolResult)" class="tool-card__section">
 											<div class="tool-card__label">{{ t('pages.chat.toolResultTitle') }}</div>
-											<pre class="tool-card__content">{{ getToolResultOutput(toolResult) }}</pre>
+											<pre class="tool-card__content">
+												<XMarkdown :markdown="getToolResultOutput(toolResult)"/>
+											</pre>
 										</div>
 										<div v-if="hasToolResultItems(toolResult)" class="tool-card__section">
 											<div class="tool-card__label">content_items</div>
-											<pre class="tool-card__content">{{ formatStructuredValue(getToolResultItems(toolResult)) }}</pre>
+											<pre class="tool-card__content">
+												<XMarkdown :markdown="formatStructuredValue(getToolResultItems(toolResult))"/>
+											</pre>
 										</div>
 										<div v-if="toolResult.error" class="tool-card__section">
 											<div class="tool-card__label">{{ t('pages.chat.failedTag') }}</div>
-											<pre class="tool-card__content tool-card__content--error">{{ toolResult.error }}</pre>
+											<pre class="tool-card__content tool-card__content--error">{{
+												toolResult.error
+											}}</pre>
 										</div>
 									</div>
 								</div>
 							</div>
 						</div>
 
-						<XMarkdown v-if="item.content" class="msg-content-text" :markdown="item.content" enableLatex enableBreaks />
+						<XMarkdown
+							v-if="getDisplayContent(item)"
+							class="msg-content-text"
+							:markdown="getDisplayContent(item)"
+							enableLatex
+							enableBreaks
+						/>
 					</div>
 				</template>
 
 				<template #footer>
 					<div v-if="getMessageImages(item).length" class="file-wrap">
-						<el-image v-for="(src, index) in getMessageImages(item)" :key="`${item.id || item.key}-file-${index}`"
-							:src="src" class="file-image" fit="cover" preview-teleported hide-on-click-modal
-							:preview-src-list="getMessageImages(item)" />
+						<el-image
+							v-for="(src, index) in getMessageImages(item)"
+							:key="`${item.id || item.key}-file-${index}`"
+							:src="src"
+							class="file-image"
+							fit="cover"
+							preview-teleported
+							hide-on-click-modal
+							:preview-src-list="getMessageImages(item)"
+						/>
 					</div>
 					<div class="footer-wrapper" v-if="item.role === 'ai' && item.streamCompleted">
 						<div class="footer-container">
-							<el-button type="info" :icon="Refresh" size="small" circle @click="handleRetry(item)" />
-							<el-button color="#626aef" :icon="DocumentCopy" size="small" circle @click="handleCopy(item)" />
+							<el-button
+								type="info"
+								:icon="Refresh"
+								size="small"
+								circle
+								@click="handleRetry(item)"
+							/>
+							<el-button
+								color="#626aef"
+								:icon="DocumentCopy"
+								size="small"
+								circle
+								@click="handleCopy(item)"
+							/>
 						</div>
 						<div class="footer-time">
 							{{ item.updateTime }}
@@ -117,6 +186,7 @@
 			</div>
 			<div class="empty-text">{{ t('pages.chat.emptyTitle') }}</div>
 			<div class="empty-subtext">{{ t('pages.chat.emptySubtitle') }}</div>
+			<slot />
 		</div>
 	</div>
 </template>
@@ -127,7 +197,7 @@ import { Bubble, Thinking, XMarkdown } from 'vue-element-plus-x'
 import { useI18n } from '@/composables/useI18n'
 import { ElMessage } from 'element-plus'
 import { DocumentCopy, Refresh } from '@element-plus/icons-vue'
-import { Icon } from "@repo/ui"
+import { Icon } from '@repo/ui'
 import type { BubbleMessage, ChatToolResult } from '../../views/chat/types'
 
 const { t } = useI18n()
@@ -147,10 +217,38 @@ const getMessageImages = (message: BubbleMessage) => {
 	return ids.filter(Boolean).map((fileId) => `/File/GetImage?fileId=${fileId}`)
 }
 
-const getThinkingText = (message: BubbleMessage) => `${message.thinking || ''}`.trim()
+const THINK_TAG_RE = /<think\b[^>]*>([\s\S]*?)(?:<\/think>|$)/gi
+
+const parseThinkContent = (content?: string) => {
+	const text = `${content || ''}`
+	const thinkingParts: string[] = []
+	const displayContent = text
+		.replace(THINK_TAG_RE, (_match, thinkingText) => {
+			const trimmed = `${thinkingText || ''}`.trim()
+			if (trimmed) thinkingParts.push(trimmed)
+			return ''
+		})
+		.trim()
+
+	return {
+		displayContent,
+		thinkingText: thinkingParts.join('\n\n')
+	}
+}
+
+const getEmbeddedThinkingText = (message: BubbleMessage) => parseThinkContent(message.content).thinkingText
+
+const getDisplayContent = (message: BubbleMessage) => parseThinkContent(message.content).displayContent
+
+const getThinkingText = (message: BubbleMessage) => {
+	const parts = [`${message.thinking || ''}`.trim(), getEmbeddedThinkingText(message)].filter(Boolean)
+	return parts.join('\n\n')
+}
 
 const shouldShowThinking = (message: BubbleMessage) => !!getThinkingText(message)
 
+const isThinkingOpen = (message: BubbleMessage) => message.thinkingOpen ?? !message.streamCompleted
+
 const getThinkingStatus = (message: BubbleMessage) => {
 	if (message.error) return 'error'
 	return message.streamCompleted ? 'end' : 'thinking'
@@ -173,16 +271,13 @@ const hasToolResultOutput = (toolResult: ChatToolResult) => {
 	return output !== null && output !== undefined && `${output}`.trim() !== ''
 }
 
-const getToolResultItems = (toolResult: ChatToolResult) => Array.isArray(toolResult.contentItems) ? toolResult.contentItems : []
+const getToolResultItems = (toolResult: ChatToolResult) =>
+	Array.isArray(toolResult.contentItems) ? toolResult.contentItems : []
 
 const hasToolResultItems = (toolResult: ChatToolResult) => getToolResultItems(toolResult).length > 0
 
 const hasStructuredBlocks = (message: BubbleMessage) =>
-	!!(
-		getThinkingText(message) ||
-		getToolCalls(message).length ||
-		getToolResults(message).length
-	)
+	!!(getThinkingText(message) || getToolCalls(message).length || getToolResults(message).length)
 
 const formatStructuredValue = (value: any) => {
 	if (value === null || value === undefined || value === '') return '-'
@@ -194,7 +289,6 @@ const formatStructuredValue = (value: any) => {
 	}
 }
 
-
 const scrollContainerRef = ref<HTMLDivElement>()
 
 defineExpose({
@@ -219,6 +313,9 @@ const handleCopy = (message: BubbleMessage) => {
 </script>
 
 <style lang="less" scoped>
+:deep(.el-thinking) {
+	margin: 0;
+}
 .chat-content {
 	flex: 1;
 	display: flex;
@@ -232,7 +329,7 @@ const handleCopy = (message: BubbleMessage) => {
 		padding-right: 4px;
 	}
 
-	.message-bubble+.message-bubble {
+	.message-bubble + .message-bubble {
 		margin-top: 16px;
 	}
 
@@ -400,7 +497,7 @@ const handleCopy = (message: BubbleMessage) => {
 	word-break: break-all;
 }
 
-.tool-card__section+.tool-card__section {
+.tool-card__section + .tool-card__section {
 	margin-top: 8px;
 }
 
@@ -477,7 +574,6 @@ const handleCopy = (message: BubbleMessage) => {
 }
 
 @keyframes bounce {
-
 	0%,
 	100% {
 		transform: translateY(5px);

+ 7 - 4
apps/web/src/features/fileUpload/FileUploadInput.vue

@@ -175,7 +175,7 @@ const props = withDefaults(defineProps<Props>(), {
 	multiple: false,
 	fileTypes: () => [],
 	fileExtensions: () => [],
-	allowLinkInput: true
+	allowLinkInput: false
 })
 
 const emit = defineEmits<Emits>()
@@ -221,7 +221,8 @@ const normalizeUploadFile = (value: Partial<WorkflowUploadFile>) => {
 		name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: Number(value.size || 0),
-		path
+		path,
+		contentType: value.contentType || ''
 	} satisfies WorkflowUploadFile
 }
 
@@ -469,7 +470,8 @@ const buildFileFromUploadedFile = (file: File, fileId: string): WorkflowUploadFi
 		name: file.name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: file.size,
-		path: fileId
+		path: fileId,
+		contentType: file.type
 	}
 }
 
@@ -483,7 +485,8 @@ const buildFileFromLink = (value: string): WorkflowUploadFile => {
 		name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: 0,
-		path: value
+		path: value,
+		contentType: ''
 	}
 }
 

+ 1 - 0
apps/web/src/features/fileUpload/shared.ts

@@ -6,6 +6,7 @@ export interface WorkflowUploadFile {
 	extensionName: string
 	size: number
 	path: string
+	contentType: string
 }
 
 // 文件类型分组和后缀白名单集中维护,start 节点配置和上传组件共用这一份定义。

+ 449 - 0
apps/web/src/features/setter/Chat.vue

@@ -0,0 +1,449 @@
+<template>
+	<div class="setter-chat">
+		<MessageList ref="messageListRef" :messages="messages" :loading="isRunning" @retry="handleRetry" />
+
+		<div class="setter-chat__footer">
+			<el-dialog
+				v-model="uploadDialogVisible"
+				title="上传文件"
+				width="560px"
+				append-to-body
+				:close-on-click-modal="false"
+			>
+				<FileUploadInput
+					v-model="attachments"
+					:multiple="true"
+					:allow-link-input="false"
+					tip="上传后会作为本次 AI 对话的 files 参数。"
+				/>
+				<template #footer>
+					<el-button @click="uploadDialogVisible = false">关闭</el-button>
+					<el-button type="primary" @click="uploadDialogVisible = false">完成</el-button>
+				</template>
+			</el-dialog>
+
+			<ChatInput
+				v-model="senderValue"
+				:loading="isRunning"
+				:attachments="attachments"
+				@submit="handleSend"
+				@cancel="handleCancel"
+			>
+				<template #prefix-extra>
+					<el-badge :value="attachments.length" :hidden="!attachments.length">
+						<el-button round plain color="#626aef" @click="uploadDialogVisible = true">
+							<el-icon>
+								<Paperclip />
+							</el-icon>
+						</el-button>
+					</el-badge>
+				</template>
+			</ChatInput>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Paperclip } from '@element-plus/icons-vue'
+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 { useRunnerStore } from '@/store/modules/runner.store'
+
+import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
+import type {
+	BubbleMessage,
+	ChatReference,
+	ChatSseMessage,
+	ChatToolCall,
+	ChatToolResult
+} from '@/views/chat/types'
+import type { IWorkflowNode } from '@repo/workflow'
+
+interface Props {
+	node?: IWorkflowNode
+	workflowId?: string
+}
+
+const props = defineProps<Props>()
+
+const runnerStore = useRunnerStore()
+const messages = ref<BubbleMessage[]>([])
+const senderValue = ref('')
+const attachments = ref<WorkflowUploadFile[]>([])
+const uploadDialogVisible = ref(false)
+const messageListRef = ref<InstanceType<typeof MessageList>>()
+const activeAiMessage = ref<BubbleMessage | null>(null)
+const handledChatMessageCount = ref(0)
+const startingRunner = ref(false)
+
+const isRunning = computed(
+	() =>
+		startingRunner.value ||
+		(!!activeAiMessage.value &&
+			(runnerStore.status === 'connecting' || runnerStore.status === 'running'))
+)
+
+const appAgentId = computed(
+	() => props.workflowId || props.node?.appAgentId || (props.node?.data as any)?.appAgentId || ''
+)
+
+const scrollToBottom = () => {
+	nextTick(() => {
+		messageListRef.value?.scrollToBottom?.()
+	})
+}
+
+const normalizeText = (value?: unknown) => `${value || ''}`.trim()
+
+const createMessageKey = (prefix: string) =>
+	`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+
+const createUserMessage = (content: string, files: WorkflowUploadFile[] = []): BubbleMessage => ({
+	key: createMessageKey('user'),
+	role: 'user',
+	placement: 'end',
+	content,
+	loading: false,
+	shape: 'corner',
+	variant: 'outlined',
+	isMarkdown: true,
+	typing: false,
+	isFog: false,
+	message_files: files.map((item) => item.path || item.id).filter(Boolean)
+})
+
+const createAiMessage = (): BubbleMessage => ({
+	key: createMessageKey('ai'),
+	role: 'ai',
+	placement: 'start',
+	content: '',
+	answerText: '',
+	thinking: '',
+	toolCalls: [],
+	toolResults: [],
+	references: [],
+	assistantMessageId: '',
+	loading: true,
+	shape: 'corner',
+	variant: 'filled',
+	isMarkdown: true,
+	typing: { step: 3, interval: 25 },
+	isFog: true
+})
+
+const getFilesParam = () =>
+	attachments.value
+		.map((item) => ({
+			id: item.id,
+			name: item.name,
+			extensionName: item.extensionName,
+			size: item.size,
+			path: item.path || item.id
+		}))
+		.filter((item) => item.path)
+
+const normalizeToolCall = (event: ChatSseMessage): ChatToolCall => {
+	const data = event.data || {}
+	return {
+		id: data.id || event.id,
+		toolCallId: data.tool_call_id || data.toolCallId,
+		toolName: data.tool_name || data.toolName,
+		arguments: data.arguments || data.params || data.args || {},
+		content: event.content || data.content
+	}
+}
+
+const normalizeToolResult = (event: ChatSseMessage): ChatToolResult => {
+	const data = event.data || {}
+	return {
+		id: data.id || event.id,
+		toolCallId: data.tool_call_id || data.toolCallId,
+		toolName: data.tool_name || data.toolName,
+		success: data.success,
+		error: data.error,
+		output: data.output,
+		thought: data.thought,
+		durationMs: data.duration_ms || data.durationMs,
+		displayType: data.display_type || data.displayType,
+		contentItems: data.content_items || data.contentItems
+	}
+}
+
+const normalizeReferences = (event: ChatSseMessage): ChatReference[] => {
+	const data = event.data || {}
+	const eventRefs = (event as ChatSseMessage & { references?: unknown }).references
+	const refs = data.knowledge_references || data.references || eventRefs || []
+	if (!Array.isArray(refs)) return []
+
+	return refs.map((item: Record<string, any>) => ({
+		id: item.id,
+		knowledgeBaseId: item.knowledge_base_id || item.knowledgeBaseId,
+		knowledgeId: item.knowledge_id || item.knowledgeId,
+		knowledgeTitle: item.knowledge_title || item.knowledgeTitle,
+		knowledgeFilename: item.knowledge_filename || item.knowledgeFilename,
+		knowledgeDescription: item.knowledge_description || item.knowledgeDescription,
+		content: item.content,
+		matchedContent: item.matched_content || item.matchedContent,
+		score: item.score,
+		chunkType: item.chunk_type || item.chunkType
+	}))
+}
+
+const normalizeChatEvent = (raw: Record<string, any>): ChatSseMessage => {
+	const data = raw.data && typeof raw.data === 'object' ? raw.data : raw
+	const content =
+		raw.content ?? raw.answer ?? raw.output ?? raw.delta ?? raw.text ?? data.content ?? data.answer ?? ''
+
+	return {
+		id: raw.id || data.id,
+		response_type:
+			raw.response_type ||
+			raw.responseType ||
+			raw.type ||
+			raw.event ||
+			(content ? 'agent_query' : 'complete'),
+		content,
+		done: raw.done || raw.is_end || raw.finished,
+		data
+	}
+}
+
+const buildReferenceMarkdown = (references: ChatReference[] = []) => {
+	if (!references.length) return ''
+
+	const items = references.map((item, index) => {
+		const title =
+			item.knowledgeTitle || item.knowledgeFilename || item.knowledgeId || `引用 ${index + 1}`
+		const content = item.matchedContent || item.content || ''
+		return `\n${index + 1}. ${title}${content ? `\n   ${content}` : ''}`
+	})
+
+	return `\n\n**引用**${items.join('')}`
+}
+
+const updateMessageContent = (message: BubbleMessage) => {
+	const answer = normalizeText(message.answerText || message.content)
+	message.content = `${answer}${buildReferenceMarkdown(message.references)}`
+}
+
+const finishActiveMessage = (error?: string) => {
+	const message = activeAiMessage.value
+	if (!message) return
+
+	if (error && !message.content && !message.answerText) {
+		message.content = error
+		message.answerText = error
+		message.error = true
+	}
+
+	message.loading = false
+	message.isFog = false
+	message.streamCompleted = true
+	updateMessageContent(message)
+	activeAiMessage.value = null
+	scrollToBottom()
+}
+
+const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMessage) => {
+	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 'thinking': {
+			const thought = normalizeText(event.content || data.thought || data.content)
+			if (thought) {
+				message.thinking = `${message.thinking || ''}${message.thinking ? '\n\n' : ''}${thought}`
+			}
+			break
+		}
+		case 'tool_call':
+			message.toolCalls = [...(message.toolCalls || []), normalizeToolCall(event)]
+			break
+		case 'tool_result': {
+			message.toolResults = [...(message.toolResults || []), normalizeToolResult(event)]
+			const thought = normalizeText(data.thought)
+			if (thought) {
+				message.thinking = `${message.thinking || ''}${message.thinking ? '\n\n' : ''}${thought}`
+			}
+			break
+		}
+		case 'references':
+			message.references = [...(message.references || []), ...normalizeReferences(event)]
+			break
+		case 'complete':
+			break
+		default:
+			if (event.content) {
+				message.answerText = `${message.answerText || ''}${event.content}`
+			}
+			break
+	}
+
+	updateMessageContent(message)
+	message.loading = event.response_type !== 'complete' && !event.done
+	message.isFog = false
+
+	if (event.response_type === 'complete' || event.done) {
+		message.streamCompleted = true
+	}
+}
+
+const handleRunnerChatMessages = () => {
+	const list = runnerStore.agentChatMessages
+	if (handledChatMessageCount.value >= list.length) return
+
+	const nextMessages = list.slice(handledChatMessageCount.value)
+	handledChatMessageCount.value = list.length
+
+	if (!activeAiMessage.value) return
+
+	nextMessages.forEach((item) => {
+		applyStructuredEventToMessage(activeAiMessage.value!, normalizeChatEvent(item.msg || {}))
+	})
+	scrollToBottom()
+}
+
+const handleSend = async (content?: string) => {
+	const query = normalizeText(content)
+	if (!query) {
+		ElMessage.warning('请输入问题')
+		return
+	}
+	if (!props.node?.id || !appAgentId.value) {
+		ElMessage.warning('缺少智能体或节点信息')
+		return
+	}
+	if (isRunning.value) return
+
+	const files = getFilesParam()
+	messages.value.push(createUserMessage(query, attachments.value))
+	const aiMessage = createAiMessage()
+	messages.value.push(aiMessage)
+	activeAiMessage.value = aiMessage
+	senderValue.value = ''
+	attachments.value = []
+	handledChatMessageCount.value = 0
+	scrollToBottom()
+
+	startingRunner.value = true
+	try {
+		const response = await agent.postAgentDoExecute({
+			appAgentId: appAgentId.value,
+			start_node_id: props.node.id,
+			is_debugger: true,
+			responseType: 'ws',
+			params: {
+				query,
+				files
+			}
+		})
+
+		const agentRunnerKey = response?.result
+		if (!agentRunnerKey) {
+			finishActiveMessage('智能体启动失败')
+			ElMessage.error('智能体启动失败')
+			return
+		}
+
+		runnerStore.startRunner(agentRunnerKey, props.node.id)
+		handledChatMessageCount.value = 0
+	} catch (error) {
+		console.error('postAgentDoExecute error', error)
+		finishActiveMessage('智能体运行失败')
+		ElMessage.error('智能体运行失败')
+	} finally {
+		startingRunner.value = false
+	}
+}
+
+const handleCancel = () => {
+	runnerStore.stopRunner()
+	finishActiveMessage()
+}
+
+const handleRetry = (message: BubbleMessage) => {
+	const index = messages.value.findIndex((item) => item === message)
+	if (index <= 0) return
+	const previous = messages.value[index - 1]
+	if (previous?.role !== 'user') return
+	handleSend(previous.content)
+}
+
+watch(
+	() => runnerStore.agentChatMessages.length,
+	() => {
+		handleRunnerChatMessages()
+	}
+)
+
+watch(
+	() => runnerStore.status,
+	(status) => {
+		if (!activeAiMessage.value) return
+		if (status === 'finished') {
+			finishActiveMessage()
+		}
+		if (status === 'error') {
+			finishActiveMessage(runnerStore.errorMsg || '智能体运行失败')
+		}
+		if (status === 'suspended') {
+			finishActiveMessage('智能体运行已挂起')
+		}
+	}
+)
+</script>
+
+<style lang="less" scoped>
+.setter-chat {
+	height: 100%;
+	min-height: 0;
+	display: flex;
+	flex-direction: column;
+	background: var(--bg-page);
+	overflow: hidden;
+}
+
+.setter-chat__footer {
+	flex-shrink: 0;
+	border-top: 1px solid var(--border-light);
+	background: var(--bg-container);
+}
+
+:deep(.chat-content) {
+	min-height: 0;
+	padding: 12px;
+}
+
+:deep(.sender-wrapper) {
+	width: calc(100% - 24px);
+	margin-bottom: 0;
+	padding: 10px 0;
+}
+
+:deep(.el-bubble-end .el-bubble-content) {
+	background-color: var(--el-color-primary-light-9);
+	border-radius: 8px 0 8px 8px;
+	color: var(--text-primary);
+	border: none;
+}
+
+:deep(.el-bubble-start .el-bubble-content) {
+	background-color: var(--bg-container);
+	border: none;
+	color: var(--text-primary);
+}
+</style>

+ 58 - 4
apps/web/src/features/setter/NodeLog.vue

@@ -2,6 +2,8 @@
 import { computed, ref, watch } from 'vue'
 import { agent } from '@repo/api-service'
 import type { IWorkflowNode } from '@repo/workflow'
+import { ElMessage } from 'element-plus'
+import { DocumentCopy } from '@element-plus/icons-vue'
 
 import { useI18n } from '@/composables/useI18n'
 import { useRunnerStore, type RunnerNodeState } from '@/store/modules/runner.store'
@@ -250,6 +252,11 @@ const prettyTrack = computed(() => {
 	if (!nodeLog.value?.raw) return ''
 	return toPrettyJson(nodeLog.value.raw)
 })
+
+const copyText = async (value: string) => {
+	await navigator.clipboard.writeText(value || '')
+	ElMessage.success(t('pages.setter.nodeLog.copySuccess'))
+}
 </script>
 
 <template>
@@ -273,17 +280,50 @@ const prettyTrack = computed(() => {
 			</div>
 
 			<div class="node-log__body">
-				<div class="node-log__section-title">{{ t('pages.setter.nodeLog.input') }}</div>
+				<div class="node-log__section-header">
+					<div class="node-log__section-title">{{ t('pages.setter.nodeLog.input') }}</div>
+					<el-button
+						:icon="DocumentCopy"
+						text
+						size="small"
+						class="node-log__copy-btn"
+						@click="copyText(toPrettyJson(inputData))"
+					>
+						{{ t('pages.setter.nodeLog.copy') }}
+					</el-button>
+				</div>
 				<pre class="node-log__pre">{{ toPrettyJson(inputData) }}</pre>
 			</div>
 
 			<div class="node-log__body">
-				<div class="node-log__section-title">{{ t('pages.setter.nodeLog.output') }}</div>
+				<div class="node-log__section-header">
+					<div class="node-log__section-title">{{ t('pages.setter.nodeLog.output') }}</div>
+					<el-button
+						:icon="DocumentCopy"
+						text
+						size="small"
+						class="node-log__copy-btn"
+						@click="copyText(toPrettyJson(outputData))"
+					>
+						{{ t('pages.setter.nodeLog.copy') }}
+					</el-button>
+				</div>
 				<pre class="node-log__pre">{{ toPrettyJson(outputData) }}</pre>
 			</div>
 
 			<div v-if="prettyTrack" class="node-log__body">
-				<div class="node-log__section-title">{{ t('pages.setter.nodeLog.rawDetail') }}</div>
+				<div class="node-log__section-header">
+					<div class="node-log__section-title">{{ t('pages.setter.nodeLog.rawDetail') }}</div>
+					<el-button
+						:icon="DocumentCopy"
+						text
+						size="small"
+						class="node-log__copy-btn"
+						@click="copyText(prettyTrack)"
+					>
+						{{ t('pages.setter.nodeLog.copy') }}
+					</el-button>
+				</div>
 				<pre class="node-log__pre">{{ prettyTrack }}</pre>
 			</div>
 		</div>
@@ -341,10 +381,24 @@ const prettyTrack = computed(() => {
 		padding: 12px;
 	}
 
+	&__section-header {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 12px;
+		margin-bottom: 8px;
+	}
+
 	&__section-title {
 		font-size: 12px;
 		color: #6b7280;
-		margin-bottom: 8px;
+	}
+
+	&__copy-btn {
+		flex-shrink: 0;
+		padding: 2px 4px;
+		font-size: 12px;
+		color: #6b7280;
 	}
 
 	&__pre {

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

@@ -4,6 +4,7 @@ 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 { nodeMap } from '@/nodes'
 import { useI18n } from '@/composables/useI18n'
@@ -48,6 +49,10 @@ 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/')
 })
@@ -246,6 +251,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>

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

@@ -1200,7 +1200,9 @@ export default {
 				lastUpdated: 'Last updated: ',
 				input: 'Input',
 				output: 'Output',
-				rawDetail: 'Run details (raw data)'
+				rawDetail: 'Run details (raw data)',
+				copy: 'Copy',
+				copySuccess: 'Copied successfully'
 			}
 		},
 		toolbar: {

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

@@ -1105,7 +1105,9 @@ export default {
 				lastUpdated: '最后更新时间:',
 				input: '输入',
 				output: '输出',
-				rawDetail: '运行详情(原始数据)'
+				rawDetail: '运行详情(原始数据)',
+				copy: '复制',
+				copySuccess: '复制成功'
 			}
 		},
 		toolbar: {

+ 3 - 1
apps/web/src/nodes/_base/PromptEditor/index.vue

@@ -146,7 +146,9 @@ function onChange(editorState: any) {
 				<template #contentEditable>
 					<ContentEditable class="el-input__inner" @focus="onFocus" @blur="onBlur">
 						<template #placeholder>
-							<div class="editor-placeholder">{{ placeholder }}</div>
+							<div class="editor-placeholder" :style="{ marginTop: `${rows > 1 ? 6 : 0}px` }">
+								{{ placeholder }}
+							</div>
 						</template>
 					</ContentEditable>
 				</template>

+ 3 - 3
apps/web/src/nodes/src/knowledge-retrieval/index.ts

@@ -9,7 +9,7 @@ import i18n from '@/i18n'
 import { getNodeDescription, getNodeDisplayName } from '@/nodes/i18n'
 
 export type KnowledgeRetrievalData = INodeDataBaseSchema & {
-	query_variable: NodeVariable
+	query: string
 	top_k: number
 	knowledge_base_ids: string[]
 	knowledge_ids: string[]
@@ -36,8 +36,8 @@ export const knowledgeRetrievalNode: INodeType = {
 	inputs: [NodeConnectionTypes.main],
 	outputs: [NodeConnectionTypes.main],
 	validate: (data: KnowledgeRetrievalData) => {
-		if (!data?.query_variable?.value?.trim()) {
-			return i18n.t('pages.knowledgeRetrievalSetter.queryRequired')
+		if (!data?.query?.trim()) {
+			return '请输入查询内容'
 		}
 
 		const hasKnowledgeBase = data?.knowledge_base_ids?.some((id) => id?.trim())

+ 8 - 30
apps/web/src/nodes/src/knowledge-retrieval/setter.vue

@@ -3,15 +3,13 @@
 		<div class="knowledge-retrieval-setter">
 			<section class="section-block">
 				<div class="section-title-row">
-					<label class="section-title">{{ texts.queryVariable }}</label>
+					<label class="section-title">查询内容</label>
 				</div>
-				<VarSelect
-					v-model="formData.query_variable.value"
+				<VarInput
+					v-model="formData.query"
+					:rows="3"
 					class="w-full"
-					:var-type="formData.query_variable.type"
-					:placeholder="texts.queryVariablePlaceholder"
-					@change="handleQueryVariableChange"
-					@clear="handleQueryVariableClear"
+					placeholder="请输入,输入/选择变量"
 				/>
 				<div class="field-hint">{{ texts.queryVariableTip }}</div>
 			</section>
@@ -123,12 +121,11 @@ import { computed, onMounted, ref, watch } from 'vue'
 import { knowledge } from '@repo/api-service'
 
 import NodeRuntimeConfig from '@/nodes/_base/NodeRuntimeConfig.vue'
-import VarSelect from '@/nodes/_base/VarSelect.vue'
+import VarInput from '@/nodes/_base/VarInput.vue'
 import { useI18n } from '@/composables/useI18n'
 import { useSetterModel } from '../_shared/useSetterModel'
 
-import { createDefaultQueryVariable, type KnowledgeRetrievalData } from './index'
-import type { NodeVariableType } from '@/nodes/Interface'
+import type { KnowledgeRetrievalData } from './index'
 
 type SelectOption = {
 	label: string
@@ -284,11 +281,7 @@ const fetchKnowledgeOptions = async (baseIdsInput?: string[]) => {
 const ensureDefaults = () => {
 	formData.value.title ||= t('nodes.meta.knowledge-retrieval.displayName')
 	formData.value.type ||= 'knowledge-retrieval'
-	formData.value.query_variable ||= createDefaultQueryVariable()
-	formData.value.query_variable.describe ||= t('pages.knowledgeRetrievalSetter.queryDescribe')
-	formData.value.query_variable.name ||= 'query'
-	formData.value.query_variable.type ||= 'string'
-	formData.value.query_variable.value ||= ''
+	formData.value.query ||= ''
 	formData.value.top_k = typeof formData.value.top_k === 'number' ? formData.value.top_k : 10
 	formData.value.knowledge_base_ids ||= []
 	formData.value.knowledge_ids ||= []
@@ -348,21 +341,6 @@ watch(
 onMounted(() => {
 	void syncSelectableLists()
 })
-
-const handleQueryVariableChange = (val: { value: string; type: NodeVariableType }) => {
-	formData.value.query_variable = {
-		...formData.value.query_variable,
-		name: 'query',
-		describe: t('pages.knowledgeRetrievalSetter.queryDescribe'),
-		value: val.value,
-		type: val.type || 'string'
-	}
-}
-
-const handleQueryVariableClear = () => {
-	formData.value.query_variable = createDefaultQueryVariable()
-	formData.value.query_variable.describe = t('pages.knowledgeRetrievalSetter.queryDescribe')
-}
 </script>
 
 <style scoped lang="less">

+ 15 - 6
apps/web/src/nodes/src/llm/setter.vue

@@ -64,7 +64,10 @@ const formatSchemaType = (schema: any) => {
 	return schema?.type || 'string'
 }
 
-const buildSchemaPreviewTree = (schema: any, parentPath = 'structured_output'): SchemaPreviewNode[] => {
+const buildSchemaPreviewTree = (
+	schema: any,
+	parentPath = 'structured_output'
+): SchemaPreviewNode[] => {
 	if (!schema?.properties) return []
 
 	const required = new Set<string>(Array.isArray(schema.required) ? schema.required : [])
@@ -174,10 +177,12 @@ onMounted(async () => {
 				<div class="section-title-row">
 					<label class="section-title">上下文</label>
 				</div>
-				<VarSelect v-model="formData.context" class="w-full" placeholder="设置变量值" />
-				<div v-if="formData.context" class="tip text-12px text-#DC6803">
-					要启用上下文,请在提示词使用上下文变量
-				</div>
+				<VarInput
+					v-model="formData.context"
+					:rows="3"
+					class="w-full"
+					placeholder="请输入,输入/选择变量"
+				/>
 			</section>
 
 			<section class="section-block">
@@ -291,7 +296,11 @@ onMounted(async () => {
 											{{ data.description }}
 										</div>
 										<div v-if="data.enumValues.length" class="schema-tree-node__enum">
-											{{ data.enumValues.map((item: string | number | boolean) => JSON.stringify(item)).join(' | ') }}
+											{{
+												data.enumValues
+													.map((item: string | number | boolean) => JSON.stringify(item))
+													.join(' | ')
+											}}
 										</div>
 									</div>
 								</template>

+ 10 - 10
apps/web/src/router/index.ts

@@ -46,11 +46,6 @@ const routes = [
 				name: 'Statistics',
 				component: Statistics
 			},
-			{
-				path: 'workflow',
-				name: 'Workflow',
-				component: FlowManagement
-			},
 			{
 				path: 'templates/:id',
 				name: 'TemplateDetail',
@@ -71,11 +66,6 @@ const routes = [
 				name: 'Chat',
 				component: Chat
 			},
-			{
-				path: 'workflow/:id',
-				name: 'Editor',
-				component: Editor
-			},
 			{
 				path: 'quick-start',
 				name: 'QuickStart',
@@ -162,6 +152,16 @@ const routes = [
 				component: Workspace
 			}
 		]
+	},
+	{
+		path: '/workflow',
+		name: 'Workflow',
+		component: FlowManagement
+	},
+	{
+		path: '/workflow/:id',
+		name: 'Editor',
+		component: Editor
 	}
 ]
 

+ 19 - 0
apps/web/src/store/modules/runner.store.ts

@@ -92,6 +92,11 @@ interface AgentSuspendMessage extends AgentRunnerMessageBase {
 	cmd: 'CMD_AGENT_SUSPEND_MSG'
 }
 
+interface AgentChatMessage extends AgentRunnerMessageBase {
+	cmd: 'CMD_AGENT_CHAT_MSG'
+	msg: Record<string, any>
+}
+
 export type AgentRunnerMessage =
 	| ConnectErrorMessage
 	| WelcomeMessage
@@ -105,6 +110,7 @@ export type AgentRunnerMessage =
 	| AgentFinishMessage
 	| AgentErrorMessage
 	| AgentSuspendMessage
+	| AgentChatMessage
 
 const AGENT_RUNNER_WS_BASE = `wss://${import.meta.env.VITE_BASE_URL}/api/ws/agentRunner`
 
@@ -116,6 +122,7 @@ export const useRunnerStore = defineStore('runner', () => {
 	const connected = ref(false)
 	const lastHeartbeatAt = ref<number | null>(null)
 	const agentResult = ref<any>(null)
+	const agentChatMessages = ref<AgentChatMessage[]>([])
 
 	const nodesMap = reactive<Record<string, RunnerNodeState>>({})
 
@@ -144,6 +151,7 @@ export const useRunnerStore = defineStore('runner', () => {
 		connected.value = false
 		lastHeartbeatAt.value = null
 		agentResult.value = null
+		agentChatMessages.value = []
 		Object.keys(nodesMap).forEach((key) => {
 			delete nodesMap[key]
 		})
@@ -366,6 +374,16 @@ export const useRunnerStore = defineStore('runner', () => {
 				})
 				break
 			}
+
+			/**
+			 * AI模型聊天
+			 */
+			case 'CMD_AGENT_CHAT_MSG': {
+				const msg = data as AgentChatMessage
+				agentChatMessages.value.push(msg)
+				break
+			}
+
 			default:
 				break
 		}
@@ -472,6 +490,7 @@ export const useRunnerStore = defineStore('runner', () => {
 		lastHeartbeatAt,
 		nodes,
 		agentResult,
+		agentChatMessages,
 		executions,
 		startRunner,
 		stopRunner,

Dosya farkı çok büyük olduğundan ihmal edildi
+ 410 - 251
apps/web/src/views/chat/index.vue


+ 1 - 0
apps/web/src/views/chat/types.ts

@@ -16,6 +16,7 @@ export interface BubbleMessage {
 	updateTime?: string
 	message_files?: string | string[]
 	thinking?: string
+	thinkingOpen?: boolean
 	toolCalls?: ChatToolCall[]
 	toolResults?: ChatToolResult[]
 	references?: ChatReference[]

+ 75 - 23
apps/web/src/views/knowledge/DocumentManage.vue

@@ -2,10 +2,20 @@
 	<div class="document-manage">
 		<div class="action-bar">
 			<div class="action-bar__left">
-				<el-input v-model="keyword" clearable placeholder="搜索知识标题" style="width: 240px"
-					@keyup.enter="fetchKnowledgeList" />
-				<el-input v-model="fileType" clearable placeholder="文件类型,如 pdf/md" style="width: 180px"
-					@keyup.enter="fetchKnowledgeList" />
+				<el-input
+					v-model="keyword"
+					clearable
+					placeholder="搜索知识标题"
+					style="width: 240px"
+					@keyup.enter="fetchKnowledgeList"
+				/>
+				<el-input
+					v-model="fileType"
+					clearable
+					placeholder="文件类型,如 pdf/md"
+					style="width: 180px"
+					@keyup.enter="fetchKnowledgeList"
+				/>
 				<el-button @click="fetchKnowledgeList">
 					<el-icon>
 						<Refresh />
@@ -36,7 +46,12 @@
 				</div>
 			</template>
 
-			<el-table v-if="knowledgeList.length || loading" :data="knowledgeList" v-loading="loading" border>
+			<el-table
+				v-if="knowledgeList.length || loading"
+				:data="knowledgeList"
+				v-loading="loading"
+				border
+			>
 				<el-table-column prop="title" label="标题" min-width="220" />
 				<el-table-column prop="type" label="类型" width="100">
 					<template #default="{ row }">
@@ -69,7 +84,12 @@
 				<el-table-column prop="updateTime" label="更新时间" width="180" />
 				<el-table-column label="操作" width="220" fixed="right">
 					<template #default="{ row }">
-						<el-button v-if="row.file_type === 'manual'" link type="primary" @click="openEditDrawer(row)">
+						<el-button
+							v-if="row.file_type === 'manual'"
+							link
+							type="primary"
+							@click="openEditDrawer(row)"
+						>
 							编辑
 						</el-button>
 						<el-button link type="warning" @click="reparseKnowledge(row.id)">重新解析</el-button>
@@ -106,28 +126,40 @@
 		<el-drawer v-model="fileDrawerVisible" title="导入文件知识" direction="rtl" size="640px">
 			<el-form :model="fileForm" label-position="top">
 				<el-form-item label="上传文件">
-					<FileUploadInput v-model="uploadFile" :multiple="false" :file-types="['document', 'image', 'audio']"
-						:allow-link-input="false" tip="支持文档、图片、音频格式,上传后会按知识内容处理。" style="width: 100%" />
+					<FileUploadInput
+						v-model="uploadFile"
+						:multiple="false"
+						:file-types="['document', 'image', 'audio']"
+						:allow-link-input="false"
+						tip="支持文档、图片、音频格式,上传后会按知识内容处理。"
+						style="width: 100%"
+					/>
 				</el-form-item>
 				<el-form-item>
 					<div class="metadata-config">
 						<div class="metadata-config__top">
 							<span>元数据(可选)</span>
 							<el-button type="primary" link @click="addMetadataItem">
-								<el-icon>
-									<Plus />
-								</el-icon>添加元数据
+								<el-icon> <Plus /> </el-icon>添加元数据
 							</el-button>
 						</div>
 						<div class="metadata-config__desc">
 							导入文件知识时附加的元数据,常用于业务分类、标签标记、来源标识等场景
 						</div>
 						<div class="metadata-config__rows">
-							<div v-for="(item, index) in fileForm.metadata" :key="`metadata-${index}`" class="metadata-config__row">
+							<div
+								v-for="(item, index) in fileForm.metadata"
+								:key="`metadata-${index}`"
+								class="metadata-config__row"
+							>
 								<el-input v-model="item.key" placeholder="元数据名称" />
 								<el-input v-model="item.value" placeholder="元数据值" />
-								<el-button link type="danger" :disabled="fileForm.metadata.length === 1"
-									@click="removeMetadataItem(index)">
+								<el-button
+									link
+									type="danger"
+									:disabled="fileForm.metadata.length === 1"
+									@click="removeMetadataItem(index)"
+								>
 									删除
 								</el-button>
 							</div>
@@ -149,13 +181,24 @@
 		</el-drawer>
 
 		<el-drawer v-model="editDrawerVisible" title="编辑知识" direction="rtl" size="560px">
-			<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-position="top">
+			<el-form
+				ref="editFormRef"
+				v-loading="editDrawerLoading"
+				:model="editForm"
+				:rules="editRules"
+				label-position="top"
+			>
 				<el-form-item label="标题" prop="title">
 					<el-input v-model="editForm.title" />
 				</el-form-item>
 				<el-form-item label="描述(点击编辑,支持Markdown语法)">
 					<!-- <markdown v-if="!isEdit" :content="editForm.description" @click="isEdit = true" /> -->
-					<el-input v-model="editForm.description" type="textarea" :rows="5" @blur="isEdit = false" />
+					<el-input
+						v-model="editForm.description"
+						type="textarea"
+						:rows="5"
+						@blur="isEdit = false"
+					/>
 				</el-form-item>
 			</el-form>
 			<template #footer>
@@ -198,6 +241,7 @@ const isEdit = ref(false)
 
 const manualDrawerVisible = ref(false)
 const fileDrawerVisible = ref(false)
+const editDrawerLoading = ref(false)
 const editDrawerVisible = ref(false)
 const uploadFile = ref<WorkflowUploadFile | null>(null)
 
@@ -308,12 +352,21 @@ function openFileDrawer() {
 	fileDrawerVisible.value = true
 }
 
-function openEditDrawer(row: KnowledgeItem) {
-	Object.assign(editForm, {
-		id: row.id || '',
-		title: row.title || '',
-		description: row.description || ''
-	})
+async function openEditDrawer(row: KnowledgeItem) {
+	// 获取详情
+	try {
+		editDrawerLoading.value = true
+		const res = await knowledge.postKnowledgeInfo({
+			id: row.id!
+		})
+		if (res.isSuccess) {
+			editForm.description = res.result.description || row.description || ''
+			editForm.title = res.result.title || row.title || ''
+			editForm.id = row.id || ''
+		}
+	} finally {
+		editDrawerLoading.value = false
+	}
 	editDrawerVisible.value = true
 }
 
@@ -449,7 +502,6 @@ watch(
 }
 </style>
 
-
 <style scoped lang="less">
 .document-manage {
 	display: flex;

+ 151 - 9
apps/web/src/views/knowledge/QaManage.vue

@@ -15,10 +15,16 @@
 					搜索
 				</el-button>
 			</div>
-			<el-button type="primary" @click="openCreateDrawer">
-				<el-icon><Plus /></el-icon>
-				新增问答
-			</el-button>
+			<div class="flex">
+				<el-button @click="openImportModal">
+					<el-icon><Upload /></el-icon>
+					模版导入
+				</el-button>
+				<el-button type="primary" @click="openCreateDrawer">
+					<el-icon><Plus /></el-icon>
+					新增问答
+				</el-button>
+			</div>
 		</div>
 
 		<el-card>
@@ -75,6 +81,7 @@
 			</div>
 		</el-card>
 
+		<!-- 编辑抽屉 -->
 		<el-drawer
 			v-model="drawerVisible"
 			:title="form.id ? '编辑问答' : '新增问答'"
@@ -198,7 +205,7 @@
 				</div>
 			</template>
 		</el-drawer>
-
+		<!-- 详情弹窗 -->
 		<el-dialog v-model="detailVisible" title="问答详情" width="720px">
 			<div v-loading="detailLoading" class="detail-panel">
 				<template v-if="detailData">
@@ -267,9 +274,7 @@
 						<div class="detail-item">
 							<div class="detail-item__label">索引方式</div>
 							<div class="detail-item__value">
-								{{
-									detailData.index_mode === 'question_only' ? '仅标准问/相似问' : '标准问 + 答案'
-								}}
+								{{ detailData.index_mode }}
 							</div>
 						</div>
 						<div class="detail-item">
@@ -280,15 +285,64 @@
 				</template>
 			</div>
 		</el-dialog>
+		<!-- 导入弹窗 -->
+		<el-dialog v-model="importDialogVisible" title="模版导入" width="500px">
+			<el-form> </el-form>
+			<div class="import-modal-content">
+				<div class="import-step">
+					<div class="step-title">上传文件</div>
+					<div class="step-desc">上传填写好的 Excel 文件。</div>
+					<FileUploadInput
+						v-model="importFile"
+						:fileExtensions="['.xlsx', '.xls']"
+						placeholder="点击或拖拽上传 Excel 文件"
+					/>
+				</div>
+
+				<div class="import-step">
+					<div class="step-title">导入模式</div>
+					<el-select v-model="importFormData.mode" placeholder="请选择">
+						<el-option label="追加" value="append"></el-option>
+						<el-option label="覆盖" value="replace"></el-option>
+					</el-select>
+				</div>
+
+				<div class="import-step">
+					<div class="step-title">下载模版</div>
+					<div class="step-desc">请下载标准模版,并按照格式填写问答数据。</div>
+					<div>
+						<el-button type="primary" link @click="downloadTemplate">
+							<el-icon><Download /></el-icon>
+							下载 faq_example.xlsx
+						</el-button>
+					</div>
+				</div>
+			</div>
+			<template #footer>
+				<div class="drawer-footer">
+					<el-button @click="importDialogVisible = false">取消</el-button>
+					<el-button
+						type="primary"
+						:loading="importLoading"
+						:disabled="!importFile"
+						@click="submitImport"
+					>
+						开始导入
+					</el-button>
+				</div>
+			</template>
+		</el-dialog>
 	</div>
 </template>
 
 <script setup lang="ts">
 import { reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { Plus, Search } from '@element-plus/icons-vue'
+import { Plus, Search, Upload, Download } from '@element-plus/icons-vue'
 import { knowledge } from '@repo/api-service'
+import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
 import type { FaqForm, FaqItem } from './types'
+import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
 
 interface FaqDetail extends FaqItem {
 	answer_strategy?: string
@@ -318,6 +372,20 @@ const pagination = reactive({
 	totalCount: 0
 })
 
+const importDialogVisible = ref(false)
+const importLoading = ref(false)
+const importFormData = reactive<{
+	knowledge_base_id: string
+	fileId: string
+	mode: 'replace' | 'append'
+}>({
+	knowledge_base_id: '',
+	fileId: '',
+	mode: 'append' // 默认追加,可根据需求调整
+})
+
+const importFile = ref<WorkflowUploadFile>()
+
 type ArrayFieldKey = 'similar_questions' | 'negative_questions' | 'answers'
 
 function normalizeStringArray(value?: string[]) {
@@ -532,6 +600,55 @@ async function formValidate(formInstance: any) {
 		.catch(() => false)
 }
 
+// --- 导入相关逻辑 ---
+
+function openImportModal() {
+	importFormData.fileId = ''
+	importFormData.knowledge_base_id = props.currentBaseId
+	importDialogVisible.value = true
+}
+
+function downloadTemplate() {
+	window.open('/Content/Template/faq_example.xlsx', '_blank')
+}
+
+async function submitImport() {
+	if (!importFile.value) {
+		ElMessage.warning('请先上传文件')
+		return
+	}
+	if (!props.currentBaseId) {
+		ElMessage.error('知识库ID缺失')
+		return
+	}
+
+	importLoading.value = true
+	try {
+		const payload: any = {
+			knowledge_base_id: props.currentBaseId,
+			fileId: importFile.value.id,
+			mode: importFormData.mode
+		}
+
+		const res = await knowledge.postFaqBatchImport(payload)
+
+		if (!res.isSuccess) {
+			ElMessage.error(res.error)
+			return
+		}
+		ElMessage.success('导入成功')
+		importDialogVisible.value = false
+		await refreshFaqList()
+		importFile.value = undefined
+		importFormData.mode = 'append'
+	} catch (error) {
+		console.error('Import failed:', error)
+		ElMessage.error('导入失败,请检查文件格式或联系管理员')
+	} finally {
+		importLoading.value = false
+	}
+}
+
 watch(
 	() => props.currentBaseId,
 	() => {
@@ -711,4 +828,29 @@ watch(
 	color: #333;
 	font-weight: 600;
 }
+
+.import-modal-content {
+	display: flex;
+	flex-direction: column;
+	gap: 24px;
+	padding: 10px 0;
+}
+
+.import-step {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.step-title {
+	font-weight: 600;
+	font-size: 14px;
+	color: #303133;
+}
+
+.step-desc {
+	font-size: 13px;
+	color: #909399;
+	line-height: 1.4;
+}
 </style>

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

@@ -26,6 +26,129 @@
 		}
 	],
 	"paths": {
+		"/api/ai/agent/suggested-questions": {
+			"post": {
+				"summary": "获取智能体建议提问列表",
+				"deprecated": false,
+				"description": "",
+				"tags": ["AgentApplication"],
+				"parameters": [
+					{
+						"name": "Authorization",
+						"in": "header",
+						"description": "",
+						"example": "bpm_client_1518266702310150144",
+						"schema": {
+							"type": "string"
+						}
+					}
+				],
+				"requestBody": {
+					"content": {
+						"application/json": {
+							"schema": {
+								"type": "object",
+								"properties": {
+									"id": {
+										"type": "string"
+									},
+									"limit": {
+										"type": "integer"
+									}
+								},
+								"required": ["id", "limit"]
+							},
+							"example": {
+								"id": "7ccae457-69e2-4cf6-9335-a5d1fa052a2b",
+								"limit": 6
+							}
+						}
+					},
+					"required": true
+				},
+				"responses": {
+					"200": {
+						"description": "",
+						"content": {
+							"application/json": {
+								"schema": {
+									"type": "object",
+									"properties": {
+										"isSuccess": {
+											"type": "boolean"
+										},
+										"code": {
+											"type": "integer"
+										},
+										"result": {
+											"type": "array",
+											"items": {
+												"type": "object",
+												"properties": {
+													"knowledge_base_id": {
+														"type": "string"
+													},
+													"question": {
+														"type": "string"
+													},
+													"source": {
+														"type": "string"
+													}
+												},
+												"required": ["knowledge_base_id", "question", "source"]
+											}
+										},
+										"isAuthorized": {
+											"type": "boolean"
+										}
+									},
+									"required": ["isSuccess", "code", "result", "isAuthorized"]
+								},
+								"example": {
+									"isSuccess": true,
+									"code": 1,
+									"result": [
+										{
+											"knowledge_base_id": "620a5d3f-29d4-49f4-8dde-5b4c4071eff0",
+											"question": "介绍一下QueryParam",
+											"source": "wiki"
+										},
+										{
+											"knowledge_base_id": "620a5d3f-29d4-49f4-8dde-5b4c4071eff0",
+											"question": "介绍一下ExternalAsynTaskService",
+											"source": "wiki"
+										},
+										{
+											"knowledge_base_id": "620a5d3f-29d4-49f4-8dde-5b4c4071eff0",
+											"question": "什么是Asynchronous Task?",
+											"source": "wiki"
+										},
+										{
+											"knowledge_base_id": "620a5d3f-29d4-49f4-8dde-5b4c4071eff0",
+											"question": "如何使用Java上传文件并处理响应结果?",
+											"source": "document"
+										},
+										{
+											"knowledge_base_id": "620a5d3f-29d4-49f4-8dde-5b4c4071eff0",
+											"question": "什么是框架统一响应数据包结构?",
+											"source": "wiki"
+										},
+										{
+											"knowledge_base_id": "fdb64e26-b6a7-41b1-ad11-d7039add6661",
+											"question": "我的快递物流一直不更新怎么办?",
+											"source": "faq"
+										}
+									],
+									"isAuthorized": true
+								}
+							}
+						},
+						"headers": {}
+					}
+				},
+				"security": []
+			}
+		},
 		"/api/openapi/doBatchGenerateUUID": {
 			"post": {
 				"summary": "批量生成UUID",

Dosya farkı çok büyük olduğundan ihmal edildi
+ 324 - 0
packages/api-service/schema/knowledge.openapi.json


+ 23 - 0
packages/api-service/servers/api/agentApplication.ts

@@ -319,6 +319,29 @@ export async function postAiAgentPageList(
   })
 }
 
+/** 获取智能体建议提问列表 POST /api/ai/agent/suggested-questions */
+export async function postAiAgentSuggestedQuestions(
+  body: {
+    id: string
+    limit: number
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: { knowledge_base_id: string; question: string; source: string }[]
+    isAuthorized: boolean
+  }>('/api/ai/agent/suggested-questions', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
 /** 获取智能体可以使用的工具列表 POST /api/ai/agent/tools */
 export async function postAiAgentTools(body: {}, options?: { [key: string]: any }) {
   return request<{

+ 3 - 3
packages/api-service/servers/api/index.ts

@@ -2,11 +2,11 @@
 /* eslint-disable */
 // API 更新时间:
 // API 唯一标识:
+import * as agentApplication from './agentApplication'
 import * as tools from './tools'
 import * as agent from './agent'
-import * as agentApplication from './agentApplication'
 export default {
+  agentApplication,
   tools,
-  agent,
-  agentApplication
+  agent
 }

+ 78 - 0
packages/api-service/servers/knowledge/api/knowledge.ts

@@ -2,6 +2,31 @@
 /* eslint-disable */
 import request from '@repo/api-client'
 
+/** Excel导入 POST /api/ai/faq/batch_import */
+export async function postFaqBatchImport(
+  body: {
+    knowledge_base_id: string
+    standard_question: string
+    similar_questions: string[]
+    negative_questions: string[]
+    answers: string[]
+    is_enabled: boolean
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/faq/batch_import',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}
+
 /** 创建单个FAQ条目 POST /api/ai/faq/create */
 export async function postFaqCreate(
   body: {
@@ -588,6 +613,59 @@ export async function postKnowledgeOpenApiDelete(
   )
 }
 
+/** 获取详情 POST /api/ai/knowledge/info */
+export async function postKnowledgeInfo(
+  body: {
+    id: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      channel: string
+      creationTime: string
+      description: string
+      embedding_model_id: string
+      enable_status: string
+      error_message: string
+      file_hash: string
+      file_name: string
+      file_path: string
+      file_size: number
+      file_type: string
+      id: string
+      isDeleted: boolean
+      knowledge_base_id: string
+      metadata: {
+        updated_at: string
+        format: string
+        version: number
+        content: string
+        status: string
+      }
+      parse_status: string
+      source: string
+      storage_size: number
+      summary_status: string
+      tag_id: string
+      title: string
+      type: string
+      updateTime: string
+      userId: string
+    }
+    isAuthorized: boolean
+  }>('/api/ai/knowledge/info', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
 /** 获取知识分页列表 POST /api/ai/knowledge/pageList */
 export async function postKnowledgePageList(
   body: {