Przeglądaj źródła

feat: 新增llm节点

jiaxing.liao 2 tygodni temu
rodzic
commit
3d901a000f

+ 4 - 2
.vscode/settings.json

@@ -13,6 +13,8 @@
   "i18n-ally.localesPaths": [
     "apps/web/src/i18n/locales"
   ],
+  "i18n-ally.pathMatcher": "{locale}.{ext}",
+  "i18n-ally.namespace": false,
   "i18n-ally.enabledParsers": [
     "ts",
     "json"
@@ -24,7 +26,7 @@
     "vue"
   ],
   "[vue]": {
-    "editor.defaultFormatter": "Vue.volar"
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
   },
   "[typescriptreact]": {
     "editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -32,4 +34,4 @@
   "[javascriptreact]": {
     "editor.defaultFormatter": "esbenp.prettier-vscode"
   }
-}
+}

+ 1 - 0
apps/web/auto-imports.d.ts

@@ -7,6 +7,7 @@
 export {}
 declare global {
   const EffectScope: typeof import('vue').EffectScope
+  const ElMessage: typeof import('element-plus/es').ElMessage
   const computed: typeof import('vue').computed
   const createApp: typeof import('vue').createApp
   const customRef: typeof import('vue').customRef

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

@@ -26,6 +26,7 @@ declare module 'vue' {
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
+    ElCode: typeof import('element-plus/es')['ElCode']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -74,6 +75,7 @@ declare module 'vue' {
     ElTag: typeof import('element-plus/es')['ElTag']
     ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
+    ElTree: typeof import('element-plus/es')['ElTree']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
     MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
@@ -108,6 +110,7 @@ declare global {
   const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
   const ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
   const ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
+  const ElCode: typeof import('element-plus/es')['ElCode']
   const ElCol: typeof import('element-plus/es')['ElCol']
   const ElCollapse: typeof import('element-plus/es')['ElCollapse']
   const ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -156,6 +159,7 @@ declare global {
   const ElTag: typeof import('element-plus/es')['ElTag']
   const ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
   const ElTooltip: typeof import('element-plus/es')['ElTooltip']
+  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 MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']

+ 29 - 11
apps/web/src/components/Chat/ChatInput.vue

@@ -1,8 +1,17 @@
 <template>
 	<div class="sender-wrapper">
-		<Sender v-model="modelValue" variant="updown" submitType='cmdOrCtrlEnter' :auto-size="{ minRows: 2, maxRows: 5 }"
-			clearable allow-speech :placeholder="t('pages.chat.senderPlaceholder')" @submit="emit('submit', modelValue!)"
-			:loading="loading" @cancel="emit('cancel')">
+		<Sender
+			v-model="modelValue"
+			variant="updown"
+			submitType="cmdOrCtrlEnter"
+			:auto-size="{ minRows: 2, maxRows: 5 }"
+			clearable
+			allow-speech
+			placeholder="请输入问题..."
+			@submit="emit('submit', modelValue!)"
+			:loading="loading"
+			@cancel="emit('cancel')"
+		>
 			<!-- 插槽内容 -->
 			<template #prefix>
 				<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
@@ -24,8 +33,14 @@
 			<template #footer>
 				<div v-if="attachments.length" class="attachment-preview">
 					<div v-for="file in attachments" :key="file.id" class="attachment-preview__item">
-						<el-image :src="getImageSrc(file)" class="attachment-preview__image" fit="cover" preview-teleported
-							hide-on-click-modal :preview-src-list="attachments.map(getImageSrc)" />
+						<el-image
+							:src="getImageSrc(file)"
+							class="attachment-preview__image"
+							fit="cover"
+							preview-teleported
+							hide-on-click-modal
+							:preview-src-list="attachments.map(getImageSrc)"
+						/>
 					</div>
 				</div>
 			</template>
@@ -44,12 +59,15 @@ const { t } = useI18n()
 
 const modelValue = defineModel<string>()
 
-const props = withDefaults(defineProps<{
-	loading: boolean
-	attachments?: WorkflowUploadFile[]
-}>(), {
-	attachments: () => []
-})
+const props = withDefaults(
+	defineProps<{
+		loading: boolean
+		attachments?: WorkflowUploadFile[]
+	}>(),
+	{
+		attachments: () => []
+	}
+)
 
 const emit = defineEmits<{
 	(e: 'submit', content?: string): void

+ 293 - 0
apps/web/src/features/PromptModal.vue

@@ -0,0 +1,293 @@
+<template>
+	<el-dialog v-model="visible" title="选择提示词模版" width="960px" append-to-body>
+		<div class="prompt-template-dialog">
+			<div class="prompt-template-dialog__meta">
+				<span>{{ dialogTitle }}</span>
+				<el-button text :icon="Refresh" :loading="loading" @click="loadTemplates"> 刷新 </el-button>
+			</div>
+			<div v-loading="loading" class="prompt-template-grid">
+				<el-empty v-if="!options.length && !loading" description="暂无可用模版" />
+				<div
+					v-for="template in options"
+					:key="template.id"
+					class="prompt-template-card"
+					:class="{ 'is-active': selectedTemplate?.id === template.id }"
+					@click="selectedTemplate = template"
+				>
+					<div class="prompt-template-card__top">
+						<div>
+							<div class="prompt-template-card__title">
+								<el-tag v-if="template.is_builtin" type="success" effect="light">内置</el-tag>
+								<el-tag v-if="template.default" type="warning" effect="light">默认</el-tag>
+								{{ template.name }}
+							</div>
+							<div class="prompt-template-card__desc">{{ template.description || '暂无描述' }}</div>
+						</div>
+					</div>
+					<div class="prompt-template-card__meta">
+						<span>类型:{{ formatTemplateType(template.type) }}</span>
+					</div>
+					<div class="prompt-template-card__content">
+						{{ getTemplatePreview(template) }}
+					</div>
+				</div>
+			</div>
+		</div>
+		<template #footer>
+			<div class="drawer-footer">
+				<div class="text-12px text-gray-500">当前选择:{{ selectedTemplate?.name }}</div>
+				<div>
+					<el-button @click="visible = false">取消</el-button>
+					<el-button type="primary" :disabled="!selectedTemplate" @click="confirmTemplate">
+						确认
+					</el-button>
+				</div>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { Refresh } from '@element-plus/icons-vue'
+import { resource } from '@repo/api-service'
+type PromptTemplateConfigResponse = Awaited<ReturnType<typeof resource.postPromptTemplateConfig>>
+type PromptTemplateConfig = NonNullable<PromptTemplateConfigResponse['result']>
+
+type PromptTemplateItem = {
+	content: string
+	default: boolean
+	description: string
+	has_knowledge_base: boolean
+	has_web_search: boolean
+	id: string
+	is_builtin: boolean
+	mode: string
+	name: string
+	type: string
+	user?: string
+	value: string
+}
+
+export type PromptFieldKey =
+	| 'systemPrompt'
+	| 'contextTemplate'
+	| 'rewritePromptSystem'
+	| 'rewritePromptUser'
+	| 'fallbackPrompt'
+
+const visible = defineModel<boolean>({ required: true })
+
+const props = defineProps<{
+	field: PromptFieldKey
+	mode: string
+}>()
+
+const emit = defineEmits<{
+	confirm: [content: string]
+}>()
+
+const loading = ref(false)
+const config = ref<PromptTemplateConfig | null>(null)
+const options = ref<PromptTemplateItem[]>([])
+const selectedTemplate = ref<PromptTemplateItem | null>(null)
+
+const dialogTitle = computed(() => {
+	const titleMap: Record<PromptFieldKey, string> = {
+		systemPrompt: '系统提示词模版',
+		contextTemplate: '上下文模板模版',
+		rewritePromptSystem: '改写系统提示词模版',
+		rewritePromptUser: '改写用户提示词模版',
+		fallbackPrompt: '兜底提示词模版'
+	}
+	return titleMap[props.field]
+})
+
+function getTemplateItems(field: PromptFieldKey) {
+	if (!config.value) return [] as PromptTemplateItem[]
+	switch (field) {
+		case 'systemPrompt':
+			return props.mode === 'smart-reasoning'
+				? ([
+						...(config.value.agent_system_prompts || []),
+						...(config.value.system_prompts || [])
+					] as PromptTemplateItem[])
+				: ([
+						...(config.value.system_prompts || []),
+						...(config.value.agent_system_prompts || [])
+					] as PromptTemplateItem[])
+		case 'contextTemplate':
+			return (config.value.context_templates || []) as PromptTemplateItem[]
+		case 'rewritePromptSystem':
+		case 'rewritePromptUser':
+			return (config.value.rewrites || []) as PromptTemplateItem[]
+		case 'fallbackPrompt':
+			return (config.value.fall_backs || []) as PromptTemplateItem[]
+		default:
+			return [] as PromptTemplateItem[]
+	}
+}
+
+function getTemplateContent(item: PromptTemplateItem) {
+	if (props.field === 'rewritePromptUser') return item.user || item.content || ''
+	return item.content || ''
+}
+
+function formatTemplateType(type?: string) {
+	const typeMap: Record<string, string> = {
+		'system-prompt': '系统提示词',
+		'agent-system-prompt': 'Agent 系统提示词',
+		rewrite: '改写提示词',
+		'fall-back': '回退提示词',
+		'context-template': '上下文模板'
+	}
+	return typeMap[type || ''] || type || '-'
+}
+
+function getTemplatePreview(item: PromptTemplateItem) {
+	return (item.content || item.user || '-').slice(0, 120)
+}
+
+async function loadTemplates() {
+	loading.value = true
+	try {
+		const res = await resource.postPromptTemplateConfig({})
+		if (res.isSuccess && res.result) {
+			config.value = res.result as PromptTemplateConfig
+			options.value = getTemplateItems(props.field)
+			selectedTemplate.value = options.value[0] || null
+		}
+	} finally {
+		loading.value = false
+	}
+}
+
+function confirmTemplate() {
+	if (!selectedTemplate.value) return
+	emit('confirm', getTemplateContent(selectedTemplate.value))
+	visible.value = false
+}
+
+watch(
+	() => visible.value,
+	(value) => {
+		if (!value) return
+		selectedTemplate.value = null
+		loadTemplates()
+	}
+)
+
+watch(
+	() => [props.field, props.mode],
+	() => {
+		if (!config.value) return
+		options.value = getTemplateItems(props.field)
+		selectedTemplate.value = options.value[0] || null
+	}
+)
+</script>
+
+<style scoped>
+.prompt-template-dialog {
+	display: flex;
+	flex-direction: column;
+	gap: 14px;
+}
+
+.prompt-template-dialog__meta {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	font-size: 14px;
+	font-weight: 600;
+	color: #111827;
+}
+
+.prompt-template-grid {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 12px;
+	max-height: 62vh;
+	overflow: auto;
+	padding-right: 4px;
+}
+
+.prompt-template-grid .el-empty {
+	grid-column: 1 / -1;
+}
+
+.prompt-template-card {
+	padding: 16px;
+	border-radius: 18px;
+	border: 1px solid #e5e7eb;
+	background: #fff;
+	cursor: pointer;
+	transition: border-color 0.15s ease;
+	box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05);
+}
+
+.prompt-template-card.is-active {
+	border-color: #3b82f6;
+}
+
+.prompt-template-card__top {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 10px;
+}
+
+.prompt-template-card__title {
+	display: flex;
+	align-items: center;
+	gap: 4px;
+	flex-wrap: wrap;
+	font-size: 18px;
+	font-weight: 700;
+	color: #111827;
+}
+
+.prompt-template-card__desc {
+	margin-top: 6px;
+	font-size: 13px;
+	line-height: 1.6;
+	color: #6b7280;
+}
+
+.prompt-template-card__meta {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px 12px;
+	margin-top: 12px;
+	font-size: 12px;
+	color: #6b7280;
+}
+
+.prompt-template-card__content {
+	margin-top: 12px;
+	padding: 12px;
+	border-radius: 12px;
+	background: #f8fafc;
+	color: #334155;
+	font-size: 13px;
+	line-height: 1.6;
+	white-space: pre-wrap;
+	max-height: 160px;
+	overflow: auto;
+}
+
+.drawer-footer {
+	display: flex;
+	justify-content: flex-end;
+	align-items: center;
+	justify-content: space-between;
+	gap: 10px;
+}
+
+@media (max-width: 768px) {
+	.prompt-template-grid {
+		grid-template-columns: 1fr;
+	}
+}
+</style>

+ 2 - 0
apps/web/src/nodes/src/index.ts

@@ -17,6 +17,7 @@ import { viewDataNode } from './view-data'
 import { smsSenderNode } from './sms-sender'
 import { mailSenderNode } from './mail-sender'
 import { workflowApprovalNode } from './workflow-approval'
+import { llmNode } from './llm'
 
 import { getNodeDisplayName } from '@/nodes/i18n'
 import type { INodeType } from '../Interface'
@@ -108,6 +109,7 @@ const withFailBranchOutput = (node: INodeType): INodeType => {
 const baseNodes = [
 	startNode,
 	endNode,
+	llmNode,
 	httpNode,
 	conditionNode,
 	databaseNode,

+ 410 - 0
apps/web/src/nodes/src/llm/SchemaTreeNode.vue

@@ -0,0 +1,410 @@
+<script setup lang="ts">
+import { computed, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Delete, Plus, Warning } from '@element-plus/icons-vue'
+import { VARIABLE_TYPE_OPTIONS } from '@/constant'
+
+export interface SchemaNode {
+	name: string
+	type: string
+	description: string
+	enumValues: string
+	required: boolean
+	children?: SchemaNode[]
+}
+
+defineOptions({
+	name: 'SchemaTreeNode'
+})
+
+const props = defineProps<{
+	modelValue: SchemaNode
+	index: number
+	isRoot?: boolean
+	siblings?: SchemaNode[]
+}>()
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', value: SchemaNode): void
+	(e: 'remove'): void
+}>()
+
+const node = computed({
+	get: () => props.modelValue,
+	set: (val) => emit('update:modelValue', val)
+})
+
+// 判断是否显示子节点编辑区
+const showChildren = computed(() => {
+	const t = node.value.type
+	if (t === 'object') return true
+	if (t.startsWith('array[object]')) return true
+	return false
+})
+
+const getArrayItemType = (type: string) => type.match(/^array\[(.*)\]$/)?.[1] || 'string'
+
+// 字段名要求符合 JS 变量命名规则
+const JS_IDENTIFIER_REGEXP = /^[A-Za-z_$][A-Za-z0-9_$]*$/
+
+const isNameInvalid = computed(() => {
+	if (props.isRoot) return false
+	const currentName = node.value.name?.trim()
+	if (!currentName) return false
+	return !JS_IDENTIFIER_REGEXP.test(currentName)
+})
+
+// 校验名称是否重复
+const isNameDuplicate = computed(() => {
+	const currentName = node.value.name?.trim()
+	if (!props.siblings || !currentName) return false
+	return props.siblings.some(
+		(sibling, idx) => idx !== props.index && sibling.name?.trim() === currentName
+	)
+})
+
+const nameErrorText = computed(() => {
+	if (isNameInvalid.value) return '字段名需符合 JS 变量命名规则'
+	if (isNameDuplicate.value) return '字段名重复'
+	return ''
+})
+
+const hasNameError = computed(() => isNameInvalid.value || isNameDuplicate.value)
+
+// 监听类型变化
+watch(
+	() => node.value.type,
+	(newType, oldType) => {
+		// 如果类型没有变化,不要做任何事,保护已有数据
+		if (newType === oldType) return
+
+		if (newType.startsWith('array[')) {
+			const itemType = getArrayItemType(newType)
+
+			// 对于 array[object]
+			if (itemType === 'object') {
+				// array[object] 的子节点直接表示对象属性,不保留 item 包装层
+				if (node.value.children && node.value.children.length === 1 && node.value.children[0]?.name === 'item') {
+					node.value.children = node.value.children[0]?.children || []
+				}
+				if (!node.value.children) {
+					node.value.children = []
+				}
+				return
+			}
+
+			// 对于其他数组类型(如 array[string]),需要确保有一个 item 节点
+			if (!node.value.children || node.value.children.length === 0) {
+				node.value.children = [
+					{
+						name: 'item',
+						type: itemType,
+						description: '',
+						enumValues: '',
+						required: false,
+						children: []
+					}
+				]
+			} else {
+				// 如果已有子节点,更新第一个子节点的类型
+				const firstChild = node.value.children[0]
+				if (firstChild) {
+					firstChild.type = itemType
+					if (itemType !== 'object') {
+						firstChild.children = []
+					}
+				}
+				// 保持只有一个子节点 (item)
+				if (node.value.children.length > 1) {
+					node.value.children = [node.value.children[0]!]
+				}
+			}
+		} else if (newType === 'object') {
+			if (node.value.children && node.value.children.length === 1 && node.value.children[0]?.name === 'item') {
+				node.value.children = node.value.children[0]?.children || []
+			}
+			if (!node.value.children) {
+				node.value.children = []
+			}
+		} else {
+			// 基本类型,清空 children
+			node.value.children = []
+		}
+	},
+	{ immediate: true }
+)
+
+const addChild = () => {
+	if (!node.value.children) {
+		node.value.children = []
+	}
+
+	// 如果是 Array 类型,区分处理
+	if (node.value.type.startsWith('array[')) {
+		const itemType = node.value.type.match(/^array\[(.*)\]$/)?.[1] || 'string'
+
+		// 只有非 object 的数组类型(如 array[string])才禁止添加子节点
+		if (itemType !== 'object') {
+			ElMessage.warning('简单类型数组只能定义一种项结构,无法添加子字段')
+			return
+		}
+	}
+
+	// Object 或 array[object] 类型,正常添加子字段
+	node.value.children.push({
+		name: '',
+		type: 'string',
+		description: '',
+		enumValues: '',
+		required: false,
+		children: []
+	})
+}
+
+const removeChild = (idx: number) => {
+	if (node.value.children) {
+		// 如果是 Array 类型,区分处理
+		if (node.value.type.startsWith('array[')) {
+			const itemType = node.value.type.match(/^array\[(.*)\]$/)?.[1] || 'string'
+
+			// 只有非 object 的数组类型,且只剩一个子节点(item)时,禁止删除
+			if (itemType !== 'object' && node.value.children.length === 1) {
+				ElMessage.warning('数组必须包含至少一个项结构定义')
+				return
+			}
+		}
+
+		node.value.children.splice(idx, 1)
+	}
+}
+</script>
+
+<template>
+	<div
+		class="schema-node"
+		:class="{
+			'is-root': isRoot,
+			'is-array-item': node.type.startsWith('array[')
+		}"
+	>
+		<!-- 节点头部 -->
+		<div class="node-header">
+			<div class="node-info">
+				<!-- 字段名输入 -->
+				<el-tooltip v-if="hasNameError" :content="nameErrorText" placement="top">
+					<el-input
+						v-if="!isRoot"
+						v-model="node.name"
+						placeholder="Key"
+						size="small"
+						class="name-input"
+						:class="{ 'is-error': hasNameError }"
+					>
+						<template #prefix>
+							<el-icon v-if="hasNameError" class="error-icon"><Warning /></el-icon>
+						</template>
+					</el-input>
+				</el-tooltip>
+
+				<el-input
+					v-else-if="!isRoot"
+					v-model="node.name"
+					placeholder="字段名"
+					size="small"
+					class="name-input"
+				/>
+
+				<span v-else class="root-name">structured_output</span>
+
+				<!-- 类型选择 -->
+				<el-select
+					v-model="node.type"
+					:options="VARIABLE_TYPE_OPTIONS"
+					placeholder="Type"
+					size="small"
+					class="type-select"
+				/>
+
+				<!-- 必填勾选 (仅非根节点) -->
+				<el-tooltip content="是否必填" placement="top" v-if="!isRoot">
+					<el-checkbox v-model="node.required" size="small" border class="required-check"
+						>必填</el-checkbox
+					>
+				</el-tooltip>
+			</div>
+
+			<!-- 删除按钮 (仅非根节点) -->
+			<div class="node-actions" v-if="!isRoot">
+				<el-button size="small" type="danger" link @click="emit('remove')">
+					<el-icon><Delete /></el-icon>
+				</el-button>
+			</div>
+		</div>
+
+		<!-- 节点详细配置 (描述、枚举) -->
+		<div class="node-body">
+			<el-form size="small" label-width="70px" class="config-form">
+				<el-form-item label="描述">
+					<el-input v-model="node.description" placeholder="字段描述" />
+				</el-form-item>
+
+				<!-- 枚举值:仅对简单类型显示 -->
+				<el-form-item v-if="['string', 'number', 'integer'].includes(node.type)" label="枚举值">
+					<el-input
+						v-model="node.enumValues"
+						placeholder="例: name1, name2"
+						type="textarea"
+						:rows="1"
+						autosize
+					/>
+				</el-form-item>
+			</el-form>
+		</div>
+
+		<!-- 子节点区域 (递归) -->
+		<div v-if="showChildren" class="children-container">
+			<div class="children-header">
+				<span class="children-title">
+					<template v-if="node.type.startsWith('array[')">
+						<el-tag size="small" type="warning" effect="plain">Array Items</el-tag>
+						<span class="ml-2">数组项结构 ({{ node.type.match(/^array\[(.*)\]$/)?.[1] }})</span>
+					</template>
+					<template v-else> 子属性 (Properties) </template>
+				</span>
+				<!-- 在 object 和 array[object] 下都显示添加按钮 -->
+				<el-button
+					v-if="node.type === 'object' || node.type.startsWith('array[object]')"
+					size="small"
+					type="primary"
+					link
+					@click="addChild"
+				>
+					<el-icon><Plus /></el-icon> 添加
+				</el-button>
+			</div>
+
+			<div v-for="(child, idx) in node.children" :key="idx" class="child-wrapper">
+				<SchemaTreeNode
+					v-model="node.children![idx]!"
+					:index="idx"
+					:siblings="node.children"
+					@remove="removeChild(idx)"
+				/>
+			</div>
+
+			<div v-if="!node.children || node.children.length === 0" class="empty-tip">
+				{{ node.type.startsWith('array[') ? '请定义数组项的结构' : '暂无子项' }}
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+.schema-node {
+	border: 1px solid #e4e7ed;
+	border-radius: 6px;
+	padding: 12px;
+	margin-bottom: 12px;
+	background: #fff;
+	position: relative;
+	transition: all 0.3s;
+}
+
+.is-root {
+	background: #f5f7fa;
+	border-color: #dcdfe6;
+}
+
+.is-array-item {
+	border-left: 4px solid #e6a23c;
+}
+
+.node-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 12px;
+	padding-bottom: 8px;
+	border-bottom: 1px dashed #ebeef5;
+}
+
+.node-info {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	flex: 1;
+}
+
+.name-input {
+	width: 140px;
+}
+
+.name-input.is-error :deep(.el-input__wrapper) {
+	box-shadow: 0 0 0 1px #f56c6c inset;
+}
+
+.error-icon {
+	color: #f56c6c;
+}
+
+.type-select {
+	width: 130px;
+}
+
+.required-check {
+	margin-left: 4px;
+}
+
+.root-name {
+	font-weight: bold;
+	color: #409eff;
+	margin-right: 10px;
+	font-size: 14px;
+}
+
+.node-body {
+	margin-bottom: 12px;
+}
+
+.config-form :deep(.el-form-item) {
+	margin-bottom: 8px;
+}
+
+.children-container {
+	margin-top: 12px;
+	padding-left: 24px;
+	border-left: 2px solid #f0f2f5;
+}
+
+.children-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 10px;
+}
+
+.children-title {
+	font-size: 12px;
+	color: #909399;
+	font-weight: 600;
+	display: flex;
+	align-items: center;
+	gap: 6px;
+}
+
+.ml-2 {
+	margin-left: 8px;
+}
+
+.child-wrapper {
+	margin-top: 8px;
+}
+
+.empty-tip {
+	font-size: 12px;
+	color: #c0c4cc;
+	text-align: center;
+	padding: 8px 0;
+	font-style: italic;
+}
+</style>

+ 422 - 0
apps/web/src/nodes/src/llm/StructModal.vue

@@ -0,0 +1,422 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import SchemaTreeNode, { type SchemaNode } from './SchemaTreeNode.vue'
+
+interface SchemaData {
+	type: 'object'
+	properties: Record<string, any>
+	required?: string[]
+	additionalProperties?: boolean
+}
+
+const props = defineProps<{
+	modelValue: SchemaData | null
+	visible: boolean
+}>()
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', value: SchemaData): void
+	(e: 'close'): void
+}>()
+
+const activeTab = ref<'visual' | 'json'>('visual')
+
+const dialogVisible = computed({
+	get: () => props.visible,
+	set: (value) => {
+		if (!value) close()
+	}
+})
+
+// 根节点模型
+const rootNode = ref<SchemaNode>({
+	name: 'root',
+	type: 'object',
+	description: '',
+	enumValues: '',
+	required: false,
+	children: []
+})
+
+const jsonSchema = ref<string>('')
+
+// --- 核心转换逻辑  ---
+
+const getArrayItemType = (type: string) => type.match(/^array\[(.*)\]$/)?.[1] || 'string'
+
+const parseEnumValues = (value?: string) =>
+	(value || '')
+		.split(',')
+		.map((v) => v.trim())
+		.filter(Boolean)
+
+const parseSchemaToNode = (schema: any): SchemaNode[] => {
+	if (!schema || !schema.properties) return []
+
+	const nodes: SchemaNode[] = []
+	const requiredFields = new Set(schema.required || [])
+
+	Object.keys(schema.properties).forEach((key) => {
+		const prop = schema.properties[key]
+
+		// 确定显示类型字符串
+		let displayType = prop.type || 'string'
+		if (prop.type === 'array' && prop.items) {
+			const itemType = prop.items.type || 'string'
+			displayType = `array[${itemType}]`
+		}
+
+		const node: SchemaNode = {
+			name: key,
+			type: displayType,
+			description: prop.description || '',
+			enumValues: Array.isArray(prop.enum) ? prop.enum.join(', ') : '',
+			required: requiredFields.has(key),
+			children: []
+		}
+
+		// 处理子节点
+		if (prop.type === 'object' && prop.properties) {
+			node.children = parseSchemaToNode(prop)
+		} else if (prop.type === 'array' && prop.items) {
+			const itemSchema = prop.items
+			const itemType = itemSchema.type || 'string'
+
+			// 关键修改:如果数组项是 object,不创建中间 item 节点,直接解析属性到 children
+			if (itemType === 'object') {
+				node.children = itemSchema.properties ? parseSchemaToNode(itemSchema) : []
+			}
+			// 如果数组项是简单类型或其他,保留原有的 item 节点结构
+			else {
+				const itemNode: SchemaNode = {
+					name: 'item',
+					type: itemType,
+					description: itemSchema.description || '',
+					enumValues: Array.isArray(itemSchema.enum) ? itemSchema.enum.join(', ') : '',
+					required: false,
+					children: []
+				}
+
+				// 如果 item 是 object 但没有 properties (空对象),或者 item 是其他复杂类型
+				if (itemType === 'object' && itemSchema.properties) {
+					itemNode.children = parseSchemaToNode(itemSchema)
+				}
+
+				node.children = [itemNode]
+			}
+		}
+
+		nodes.push(node)
+	})
+	return nodes
+}
+
+const buildSchemaFromNode = (nodes: SchemaNode[]) => {
+	const properties: any = {}
+	const required: string[] = []
+
+	nodes.forEach((node) => {
+		if (!node.name || !node.name.trim()) return
+
+		const prop: any = {
+			description: node.description || undefined
+		}
+
+		// 处理枚举 (仅针对非数组的基本类型)
+		if (!node.type.startsWith('array[') && node.enumValues && node.enumValues.trim()) {
+			const enums = parseEnumValues(node.enumValues)
+			if (enums.length > 0) {
+				prop.enum = enums
+			}
+		}
+
+		// 处理 Object 类型
+		if (node.type === 'object') {
+			const nested = buildSchemaFromNode(node.children || [])
+			prop.type = 'object'
+			prop.properties = nested.properties
+			prop.required = nested.required
+			prop.additionalProperties = false
+		}
+		// 处理 Array 类型
+		else if (node.type.startsWith('array[')) {
+			const itemType = getArrayItemType(node.type)
+			const itemSchema: any = {
+				type: itemType
+			}
+
+			prop.type = 'array'
+
+			// 关键修改:如果是 array[object],children 直接就是 object 的属性列表
+			if (itemType === 'object') {
+				const nested = buildSchemaFromNode(node.children || [])
+				itemSchema.properties = nested.properties
+				itemSchema.required = nested.required
+				itemSchema.additionalProperties = false
+			}
+			// 如果是 array[string/number...],children[0] 是 item 节点
+			else {
+				if (node.children && node.children.length > 0) {
+					const itemNode = node.children[0]
+
+					// 设置 item 的描述
+					if (itemNode.description) {
+						itemSchema.description = itemNode.description
+					}
+
+					// 设置 item 的枚举
+					if (itemNode.enumValues && itemNode.enumValues.trim()) {
+						const enums = parseEnumValues(itemNode.enumValues)
+						if (enums.length > 0) {
+							itemSchema.enum = enums
+						}
+					}
+				}
+			}
+
+			prop.items = itemSchema
+		}
+		// 处理基本类型
+		else {
+			prop.type = node.type
+		}
+
+		properties[node.name] = prop
+
+		if (node.required) {
+			required.push(node.name)
+		}
+	})
+
+	return { properties, required }
+}
+
+// --- 监听与初始化 ---
+
+const syncFromModelValue = (value: SchemaData | null) => {
+	if (value) {
+		rootNode.value.children = parseSchemaToNode(value)
+		jsonSchema.value = JSON.stringify(value, null, 2)
+		return
+	}
+
+	rootNode.value.children = []
+	jsonSchema.value = ''
+}
+
+watch(
+	() => props.modelValue,
+	(newVal) => {
+		syncFromModelValue(newVal)
+	},
+	{ immediate: true }
+)
+
+watch(
+	() => props.visible,
+	(visible) => {
+		if (visible) {
+			activeTab.value = 'visual'
+			syncFromModelValue(props.modelValue)
+		}
+	}
+)
+
+// 保存
+const save = () => {
+	// 在保存前再次检查是否有重复名称
+	const checkDuplicates = (nodes: SchemaNode[]): boolean => {
+		const names = new Set<string>()
+		for (const node of nodes) {
+			if (node.name && names.has(node.name)) return true
+			names.add(node.name)
+			if (node.children && checkDuplicates(node.children)) return true
+		}
+		return false
+	}
+
+	if (checkDuplicates(rootNode.value.children || [])) {
+		ElMessage.error('存在重复的字段名,请修正后保存')
+		return
+	}
+
+	if (activeTab.value === 'visual') {
+		const { properties, required } = buildSchemaFromNode(rootNode.value.children || [])
+
+		const schema: SchemaData = {
+			type: 'object',
+			properties,
+			required,
+			additionalProperties: false
+		}
+
+		jsonSchema.value = JSON.stringify(schema, null, 2)
+		emit('update:modelValue', schema)
+		ElMessage.success('保存成功')
+	} else {
+		try {
+			const parsed = JSON.parse(jsonSchema.value)
+			if (parsed.type !== 'object' || typeof parsed.properties !== 'object') {
+				throw new Error('Schema 根节点必须是 object 且包含 properties')
+			}
+			const normalized: SchemaData = {
+				type: 'object',
+				properties: parsed.properties || {},
+				required: Array.isArray(parsed.required) ? parsed.required : [],
+				additionalProperties: Boolean(parsed.additionalProperties)
+			}
+			jsonSchema.value = JSON.stringify(normalized, null, 2)
+			emit('update:modelValue', normalized)
+			ElMessage.success('保存成功')
+		} catch (e: any) {
+			ElMessage.error('JSON 格式错误: ' + e.message)
+		}
+	}
+}
+
+const clearConfig = () => {
+	rootNode.value.children = []
+	const emptySchema = {
+		type: 'object',
+		properties: {},
+		required: [],
+		additionalProperties: false
+	}
+	jsonSchema.value = JSON.stringify(emptySchema, null, 2)
+	emit('update:modelValue', emptySchema)
+}
+
+const close = () => {
+	emit('close')
+}
+</script>
+
+<template>
+	<el-dialog
+		v-model="dialogVisible"
+		title="结构化输出 Schema 配置"
+		width="900px"
+		:close-on-click-modal="false"
+		append-to-body
+		@close="close"
+	>
+		<div class="tab-header">
+			<el-segmented
+				v-model="activeTab"
+				:options="[
+					{ label: 'Visual Editor', value: 'visual' },
+					{ label: 'JSON Schema', value: 'json' }
+				]"
+			/>
+		</div>
+
+		<!-- Visual Editor -->
+		<div v-if="activeTab === 'visual'" class="visual-editor">
+			<div class="schema-root-container">
+				<div class="root-label">
+					<span class="icon">📦</span>
+					<span class="text">Root Object (structured_output)</span>
+				</div>
+
+				<div class="tree-content">
+					<!-- 递归渲染根节点的子项,传入 siblings 用于校验 -->
+					<SchemaTreeNode
+						v-for="(child, index) in rootNode.children"
+						:key="index"
+						v-model="rootNode.children[index]"
+						:index="index"
+						:siblings="rootNode.children"
+						@remove="rootNode.children.splice(index, 1)"
+					/>
+
+					<div v-if="!rootNode.children || rootNode.children.length === 0" class="empty-state">
+						<el-empty description="暂无字段,请添加顶级字段" :image-size="60" />
+					</div>
+
+					<el-button
+						type="primary"
+						plain
+						icon="Plus"
+						@click="
+							rootNode.children?.push({
+								name: '',
+								type: 'string',
+								description: '',
+								enumValues: '',
+								required: false,
+								children: []
+							})
+						"
+						class="add-root-btn"
+					>
+						添加顶级字段
+					</el-button>
+				</div>
+			</div>
+		</div>
+
+		<!-- JSON Editor -->
+		<div v-else class="json-editor">
+			<CodeEditor v-model="jsonSchema" language="json" :height="480" />
+		</div>
+
+		<template #footer>
+			<el-button @click="clearConfig">清空</el-button>
+			<el-button @click="close">取消</el-button>
+			<el-button type="primary" @click="save">保存配置</el-button>
+		</template>
+	</el-dialog>
+</template>
+
+<style scoped>
+.tab-header {
+	margin-bottom: 16px;
+	display: flex;
+	justify-content: center;
+}
+
+.visual-editor {
+	max-height: 60vh;
+	overflow-y: auto;
+	padding: 4px;
+}
+
+.schema-root-container {
+	background: #f9fafc;
+	border: 1px solid #e4e7ed;
+	border-radius: 8px;
+	padding: 20px;
+}
+
+.root-label {
+	font-weight: 600;
+	color: #303133;
+	margin-bottom: 20px;
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	font-size: 14px;
+}
+
+.tree-content {
+	padding-left: 0;
+}
+
+.add-root-btn {
+	width: 100%;
+	margin-top: 16px;
+	border-style: dashed;
+}
+
+.json-editor {
+	height: 500px;
+	border: 1px solid #e4e7ed;
+	border-radius: 4px;
+}
+
+.empty-state {
+	padding: 20px 0;
+}
+</style>

+ 122 - 0
apps/web/src/nodes/src/llm/index.ts

@@ -0,0 +1,122 @@
+import { NodeConnectionTypes, type INodeType, type INodeDataBaseSchema } from '../../Interface'
+import Setter from './setter.vue'
+
+export type LLMPromptRole = 'system' | 'user' | 'assistant'
+
+export type LLMPromptItem = {
+	role: LLMPromptRole
+	content: string
+}
+
+export type LLMJsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
+
+export type LLMJsonSchemaProperty = {
+	type: LLMJsonSchemaType | string
+	description?: string
+	enum?: Array<string | number | boolean>
+	items?: LLMJsonSchemaProperty
+	properties?: Record<string, LLMJsonSchemaProperty>
+	required?: string[]
+	additionalProperties?: boolean
+}
+
+export type LLMJsonSchema = {
+	type: 'object'
+	properties: Record<string, LLMJsonSchemaProperty>
+	required: string[]
+	additionalProperties: boolean
+}
+
+export type LLMData = INodeDataBaseSchema & {
+	model_id: string
+	context: string
+	vision_enabled: boolean
+	reasoning_tag_separation_enabled: boolean
+	structured_output_enabled: boolean
+	structured_output: LLMJsonSchema
+	prompt_template: LLMPromptItem[]
+}
+
+export const DEFAULT_LLM_OUTPUTS: LLMData['outputs'] = [
+	{
+		name: 'text',
+		describe: '生成内容',
+		type: 'string'
+	},
+	{
+		name: 'reasoning_content',
+		describe: '推理内容',
+		type: 'string'
+	},
+	{
+		name: 'usage',
+		describe: '模型用量信息',
+		type: 'object'
+	},
+	{
+		name: 'structured_output',
+		describe: '结构化对象',
+		type: 'object'
+	}
+]
+
+export const DEFAULT_STRUCTURED_OUTPUT: LLMJsonSchema = {
+	type: 'object',
+	properties: {
+		name: {
+			type: 'string',
+			description: '名称',
+			enum: ['name1', 'name2', 'name3']
+		}
+	},
+	required: ['name'],
+	additionalProperties: false
+}
+
+export const DEFAULT_PROMPT_TEMPLATE: LLMPromptItem[] = [
+	{
+		role: 'system',
+		content: ''
+	},
+	{
+		role: 'user',
+		content: ''
+	},
+	{
+		role: 'assistant',
+		content: ''
+	}
+]
+
+export const llmNode: INodeType = {
+	version: ['1'],
+	displayName: 'LLM',
+	name: 'llm',
+	Setter,
+	description: '调用大语言模型回答问题或者对自然语言进行处理',
+	group: 'data',
+	icon: 'lucide:brain-circuit',
+	iconColor: '#6172F3',
+	inputs: [NodeConnectionTypes.main],
+	outputs: [NodeConnectionTypes.main],
+	validate: (data: LLMData) => {
+		if (!data?.model_id) return '请选择模型'
+		const hasPrompt = (data.prompt_template || []).some((item) => item.content?.trim())
+		return hasPrompt ? false : '请至少填写一条提示词'
+	},
+	getSubtitle: (data: LLMData) => data?.model_id || '',
+	schema: {
+		appAgentId: '',
+		parentId: '',
+		position: {
+			x: 20,
+			y: 30
+		},
+		width: 96,
+		height: 96,
+		selected: false,
+		nodeType: 'llm',
+		zIndex: 1,
+		data: {}
+	}
+}

+ 516 - 0
apps/web/src/nodes/src/llm/setter.vue

@@ -0,0 +1,516 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { aiModel } from '@repo/api-service'
+import { Icon, IconButton } from '@repo/ui'
+
+import NodeRuntimeConfig from '@/nodes/_base/NodeRuntimeConfig.vue'
+import VarSelect from '@/nodes/_base/VarSelect.vue'
+import VarInput from '@/nodes/_base/VarInput.vue'
+import { useSetterModel } from '../_shared/useSetterModel'
+import PromptModal from '@/features/PromptModal.vue'
+import { DocumentCopy } from '@element-plus/icons-vue'
+import StructModal from './StructModal.vue'
+
+import type { LLMData, LLMPromptItem } from './index'
+
+type LLMModelItem = NonNullable<
+	Awaited<ReturnType<typeof aiModel.postModelPageList>>['result']
+>['model'][number]
+
+const props = defineProps<{
+	data: LLMData
+}>()
+
+const emit = defineEmits<{
+	(e: 'update', data: LLMData): void
+}>()
+
+const formData = useSetterModel<LLMData>(props, emit)
+const modelsLoading = ref(false)
+const chatModels = ref<LLMModelItem[]>([])
+const promptTemplateDialogVisible = ref(false)
+const editPrompt = ref<LLMPromptItem>()
+/** TODO: 提示词编辑器双向绑定存在问题 */
+const editKey = ref(0)
+const structModalVisible = ref(false)
+
+interface SchemaPreviewNode {
+	id: string
+	name: string
+	type: string
+	description: string
+	required: boolean
+	enumValues: Array<string | number | boolean>
+	children: SchemaPreviewNode[]
+}
+
+// 打开 Schema 编辑器
+const openSchemaDialog = () => {
+	structModalVisible.value = true
+}
+
+// 关闭 Schema 编辑器
+const handleSchemaClose = () => {
+	structModalVisible.value = false
+}
+
+const modelLabel = (item: LLMModelItem) => (item.title ? `${item.title} (${item.name})` : item.name)
+
+const formatSchemaType = (schema: any) => {
+	if (schema?.type === 'array') {
+		const itemType = schema.items?.type || 'string'
+		return `array[${itemType}]`
+	}
+	return schema?.type || 'string'
+}
+
+const buildSchemaPreviewTree = (schema: any, parentPath = 'structured_output'): SchemaPreviewNode[] => {
+	if (!schema?.properties) return []
+
+	const required = new Set<string>(Array.isArray(schema.required) ? schema.required : [])
+
+	return Object.entries(schema.properties).map(([name, value]) => {
+		const item = value as any
+		const isArrayObject = item.type === 'array' && item.items?.type === 'object'
+		const childSchema = item.type === 'object' ? item : isArrayObject ? item.items : null
+		const currentPath = `${parentPath}.${name}`
+
+		return {
+			id: currentPath,
+			name,
+			type: formatSchemaType(item),
+			description: item.description || '',
+			required: required.has(name),
+			enumValues: Array.isArray(item.enum) ? item.enum : [],
+			children: childSchema?.properties ? buildSchemaPreviewTree(childSchema, currentPath) : []
+		}
+	})
+}
+
+const structuredPreviewTree = computed(() =>
+	buildSchemaPreviewTree(formData.value.structured_output)
+)
+
+const fetchChatModels = async (keyword = '') => {
+	const res = await aiModel.postModelPageList({
+		keyword: keyword.trim(),
+		type: 'KnowledgeQA',
+		source: '',
+		pageIndex: 1,
+		pageSize: 20
+	})
+	if (res?.isSuccess) {
+		const nextModels = (res.result?.model || []) as LLMModelItem[]
+		chatModels.value = nextModels
+	}
+}
+
+const searchChatModels = async (keyword: string) => {
+	modelsLoading.value = true
+	try {
+		await fetchChatModels(keyword)
+	} finally {
+		modelsLoading.value = false
+	}
+}
+
+/**
+ * 提示词模版选择
+ * @param msg
+ */
+function openPromptTemplatePicker(msg: LLMPromptItem) {
+	editPrompt.value = msg
+	promptTemplateDialogVisible.value = true
+}
+
+/**
+ * 绑定提示词模版
+ * @param content
+ */
+function handlePromptTemplateConfirm(content: string) {
+	if (editPrompt.value) {
+		editPrompt.value.content = content
+		promptTemplateDialogVisible.value = false
+		editKey.value++
+	}
+}
+
+onMounted(async () => {
+	await searchChatModels('')
+})
+</script>
+
+<template>
+	<el-scrollbar class="w-full box-border p-12px">
+		<div class="llm-setter">
+			<section class="section-block">
+				<div class="section-title-row">
+					<label class="section-title">模型 <span class="text-#f04438">*</span></label>
+				</div>
+				<div class="field-row">
+					<el-select
+						v-model="formData.model_id"
+						filterable
+						remote
+						reserve-keyword
+						clearable
+						class="w-full"
+						:remote-method="searchChatModels"
+						:loading="modelsLoading"
+						placeholder="请选择模型"
+					>
+						<el-option
+							v-for="model in chatModels"
+							:key="model.id"
+							:label="modelLabel(model)"
+							:value="model.id"
+						/>
+					</el-select>
+					<div class="field-hint">选择用于该节点的大语言模型。</div>
+				</div>
+			</section>
+
+			<section class="section-block">
+				<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>
+			</section>
+
+			<section class="section-block">
+				<div class="section-header">
+					<label class="section-title">提示词</label>
+				</div>
+				<div class="message-list">
+					<div
+						v-for="(message, index) in formData.prompt_template"
+						:key="`${message.role}-${index}`"
+						class="message-card"
+					>
+						<div class="message-card__header">
+							<div class="message-role-row">
+								<el-select
+									v-model="message.role"
+									:disabled="!index"
+									class="role-select"
+									size="small"
+								>
+									<el-option label="SYSTEM" value="system" disabled />
+									<el-option label="USER" value="user" />
+									<el-option label="ASSISTANT" value="assistant" />
+								</el-select>
+								<el-button
+									class="prompt-template-trigger"
+									text
+									:icon="DocumentCopy"
+									@click="openPromptTemplatePicker(message)"
+								>
+									模板选择
+								</el-button>
+							</div>
+							<IconButton
+								link
+								icon="lucide:trash-2"
+								class="text-#f04438"
+								v-if="index"
+								@click="formData.prompt_template.splice(index, 1)"
+							/>
+						</div>
+						<VarInput
+							:key="editKey"
+							v-model="message.content"
+							class="message-editor"
+							:rows="4"
+							placeholder="在这里写你的提示词,输入输入 '/' 插入变量"
+						/>
+					</div>
+				</div>
+				<el-button
+					class="add-message-btn"
+					text
+					@click="formData.prompt_template.push({ role: 'user', content: '' })"
+				>
+					+ 添加消息
+				</el-button>
+			</section>
+
+			<section class="section-block">
+				<el-collapse>
+					<el-collapse-item title="" name="2">
+						<template #title>
+							<div class="flex items-center justify-between beautify">
+								<label class="text-14px font-bold text-gray-700">输出变量</label>
+							</div>
+						</template>
+						<ul>
+							<li v-for="output in formData.outputs" :key="output.name">
+								<div>
+									<span class="text-#333">{{ output.name }}</span>
+									<span class="text-#999 ml-8px">{{ output.type }}</span>
+								</div>
+								<div class="text-#666">{{ output.describe }}</div>
+							</li>
+						</ul>
+					</el-collapse-item>
+				</el-collapse>
+
+				<div class="structured-block">
+					<span class="flex items-center gap-12px text-12px text-text-secondary">
+						<el-switch v-model="formData.structured_output_enabled" />
+						<span>结构化输出</span>
+					</span>
+
+					<div v-if="formData.structured_output_enabled">
+						<div class="structured-block__header">
+							<div class="structured-block__title">structured_output</div>
+							<el-button text @click="openSchemaDialog">
+								<el-icon><Icon icon="lucide:pencil" /></el-icon>
+								配置
+							</el-button>
+						</div>
+						<div class="structured-preview">
+							<el-tree
+								v-if="structuredPreviewTree.length"
+								:data="structuredPreviewTree"
+								node-key="id"
+								default-expand-all
+								expand-on-click-node="false"
+								class="schema-tree"
+							>
+								<template #default="{ data }">
+									<div class="schema-tree-node">
+										<div class="schema-tree-node__main">
+											<span class="schema-tree-node__name">{{ data.name }}</span>
+											<span class="schema-tree-node__type">{{ data.type }}</span>
+											<span v-if="data.required" class="schema-tree-node__required">必填</span>
+										</div>
+										<div v-if="data.description" class="schema-tree-node__desc">
+											{{ 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(' | ') }}
+										</div>
+									</div>
+								</template>
+							</el-tree>
+							<el-empty v-else description="暂无结构化字段" :image-size="60" />
+						</div>
+					</div>
+				</div>
+			</section>
+
+			<NodeRuntimeConfig v-model="formData" />
+		</div>
+	</el-scrollbar>
+
+	<PromptModal
+		v-model="promptTemplateDialogVisible"
+		:field="editPrompt?.role === 'system' ? 'systemPrompt' : 'contextTemplate'"
+		mode=""
+		@confirm="handlePromptTemplateConfirm"
+	/>
+
+	<StructModal
+		:visible="structModalVisible"
+		v-model:modelValue="formData.structured_output"
+		@close="handleSchemaClose"
+	/>
+</template>
+
+<style scoped lang="less">
+.llm-setter {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+}
+
+.section-block {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	padding-bottom: 16px;
+	border-bottom: 1px solid #eef2f7;
+}
+
+.section-title-row,
+.section-header,
+.section-row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+}
+
+.section-title {
+	font-size: 14px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.field-row {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.field-hint {
+	font-size: 12px;
+	color: #667085;
+}
+
+.message-list {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.message-card {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	padding: 14px;
+	border: 1px solid #eaecf0;
+	border-radius: 12px;
+	background: #f9fafb;
+}
+
+.message-card__header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+}
+
+.message-role-row {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+
+.role-select {
+	width: 128px;
+}
+
+.message-badge {
+	font-size: 12px;
+	color: #98a2b3;
+}
+
+.message-editor {
+	:deep(.el-input__wrapper) {
+		border-radius: 10px;
+	}
+}
+
+.add-message-btn {
+	width: 100%;
+	justify-content: center;
+	font-size: 14px;
+	font-weight: 600;
+	background: #f2f4f7;
+	border-radius: 10px;
+}
+
+.structured-block {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	padding: 12px;
+	border: 1px solid #eaecf0;
+	border-radius: 12px;
+	background: #fff;
+}
+
+.structured-block__header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+}
+
+.structured-block__title {
+	font-size: 13px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.structured-preview {
+	display: flex;
+	flex-direction: column;
+	gap: 6px;
+}
+
+.schema-tree {
+	--el-tree-node-content-height: auto;
+	--el-tree-node-hover-bg-color: transparent;
+	background: #fff;
+	border: 1px solid #eaecf0;
+	border-radius: 12px;
+	padding: 6px 8px;
+}
+
+.schema-tree :deep(.el-tree-node__content) {
+	height: auto;
+	padding: 8px 0;
+	background: transparent;
+	align-items: flex-start;
+}
+
+.schema-tree :deep(.el-tree-node__expand-icon) {
+	margin-top: 3px;
+	color: #98a2b3;
+}
+
+.schema-tree :deep(.el-tree-node__children) {
+	padding-left: 18px;
+}
+
+.schema-tree-node {
+	display: flex;
+	flex-direction: column;
+	gap: 2px;
+	min-width: 0;
+	padding: 2px 0;
+}
+
+.schema-tree-node__main {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: baseline;
+	gap: 8px;
+}
+
+.schema-tree-node__name {
+	font-size: 15px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.schema-tree-node__type {
+	font-size: 13px;
+	color: #667085;
+}
+
+.schema-tree-node__required {
+	font-size: 12px;
+	font-weight: 700;
+	color: #f97316;
+}
+
+.schema-tree-node__desc {
+	margin-left: 0;
+	font-size: 13px;
+	line-height: 1.5;
+	color: #667085;
+	word-break: break-word;
+}
+
+.schema-tree-node__enum {
+	font-size: 12px;
+	line-height: 1.4;
+	color: #98a2b3;
+	word-break: break-word;
+}
+</style>

+ 28 - 25
apps/web/src/views/FlowManagement.vue

@@ -47,8 +47,7 @@
 
 		<div class="panel">
 			<div class="toolbar">
-				<el-input v-model="keyword" :placeholder="t('pages.management.searchPlaceholder')" clearable
-					class="search-input">
+				<el-input v-model="keyword" placeholder="输入关键字搜索" clearable class="search-input">
 					<template #prefix>
 						<el-icon>
 							<Search />
@@ -62,27 +61,23 @@
 							<RefreshRight />
 						</el-icon>
 					</el-button>
-
-					<span class="meta-pill">
-						{{
-							t('pages.management.pageInfo', {
-								current: pageData.currentPage || 1,
-								total: pageData.totalPages || 1
-							})
-						}}
-					</span>
-					<span class="meta-pill">
-						{{ t('pages.management.pageSize', { size: pageData.pageSize }) }}
-					</span>
 				</div>
 			</div>
 
 			<div v-loading="loading" class="card-grid">
-				<div v-for="row in filteredAgents" :key="row.id" class="agent-card" @click="handleRowClick(row)">
+				<div
+					v-for="row in filteredAgents"
+					:key="row.id"
+					class="agent-card"
+					@click="handleRowClick(row)"
+				>
 					<div class="cover">
-						<img v-if="row.profilePhoto"
-							:src="row.profilePhoto ? `/File/GetImage?fileId=${row.profilePhoto}` : undefined" :alt="row.name"
-							class="cover-image" />
+						<img
+							v-if="row.profilePhoto"
+							:src="row.profilePhoto ? `/File/GetImage?fileId=${row.profilePhoto}` : undefined"
+							:alt="row.name"
+							class="cover-image"
+						/>
 						<div v-else class="cover-fallback">
 							<div class="fallback-monogram">{{ row.name?.slice(0, 1) || 'A' }}</div>
 						</div>
@@ -121,13 +116,22 @@
 					</div>
 				</div>
 
-				<el-empty v-if="!filteredAgents.length && !loading"
-					:description="keyword ? emptyText.filtered : emptyText.default" class="empty-state" />
+				<el-empty
+					v-if="!filteredAgents.length && !loading"
+					:description="keyword ? emptyText.filtered : emptyText.default"
+					class="empty-state"
+				/>
 			</div>
 
-			<div class="pagination">
-				<el-pagination background layout="prev, pager, next, total" :current-page="pageIndex"
-					:page-size="pageData.pageSize || 10" :total="pageData.totalCount" @current-change="handlePageChange" />
+			<div v-if="pageData.totalCount" class="pagination">
+				<el-pagination
+					background
+					layout="prev, pager, next, total"
+					:current-page="pageIndex"
+					:page-size="pageData.pageSize || 10"
+					:total="pageData.totalCount"
+					@current-change="handlePageChange"
+				/>
 			</div>
 		</div>
 	</div>
@@ -171,7 +175,7 @@ const fallbackText = computed(
 const emptyText = computed(
 	() =>
 		({
-			default: t('pages.management.empty.default'),
+			default: '暂无编排数据',
 			filtered: t('pages.management.empty.filtered')
 		}) as const
 )
@@ -627,7 +631,6 @@ onMounted(() => {
 }
 
 @media (max-width: 960px) {
-
 	.page-header,
 	.toolbar {
 		flex-direction: column;

+ 482 - 164
apps/web/src/views/agent/components/EditModal.vue

@@ -1,18 +1,34 @@
 <template>
-	<el-dialog v-model="visible" :title="formId ? '编辑智能体' : '新建智能体'" class="agent-modal" fullscreen>
+	<el-dialog
+		v-model="visible"
+		:title="formId ? '编辑智能体' : '新建智能体'"
+		class="agent-modal"
+		fullscreen
+	>
 		<div class="modal-wrap">
-			<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="agent-form" label-width="120px">
+			<el-form
+				ref="formRef"
+				:model="form"
+				:rules="rules"
+				label-position="left"
+				class="agent-form"
+				label-width="120px"
+			>
 				<el-tabs v-model="activeTab" tab-position="left" class="settings-tabs">
 					<el-tab-pane label="基础信息" name="basic">
 						<div class="tab-intro">配置智能体的基本信息</div>
 						<div class="collapse-body">
 							<el-form-item label="运行模式" prop="mode">
 								<div class="switch-wrap flex items-center gap-2">
-									<el-segmented v-model="form.mode" class="selection-segmented"
-										:options="[{ label: '快速问答', value: 'quick-answer' }, { label: '智能推理', value: 'smart-reasoning' }]" />
-									<div class="field-tip">
-										多步思考,深度分析复杂问题。
-									</div>
+									<el-segmented
+										v-model="form.mode"
+										class="selection-segmented"
+										:options="[
+											{ label: '快速问答', value: 'quick-answer' },
+											{ label: '智能推理', value: 'smart-reasoning' }
+										]"
+									/>
+									<div class="field-tip">多步思考,深度分析复杂问题。</div>
 								</div>
 							</el-form-item>
 							<div class="mode-tip ml-120px">
@@ -36,7 +52,12 @@
 								</div>
 							</el-form-item>
 							<el-form-item label="描述" prop="description">
-								<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入智能体描述" />
+								<el-input
+									v-model="form.description"
+									type="textarea"
+									:rows="3"
+									placeholder="请输入智能体描述"
+								/>
 								<div class="field-tip">简要描述智能体的用途和特点。</div>
 							</el-form-item>
 							<el-form-item label="系统提示词" prop="config.basic_config.system_prompt">
@@ -44,41 +65,74 @@
 									<div class="prompt-toolbar">
 										<div class="prompt-variable-row">
 											<span class="prompt-variable-label">支持变量</span>
-											<el-tag v-for="variable in systemPromptVariables" :key="variable"
-												class="prompt-variable-tag" type="info" effect="plain"
-												@click="insertPromptVariable('systemPrompt', variable)">
+											<el-tag
+												v-for="variable in systemPromptVariables"
+												:key="variable"
+												class="prompt-variable-tag"
+												type="info"
+												effect="plain"
+												@click="insertPromptVariable('systemPrompt', variable)"
+											>
 												{{ formatPromptVariable(variable) }}
 											</el-tag>
 										</div>
-										<el-button class="prompt-template-trigger" text :icon="DocumentCopy"
-											@click="openPromptTemplatePicker('systemPrompt')">
+										<el-button
+											class="prompt-template-trigger"
+											text
+											:icon="DocumentCopy"
+											@click="openPromptTemplatePicker('systemPrompt')"
+										>
 											模板选择
 										</el-button>
 									</div>
-									<el-input ref="systemPromptInputRef" v-model="form.config.basic_config.system_prompt" mode="prompt"
-										type="textarea" :rows="8" placeholder="请输入系统提示词,可直接编写角色、目标、约束和回答风格" />
+									<el-input
+										ref="systemPromptInputRef"
+										v-model="form.config.basic_config.system_prompt"
+										mode="prompt"
+										type="textarea"
+										:rows="8"
+										placeholder="请输入系统提示词,可直接编写角色、目标、约束和回答风格"
+									/>
 								</div>
 								<div class="field-tip">自定义系统提示词,定义智能体的行为和角色。</div>
 							</el-form-item>
-							<el-form-item v-if="form.mode === 'quick-answer'" label="上下文模板"
-								prop="config.basic_config.context_template">
+							<el-form-item
+								v-if="form.mode === 'quick-answer'"
+								label="上下文模板"
+								prop="config.basic_config.context_template"
+							>
 								<div class="prompt-field">
 									<div class="prompt-toolbar">
 										<div class="prompt-variable-row">
 											<span class="prompt-variable-label">支持变量</span>
-											<el-tag v-for="variable in quickAnswerVariables" :key="variable"
-												class="prompt-variable-tag" type="info" effect="plain"
-												@click="insertPromptVariable('contextTemplate', variable)">
+											<el-tag
+												v-for="variable in quickAnswerVariables"
+												:key="variable"
+												class="prompt-variable-tag"
+												type="info"
+												effect="plain"
+												@click="insertPromptVariable('contextTemplate', variable)"
+											>
 												{{ formatPromptVariable(variable) }}
 											</el-tag>
 										</div>
-										<el-button class="prompt-template-trigger" text :icon="DocumentCopy"
-											@click="openPromptTemplatePicker('contextTemplate')">
+										<el-button
+											class="prompt-template-trigger"
+											text
+											:icon="DocumentCopy"
+											@click="openPromptTemplatePicker('contextTemplate')"
+										>
 											模板选择
 										</el-button>
 									</div>
-									<el-input ref="contextTemplateInputRef" v-model="form.config.basic_config.context_template"
-										mode="prompt" type="textarea" :rows="5" placeholder="请输入上下文模板,用于约束问答模式下的上下文组织方式" />
+									<el-input
+										ref="contextTemplateInputRef"
+										v-model="form.config.basic_config.context_template"
+										mode="prompt"
+										type="textarea"
+										:rows="5"
+										placeholder="请输入上下文模板,用于约束问答模式下的上下文组织方式"
+									/>
 								</div>
 								<div class="field-tip">定义如何将检索到的内容格式化后传递给模型。</div>
 							</el-form-item>
@@ -89,22 +143,42 @@
 						<div class="tab-intro">配置智能体的模型参数</div>
 						<div class="collapse-body">
 							<el-form-item label="模型" prop="config.model_config.model_id">
-								<el-select v-model="form.config.model_config.model_id" filterable remote reserve-keyword
-									:remote-method="searchChatModels" :loading="chatModelsLoading" style="width: 100%">
-									<el-option v-for="model in chatModels" :key="model.id" :label="modelLabel(model)" :value="model.id" />
+								<el-select
+									v-model="form.config.model_config.model_id"
+									filterable
+									remote
+									reserve-keyword
+									:remote-method="searchChatModels"
+									:loading="chatModelsLoading"
+									style="width: 100%"
+								>
+									<el-option
+										v-for="model in chatModels"
+										:key="model.id"
+										:label="modelLabel(model)"
+										:value="model.id"
+									/>
 								</el-select>
 								<div class="field-tip">选择智能体使用的大语言模型。</div>
 							</el-form-item>
 							<el-form-item label="温度" prop="config.model_config.temperature">
 								<div class="switch-wrap w-full flex items-center gap-2">
-									<el-slider v-model="form.config.model_config.temperature" :min="0" :max="1" :step="0.1"
-										style="width: 50%" />
+									<el-slider
+										v-model="form.config.model_config.temperature"
+										:min="0"
+										:max="1"
+										:step="0.1"
+										style="width: 50%"
+									/>
 									<div class="field-tip">控制输出的随机性,0 最确定,1 最随机。</div>
 								</div>
 							</el-form-item>
 							<el-form-item label="最大 Token 数" prop="config.model_config.max_completion_tokens">
-								<el-input-number v-model="form.config.model_config.max_completion_tokens" :min="1"
-									style="width: 100%" />
+								<el-input-number
+									v-model="form.config.model_config.max_completion_tokens"
+									:min="1"
+									style="width: 100%"
+								/>
 								<div class="field-tip">限制模型单次回复可生成的最大 Token 数。</div>
 							</el-form-item>
 							<el-form-item label="思考模式" prop="config.model_config.thinking">
@@ -112,7 +186,6 @@
 									<el-switch v-model="form.config.model_config.thinking" />
 									<div class="field-tip">启用模型的扩展思考能力,需要模型本身支持。</div>
 								</div>
-
 							</el-form-item>
 						</div>
 					</el-tab-pane>
@@ -122,32 +195,66 @@
 						<div class="collapse-body">
 							<el-form-item label="关联知识库" prop="config.kb_config.knowledge_bases">
 								<div class="selection-panel">
-									<el-segmented v-model="form.config.kb_config.kb_selection_mode" class="selection-segmented"
-										:options="selectionModeOptions" />
-									<el-checkbox-group v-if="form.config.kb_config.kb_selection_mode === 'selected'"
-										v-model="form.config.kb_config.knowledge_bases" class="checkbox-grid">
-										<el-checkbox v-for="item in knowledgeCheckboxOptions" :key="item.value" :label="item.value">
+									<el-segmented
+										v-model="form.config.kb_config.kb_selection_mode"
+										class="selection-segmented"
+										:options="selectionModeOptions"
+									/>
+									<el-checkbox-group
+										v-if="form.config.kb_config.kb_selection_mode === 'selected'"
+										v-model="form.config.kb_config.knowledge_bases"
+										class="checkbox-grid"
+									>
+										<el-checkbox
+											v-for="item in knowledgeCheckboxOptions"
+											:key="item.value"
+											:label="item.value"
+										>
 											{{ item.label }}
 										</el-checkbox>
 									</el-checkbox-group>
 								</div>
 								<div class="field-tip">选择智能体可访问的知识库范围。</div>
-								<div v-if="form.config.kb_config.kb_selection_mode === 'selected'" class="field-tip">
+								<div
+									v-if="form.config.kb_config.kb_selection_mode === 'selected'"
+									class="field-tip"
+								>
 									选择要关联的知识库,包括协作知识库。
 								</div>
 							</el-form-item>
 							<el-form-item label="知识的文件类型" prop="config.kb_config.supported_file_types">
-								<el-select v-model="form.config.kb_config.supported_file_types" multiple allow-create filterable
-									style="width: 100%">
-									<el-option v-for="item in supportedFileTypes" :key="item" :label="item" :value="item" />
+								<el-select
+									v-model="form.config.kb_config.supported_file_types"
+									multiple
+									allow-create
+									filterable
+									style="width: 100%"
+								>
+									<el-option
+										v-for="item in supportedFileTypes"
+										:key="item"
+										:label="item"
+										:value="item"
+									/>
 								</el-select>
 								<div class="field-tip">限制可选择的文件类型,留空表示支持所有类型。</div>
 							</el-form-item>
 							<el-form-item label="ReRank 模型" prop="config.model_config.rerank_model_id">
-								<el-select v-model="form.config.model_config.rerank_model_id" filterable remote reserve-keyword
-									:remote-method="searchRerankModels" :loading="rerankModelsLoading" style="width: 100%">
-									<el-option v-for="model in rerankModels" :key="model.id" :label="modelLabel(model)"
-										:value="model.id" />
+								<el-select
+									v-model="form.config.model_config.rerank_model_id"
+									filterable
+									remote
+									reserve-keyword
+									:remote-method="searchRerankModels"
+									:loading="rerankModelsLoading"
+									style="width: 100%"
+								>
+									<el-option
+										v-for="model in rerankModels"
+										:key="model.id"
+										:label="modelLabel(model)"
+										:value="model.id"
+									/>
 								</el-select>
 								<div class="field-tip">用于对知识库检索结果进行重排序,提高回答准确性。</div>
 							</el-form-item>
@@ -161,19 +268,31 @@
 										<el-switch v-model="form.config.faq_config.faq_priority_enabled" />
 										<div class="field-tip">FAQ 答案将优先于普通文档被引用,提高回答准确性。</div>
 									</div>
-
 								</el-form-item>
-								<el-form-item label="直接问答阈值" prop="config.faq_config.faq_direct_answer_threshold">
+								<el-form-item
+									label="直接问答阈值"
+									prop="config.faq_config.faq_direct_answer_threshold"
+								>
 									<div class="switch-wrap w-full flex items-center gap-3">
-										<el-slider v-model="form.config.faq_config.faq_direct_answer_threshold" :min="0.7" :max="1"
-											:step="0.1" style="width: 50%" />
+										<el-slider
+											v-model="form.config.faq_config.faq_direct_answer_threshold"
+											:min="0.7"
+											:max="1"
+											:step="0.1"
+											style="width: 50%"
+										/>
 										<div class="field-tip">当问题与 FAQ 相似度超过此值时,直接使用 FAQ 答案。</div>
 									</div>
 								</el-form-item>
 								<el-form-item label="FAQ 分数加权" prop="config.faq_config.faq_score_boost">
 									<div class="switch-wrap w-full flex items-center gap-3">
-										<el-slider v-model="form.config.faq_config.faq_score_boost" :min="1" :max="2" :step="0.1"
-											style="width: 50%" />
+										<el-slider
+											v-model="form.config.faq_config.faq_score_boost"
+											:min="1"
+											:max="2"
+											:step="0.1"
+											style="width: 50%"
+										/>
 										<div class="field-tip">FAQ 结果的相关性分数乘以此系数,使其排序更靠前。</div>
 									</div>
 								</el-form-item>
@@ -186,12 +305,19 @@
 						<div class="collapse-body">
 							<el-form-item label="允许的工具" prop="config.setting_config.allowed_tools">
 								<div class="selection-panel">
-									<el-checkbox-group v-model="form.config.setting_config.allowed_tools" class="tool-group-list">
+									<el-checkbox-group
+										v-model="form.config.setting_config.allowed_tools"
+										class="tool-group-list"
+									>
 										<div v-for="group in toolList" :key="group.name" class="tool-group-section">
 											<div class="tool-group-title">{{ group.name }}</div>
 											<div class="tool-group-card">
 												<div class="tool-group-card__items">
-													<el-checkbox v-for="tool in group.tools" :key="tool.name" :label="tool.name">
+													<el-checkbox
+														v-for="tool in group.tools"
+														:key="tool.name"
+														:label="tool.name"
+													>
 														<div class="tool-option">
 															<div class="tool-option__label">{{ tool.label }}</div>
 															<div v-if="tool.description" class="tool-option__desc">
@@ -207,29 +333,50 @@
 								<div class="field-tip">选择 Agent 可以使用的工具。</div>
 							</el-form-item>
 							<el-form-item label="最大迭代次数" prop="config.setting_config.max_iterations">
-								<el-input-number v-model="form.config.setting_config.max_iterations" :min="1" :max="50"
-									style="width: 100%" />
+								<el-input-number
+									v-model="form.config.setting_config.max_iterations"
+									:min="1"
+									:max="50"
+									style="width: 100%"
+								/>
 								<div class="field-tip">Agent 执行任务时的最大推理步骤数。</div>
 							</el-form-item>
 							<el-form-item label="LLM 调用超时" prop="config.setting_config.llm_call_timeout">
-								<el-input-number v-model="form.config.setting_config.llm_call_timeout" :min="0" style="width: 100%" />
+								<el-input-number
+									v-model="form.config.setting_config.llm_call_timeout"
+									:min="0"
+									style="width: 100%"
+								/>
 								<div class="field-tip">
 									单次 LLM 调用的最大等待时间(秒),超过此时间后调用将被中止。
 								</div>
 							</el-form-item>
 							<el-form-item label="MCP 服务" prop="config.setting_config.mcp_services">
 								<div class="selection-panel">
-									<el-segmented v-model="form.config.setting_config.mcp_selection_mode" class="selection-segmented"
-										:options="selectionMCPOptions" />
-									<el-checkbox-group v-if="form.config.setting_config.mcp_selection_mode === 'selected'"
-										v-model="form.config.setting_config.mcp_services" class="checkbox-grid">
-										<el-checkbox v-for="item in mcpCheckboxOptions" :key="item.value" :label="item.value">
+									<el-segmented
+										v-model="form.config.setting_config.mcp_selection_mode"
+										class="selection-segmented"
+										:options="selectionMCPOptions"
+									/>
+									<el-checkbox-group
+										v-if="form.config.setting_config.mcp_selection_mode === 'selected'"
+										v-model="form.config.setting_config.mcp_services"
+										class="checkbox-grid"
+									>
+										<el-checkbox
+											v-for="item in mcpCheckboxOptions"
+											:key="item.value"
+											:label="item.value"
+										>
 											{{ item.label }}
 										</el-checkbox>
 									</el-checkbox-group>
 								</div>
 								<div class="field-tip">选择 Agent 可以调用的 MCP 服务。</div>
-								<div v-if="form.config.setting_config.mcp_selection_mode === 'selected'" class="field-tip">
+								<div
+									v-if="form.config.setting_config.mcp_selection_mode === 'selected'"
+									class="field-tip"
+								>
 									选择要启用的 MCP 服务。
 								</div>
 							</el-form-item>
@@ -243,11 +390,21 @@
 						<div class="collapse-body">
 							<el-form-item label="Skills 选择" prop="config.setting_config.selected_skills">
 								<div class="selection-panel">
-									<el-segmented v-model="form.config.setting_config.skills_selection_mode" class="selection-segmented"
-										:options="selectionMCPOptions" />
-									<el-checkbox-group v-if="form.config.setting_config.skills_selection_mode === 'selected'"
-										v-model="form.config.setting_config.selected_skills" class="skill-checkbox-list">
-										<el-checkbox v-for="item in skillCheckboxOptions" :key="item.value" :label="item.value">
+									<el-segmented
+										v-model="form.config.setting_config.skills_selection_mode"
+										class="selection-segmented"
+										:options="selectionMCPOptions"
+									/>
+									<el-checkbox-group
+										v-if="form.config.setting_config.skills_selection_mode === 'selected'"
+										v-model="form.config.setting_config.selected_skills"
+										class="skill-checkbox-list"
+									>
+										<el-checkbox
+											v-for="item in skillCheckboxOptions"
+											:key="item.value"
+											:label="item.value"
+										>
 											<div class="skill-option">
 												<div class="skill-option__label">{{ item.label }}</div>
 												<div v-if="item.description" class="skill-option__desc">
@@ -258,7 +415,10 @@
 									</el-checkbox-group>
 								</div>
 								<div class="field-tip">选择 Agent 可以使用的 Skills 范围。</div>
-								<div v-if="form.config.setting_config.skills_selection_mode === 'selected'" class="field-tip">
+								<div
+									v-if="form.config.setting_config.skills_selection_mode === 'selected'"
+									class="field-tip"
+								>
 									选择要启用的 Skills。
 								</div>
 							</el-form-item>
@@ -269,33 +429,56 @@
 						<div class="tab-intro">配置知识库检索和排序的参数</div>
 						<div class="collapse-body">
 							<el-form-item label="向量召回数量" prop="config.search_config.embedding_top_k">
-								<el-input-number v-model="form.config.search_config.embedding_top_k" :min="1" :max="50"
-									style="width: 100%" />
+								<el-input-number
+									v-model="form.config.search_config.embedding_top_k"
+									:min="1"
+									:max="50"
+									style="width: 100%"
+								/>
 								<div class="field-tip">向量检索返回的最大结果数量。</div>
 							</el-form-item>
 							<el-form-item label="关键词阈值" prop="config.search_config.keyword_threshold">
 								<div class="switch-wrap w-full flex items-center gap-3">
-									<el-slider v-model="form.config.search_config.keyword_threshold" :min="0" :max="1" :step="0.1"
-										style="width: 50%" />
+									<el-slider
+										v-model="form.config.search_config.keyword_threshold"
+										:min="0"
+										:max="1"
+										:step="0.1"
+										style="width: 50%"
+									/>
 									<div class="field-tip">关键词检索的最低相关性分数。</div>
 								</div>
 							</el-form-item>
 							<el-form-item label="向量阈值" prop="config.search_config.vector_threshold">
 								<div class="switch-wrap w-full flex items-center gap-3">
-									<el-slider v-model="form.config.search_config.vector_threshold" :min="0" :max="1" :step="0.1"
-										style="width: 50%" />
+									<el-slider
+										v-model="form.config.search_config.vector_threshold"
+										:min="0"
+										:max="1"
+										:step="0.1"
+										style="width: 50%"
+									/>
 									<div class="field-tip">向量检索的最低相似度分数。</div>
 								</div>
 							</el-form-item>
 							<el-form-item label="重排数量" prop="config.search_config.rerank_top_k">
-								<el-input-number v-model="form.config.search_config.rerank_top_k" :min="1" :max="20"
-									style="width: 100%" />
+								<el-input-number
+									v-model="form.config.search_config.rerank_top_k"
+									:min="1"
+									:max="20"
+									style="width: 100%"
+								/>
 								<div class="field-tip">重排序后保留的最大结果数量。</div>
 							</el-form-item>
 							<el-form-item label="重排阈值" prop="config.search_config.rerank_threshold">
 								<div class="switch-wrap w-full flex items-center gap-3">
-									<el-slider v-model="form.config.search_config.rerank_threshold" :min="-10" :max="10" :step="0.1"
-										style="width: 50%" />
+									<el-slider
+										v-model="form.config.search_config.rerank_threshold"
+										:min="-10"
+										:max="10"
+										:step="0.1"
+										style="width: 50%"
+									/>
 									<div class="field-tip">重排序的最低相关性分数。</div>
 								</div>
 							</el-form-item>
@@ -306,32 +489,53 @@
 										<el-switch v-model="form.config.advanced_config.enable_query_expansion" />
 										<div class="field-tip">自动扩展查询词以提高召回率。</div>
 									</div>
-
 								</el-form-item>
 								<el-form-item label="兜底策略" prop="config.advanced_config.fallback_strategy">
-									<el-segmented v-model="form.config.advanced_config.fallback_strategy" class="selection-segmented"
-										:options="fallbackStrategyOptions" />
+									<el-segmented
+										v-model="form.config.advanced_config.fallback_strategy"
+										class="selection-segmented"
+										:options="fallbackStrategyOptions"
+									/>
 									<div class="field-tip">当无法从知识库找到相关内容时的处理方式。</div>
 								</el-form-item>
-								<el-form-item v-if="form.config.advanced_config.fallback_strategy === 'model'" label="兜底提示词"
-									prop="config.advanced_config.fallback_prompt">
+								<el-form-item
+									v-if="form.config.advanced_config.fallback_strategy === 'model'"
+									label="兜底提示词"
+									prop="config.advanced_config.fallback_prompt"
+								>
 									<div class="prompt-field">
 										<div class="prompt-toolbar prompt-toolbar--end">
-											<el-button class="prompt-template-trigger" text :icon="DocumentCopy"
-												@click="openPromptTemplatePicker('fallbackPrompt')">
+											<el-button
+												class="prompt-template-trigger"
+												text
+												:icon="DocumentCopy"
+												@click="openPromptTemplatePicker('fallbackPrompt')"
+											>
 												模板选择
 											</el-button>
 										</div>
-										<el-input v-model="form.config.advanced_config.fallback_prompt" type="textarea" :rows="3"
-											placeholder="请输入模型兜底时使用的提示词" />
+										<el-input
+											v-model="form.config.advanced_config.fallback_prompt"
+											type="textarea"
+											:rows="3"
+											placeholder="请输入模型兜底时使用的提示词"
+										/>
 									</div>
 									<div class="field-tip">
 										当无法从知识库找到相关内容时,用于引导模型生成兜底回复。
 									</div>
 								</el-form-item>
-								<el-form-item v-else label="固定提示词" prop="config.advanced_config.fallback_response">
-									<el-input v-model="form.config.advanced_config.fallback_response" type="textarea" :rows="3"
-										placeholder="请输入固定兜底内容" />
+								<el-form-item
+									v-else
+									label="固定提示词"
+									prop="config.advanced_config.fallback_response"
+								>
+									<el-input
+										v-model="form.config.advanced_config.fallback_response"
+										type="textarea"
+										:rows="3"
+										placeholder="请输入固定兜底内容"
+									/>
 									<div class="field-tip">当无法回答时返回的固定文本。</div>
 								</el-form-item>
 							</div>
@@ -347,36 +551,71 @@
 									<div class="field-tip">启用后智能体可以搜索互联网获取信息。</div>
 								</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.web_search_config.web_search_enabled" label="选择搜索引擎"
-								prop="config.web_search_config.web_search_provider_id">
-								<el-select v-model="form.config.web_search_config.web_search_provider_id" filterable remote
-									reserve-keyword :remote-method="searchWebSearchProviders" :loading="webSearchProviderLoading"
-									clearable placeholder="请输入关键词搜索" style="width: 100%">
-									<el-option v-for="item in webSearchProviderOptions" :key="item.value" :label="item.label"
-										:value="item.value" />
+							<el-form-item
+								v-if="form.config.web_search_config.web_search_enabled"
+								label="选择搜索引擎"
+								prop="config.web_search_config.web_search_provider_id"
+							>
+								<el-select
+									v-model="form.config.web_search_config.web_search_provider_id"
+									filterable
+									remote
+									reserve-keyword
+									:remote-method="searchWebSearchProviders"
+									:loading="webSearchProviderLoading"
+									clearable
+									placeholder="请输入关键词搜索"
+									style="width: 100%"
+								>
+									<el-option
+										v-for="item in webSearchProviderOptions"
+										:key="item.value"
+										:label="item.label"
+										:value="item.value"
+									/>
 								</el-select>
 								<div class="field-tip">为此智能体指定搜索引擎,留空则使用默认搜索引擎。</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.web_search_config.web_search_enabled" label="最大搜索结果数"
-								prop="config.web_search_config.web_search_max_results">
+							<el-form-item
+								v-if="form.config.web_search_config.web_search_enabled"
+								label="最大搜索结果数"
+								prop="config.web_search_config.web_search_max_results"
+							>
 								<div class="switch-wrap w-full flex items-center gap-3">
-									<el-slider v-model="form.config.web_search_config.web_search_max_results" :min="1" :max="10"
-										style="width: 50%" />
+									<el-slider
+										v-model="form.config.web_search_config.web_search_max_results"
+										:min="1"
+										:max="10"
+										style="width: 50%"
+									/>
 									<div class="field-tip">每次搜索返回的最大结果数量。</div>
 								</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.web_search_config.web_search_enabled" label-width="130px" label="自动抓取页面内容"
-								prop="config.web_search_config.web_fetch_enabled">
+							<el-form-item
+								v-if="form.config.web_search_config.web_search_enabled"
+								label-width="130px"
+								label="自动抓取页面内容"
+								prop="config.web_search_config.web_fetch_enabled"
+							>
 								<div class="switch-wrap flex items-center gap-2">
 									<el-switch v-model="form.config.web_search_config.web_fetch_enabled" />
-									<div class="field-tip">ReRank 后自动抓取排名靠前的网页完整内容,提升回答质量。</div>
+									<div class="field-tip">
+										ReRank 后自动抓取排名靠前的网页完整内容,提升回答质量。
+									</div>
 								</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.web_search_config.web_fetch_enabled" label="抓取页面数"
-								prop="config.web_search_config.web_fetch_top_n">
+							<el-form-item
+								v-if="form.config.web_search_config.web_fetch_enabled"
+								label="抓取页面数"
+								prop="config.web_search_config.web_fetch_top_n"
+							>
 								<div class="switch-wrap w-full flex items-center gap-3">
-									<el-slider v-model="form.config.web_search_config.web_fetch_top_n" :min="1" :max="10"
-										style="width: 50%" />
+									<el-slider
+										v-model="form.config.web_search_config.web_fetch_top_n"
+										:min="1"
+										:max="10"
+										style="width: 50%"
+									/>
 									<div class="field-tip">Rerank 后最多抓取几个网页的完整内容。</div>
 								</div>
 							</el-form-item>
@@ -392,11 +631,26 @@
 									<div class="field-tip">启用后用户可在对话中上传图片进行多模态问答。</div>
 								</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.img_vlm_config.image_upload_enabled" label="VLM 模型设置"
-								prop="config.img_vlm_config.vlm_model_id">
-								<el-select v-model="form.config.img_vlm_config.vlm_model_id" filterable remote reserve-keyword
-									:remote-method="searchVlmModels" :loading="vlmModelsLoading" style="width: 100%">
-									<el-option v-for="model in vlmModels" :key="model.id" :label="modelLabel(model)" :value="model.id" />
+							<el-form-item
+								v-if="form.config.img_vlm_config.image_upload_enabled"
+								label="VLM 模型设置"
+								prop="config.img_vlm_config.vlm_model_id"
+							>
+								<el-select
+									v-model="form.config.img_vlm_config.vlm_model_id"
+									filterable
+									remote
+									reserve-keyword
+									:remote-method="searchVlmModels"
+									:loading="vlmModelsLoading"
+									style="width: 100%"
+								>
+									<el-option
+										v-for="model in vlmModels"
+										:key="model.id"
+										:label="modelLabel(model)"
+										:value="model.id"
+									/>
 								</el-select>
 								<div class="field-tip">用于图片分析的视觉语言模型。</div>
 							</el-form-item>
@@ -408,11 +662,27 @@
 									</div>
 								</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.img_vlm_config.audio_upload_enabled" label="ASR 模型设置"
-								prop="config.img_vlm_config.asr_model_id">
-								<el-select v-model="form.config.img_vlm_config.asr_model_id" filterable remote reserve-keyword
-									:remote-method="searchAsrModels" :loading="asrModelsLoading" clearable style="width: 100%">
-									<el-option v-for="model in asrModels" :key="model.id" :label="modelLabel(model)" :value="model.id" />
+							<el-form-item
+								v-if="form.config.img_vlm_config.audio_upload_enabled"
+								label="ASR 模型设置"
+								prop="config.img_vlm_config.asr_model_id"
+							>
+								<el-select
+									v-model="form.config.img_vlm_config.asr_model_id"
+									filterable
+									remote
+									reserve-keyword
+									:remote-method="searchAsrModels"
+									:loading="asrModelsLoading"
+									clearable
+									style="width: 100%"
+								>
+									<el-option
+										v-for="model in asrModels"
+										:key="model.id"
+										:label="modelLabel(model)"
+										:value="model.id"
+									/>
 								</el-select>
 								<div class="field-tip">
 									用于音频转录的语音识别模型,未配置时音频文件将以占位符形式传递。
@@ -430,10 +700,17 @@
 									<div class="field-tip">开启后将保留历史对话上下文。</div>
 								</div>
 							</el-form-item>
-							<el-form-item v-if="form.config.multiple_config.multi_turn_enabled" label="保留轮数"
-								prop="config.multiple_config.history_turns">
-								<el-input-number v-model="form.config.multiple_config.history_turns" :min="1" :max="20"
-									style="width: 100%" />
+							<el-form-item
+								v-if="form.config.multiple_config.multi_turn_enabled"
+								label="保留轮数"
+								prop="config.multiple_config.history_turns"
+							>
+								<el-input-number
+									v-model="form.config.multiple_config.history_turns"
+									:min="1"
+									:max="20"
+									style="width: 100%"
+								/>
 								<div class="field-tip">保留最近几轮对话作为上下文。</div>
 							</el-form-item>
 							<div v-if="form.mode === 'quick-answer'">
@@ -443,48 +720,81 @@
 										<div class="field-tip">多轮对话时自动改写用户问题,消解指代和补全省略。</div>
 									</div>
 								</el-form-item>
-								<el-form-item v-if="form.config.advanced_config.enable_rewrite" label="改写系统提示词"
-									prop="config.advanced_config.rewrite_prompt_system">
+								<el-form-item
+									v-if="form.config.advanced_config.enable_rewrite"
+									label="改写系统提示词"
+									prop="config.advanced_config.rewrite_prompt_system"
+								>
 									<div class="prompt-field">
 										<div class="prompt-toolbar">
 											<div class="prompt-variable-row">
 												<span class="prompt-variable-label">可用变量</span>
-												<el-tag v-for="variable in rewriteVariables" :key="variable"
-													class="prompt-variable-tag" type="info" effect="plain"
-													@click="insertPromptVariable('rewritePromptSystem', variable)">
+												<el-tag
+													v-for="variable in rewriteVariables"
+													:key="variable"
+													class="prompt-variable-tag"
+													type="info"
+													effect="plain"
+													@click="insertPromptVariable('rewritePromptSystem', variable)"
+												>
 													{{ formatPromptVariable(variable) }}
 												</el-tag>
 											</div>
-											<el-button class="prompt-template-trigger" text :icon="DocumentCopy"
-												@click="openPromptTemplatePicker('rewritePromptSystem')">
+											<el-button
+												class="prompt-template-trigger"
+												text
+												:icon="DocumentCopy"
+												@click="openPromptTemplatePicker('rewritePromptSystem')"
+											>
 												模板选择
 											</el-button>
 										</div>
-										<el-input ref="rewritePromptSystemInputRef"
-											v-model="form.config.advanced_config.rewrite_prompt_system" type="textarea" :rows="3"
-											placeholder="请输入问题改写时的系统提示词" />
+										<el-input
+											ref="rewritePromptSystemInputRef"
+											v-model="form.config.advanced_config.rewrite_prompt_system"
+											type="textarea"
+											:rows="3"
+											placeholder="请输入问题改写时的系统提示词"
+										/>
 									</div>
 									<div class="field-tip">用于问题改写的系统提示词。</div>
 								</el-form-item>
-								<el-form-item v-if="form.config.advanced_config.enable_rewrite" label="改写用户提示词"
-									prop="config.advanced_config.rewrite_prompt_user">
+								<el-form-item
+									v-if="form.config.advanced_config.enable_rewrite"
+									label="改写用户提示词"
+									prop="config.advanced_config.rewrite_prompt_user"
+								>
 									<div class="prompt-field">
 										<div class="prompt-toolbar">
 											<div class="prompt-variable-row">
 												<span class="prompt-variable-label">可用变量</span>
-												<el-tag v-for="variable in rewriteVariables" :key="variable"
-													class="prompt-variable-tag" type="info" effect="plain"
-													@click="insertPromptVariable('rewritePromptUser', variable)">
+												<el-tag
+													v-for="variable in rewriteVariables"
+													:key="variable"
+													class="prompt-variable-tag"
+													type="info"
+													effect="plain"
+													@click="insertPromptVariable('rewritePromptUser', variable)"
+												>
 													{{ formatPromptVariable(variable) }}
 												</el-tag>
 											</div>
-											<el-button class="prompt-template-trigger" text :icon="DocumentCopy"
-												@click="openPromptTemplatePicker('rewritePromptUser')">
+											<el-button
+												class="prompt-template-trigger"
+												text
+												:icon="DocumentCopy"
+												@click="openPromptTemplatePicker('rewritePromptUser')"
+											>
 												模板选择
 											</el-button>
 										</div>
-										<el-input ref="rewritePromptUserInputRef" v-model="form.config.advanced_config.rewrite_prompt_user"
-											type="textarea" :rows="3" placeholder="请输入问题改写时的用户提示词" />
+										<el-input
+											ref="rewritePromptUserInputRef"
+											v-model="form.config.advanced_config.rewrite_prompt_user"
+											type="textarea"
+											:rows="3"
+											placeholder="请输入问题改写时的用户提示词"
+										/>
 									</div>
 									<div class="field-tip">用于问题改写的用户提示词模板。</div>
 								</el-form-item>
@@ -497,8 +807,14 @@
 
 		<el-dialog v-model="emojiDialogVisible" title="选择 Emoji 图标" width="520px" append-to-body>
 			<div class="emoji-picker-wrap">
-				<EmojiPicker :native="true" :hide-search="false" :disable-skin-tones="false" :display-recent="true"
-					:static-texts="{ placeholder: '搜索 Emoji' }" @select="handleEmojiSelect" />
+				<EmojiPicker
+					:native="true"
+					:hide-search="false"
+					:disable-skin-tones="false"
+					:display-recent="true"
+					:static-texts="{ placeholder: '搜索 Emoji' }"
+					@select="handleEmojiSelect"
+				/>
 			</div>
 		</el-dialog>
 
@@ -525,8 +841,7 @@ import { DocumentCopy } from '@element-plus/icons-vue'
 import { agentApplication, aiModel, knowledge, resource } from '@repo/api-service'
 import EmojiPicker from 'vue3-emoji-picker'
 import 'vue3-emoji-picker/css'
-import PromptModal from './PromptModal.vue'
-import type { PromptFieldKey } from './promptTypes'
+import PromptModal, { type PromptFieldKey } from '@/features/PromptModal.vue'
 import type {
 	AgentFormData,
 	AgentItem,
@@ -559,14 +874,16 @@ const vlmModelsLoading = ref(false)
 const asrModelsLoading = ref(false)
 const kbOptions = ref<KnowledgeItem[]>([])
 const kbLoading = ref(false)
-const toolList = ref<Array<{
-	name: string;
-	tools: {
-		description: string;
-		label: string;
-		name: string;
-	}[];
-}>>([])
+const toolList = ref<
+	Array<{
+		name: string
+		tools: {
+			description: string
+			label: string
+			name: string
+		}[]
+	}>
+>([])
 const webSearchProviderOptions = ref<AgentSelectOption[]>([])
 const webSearchProviderLoading = ref(false)
 const mcpServiceOptions = ref<AgentSelectOption[]>([])
@@ -995,15 +1312,18 @@ function buildSubmitConfig() {
 }
 
 async function fetchModels(keyword = '', type = '') {
-	const res = await aiModel.postModelPageList({
-		keyword: keyword.trim(),
-		type,
-		source: '',
-		pageIndex: 1,
-		pageSize: modelPageSize
-	}, {
-		requestKey: `${modelRequestKeyPrefix}-${type || 'all'}`
-	})
+	const res = await aiModel.postModelPageList(
+		{
+			keyword: keyword.trim(),
+			type,
+			source: '',
+			pageIndex: 1,
+			pageSize: modelPageSize
+		},
+		{
+			requestKey: `${modelRequestKeyPrefix}-${type || 'all'}`
+		}
+	)
 	if (res.isSuccess) {
 		modelOptions.value = mergeModelOptions((res.result?.model || []) as ModelItem[])
 	}
@@ -1734,7 +2054,6 @@ watch(
 		margin-bottom: 12px;
 		height: auto;
 		white-space: normal;
-
 	}
 
 	.skill-checkbox-list :deep(.el-checkbox__label) {
@@ -1850,7 +2169,6 @@ watch(
 		.prompt-toolbar {
 			flex-direction: column;
 		}
-
 	}
 }
 </style>

+ 0 - 278
apps/web/src/views/agent/components/PromptModal.vue

@@ -1,278 +0,0 @@
-<template>
-  <el-dialog v-model="visible" title="选择提示词模版" width="960px" append-to-body>
-    <div class="prompt-template-dialog">
-      <div class="prompt-template-dialog__meta">
-        <span>{{ dialogTitle }}</span>
-        <el-button text :icon="Refresh" :loading="loading" @click="loadTemplates">
-          刷新
-        </el-button>
-      </div>
-      <div v-loading="loading" class="prompt-template-grid">
-        <el-empty v-if="!options.length && !loading" description="暂无可用模版" />
-        <div v-for="template in options" :key="template.id" class="prompt-template-card"
-          :class="{ 'is-active': selectedTemplate?.id === template.id }" @click="selectedTemplate = template">
-          <div class="prompt-template-card__top">
-            <div>
-              <div class="prompt-template-card__title">
-                <el-tag v-if="template.is_builtin" type="success" effect="light">内置</el-tag>
-                <el-tag v-if="template.default" type="warning" effect="light">默认</el-tag>
-                {{ template.name }}
-              </div>
-              <div class="prompt-template-card__desc">{{ template.description || '暂无描述' }}</div>
-            </div>
-          </div>
-          <div class="prompt-template-card__meta">
-            <span>类型:{{ formatTemplateType(template.type) }}</span>
-          </div>
-          <div class="prompt-template-card__content">
-            {{ getTemplatePreview(template) }}
-          </div>
-        </div>
-      </div>
-    </div>
-    <template #footer>
-      <div class="drawer-footer">
-        <div class="text-12px text-gray-500">当前选择:{{ selectedTemplate?.name }}</div>
-        <div>
-          <el-button @click="visible = false">取消</el-button>
-          <el-button type="primary" :disabled="!selectedTemplate" @click="confirmTemplate">
-            确认
-          </el-button>
-        </div>
-      </div>
-    </template>
-  </el-dialog>
-</template>
-
-<script setup lang="ts">
-import { computed, ref, watch } from 'vue'
-import { Refresh } from '@element-plus/icons-vue'
-import { resource } from '@repo/api-service'
-import type { PromptFieldKey } from './promptTypes'
-type PromptTemplateConfigResponse = Awaited<ReturnType<typeof resource.postPromptTemplateConfig>>
-type PromptTemplateConfig = NonNullable<PromptTemplateConfigResponse['result']>
-
-type PromptTemplateItem = {
-  content: string
-  default: boolean
-  description: string
-  has_knowledge_base: boolean
-  has_web_search: boolean
-  id: string
-  is_builtin: boolean
-  mode: string
-  name: string
-  type: string
-  user?: string
-  value: string
-}
-
-const visible = defineModel<boolean>({ required: true })
-
-const props = defineProps<{
-  field: PromptFieldKey
-  mode: string
-}>()
-
-const emit = defineEmits<{
-  confirm: [content: string]
-}>()
-
-const loading = ref(false)
-const config = ref<PromptTemplateConfig | null>(null)
-const options = ref<PromptTemplateItem[]>([])
-const selectedTemplate = ref<PromptTemplateItem | null>(null)
-
-const dialogTitle = computed(() => {
-  const titleMap: Record<PromptFieldKey, string> = {
-    systemPrompt: '系统提示词模版',
-    contextTemplate: '上下文模板模版',
-    rewritePromptSystem: '改写系统提示词模版',
-    rewritePromptUser: '改写用户提示词模版',
-    fallbackPrompt: '兜底提示词模版'
-  }
-  return titleMap[props.field]
-})
-
-function getTemplateItems(field: PromptFieldKey) {
-  if (!config.value) return [] as PromptTemplateItem[]
-  switch (field) {
-    case 'systemPrompt':
-      return props.mode === 'smart-reasoning'
-        ? ([...(config.value.agent_system_prompts || []), ...(config.value.system_prompts || [])] as PromptTemplateItem[])
-        : ([...(config.value.system_prompts || []), ...(config.value.agent_system_prompts || [])] as PromptTemplateItem[])
-    case 'contextTemplate':
-      return (config.value.context_templates || []) as PromptTemplateItem[]
-    case 'rewritePromptSystem':
-    case 'rewritePromptUser':
-      return (config.value.rewrites || []) as PromptTemplateItem[]
-    case 'fallbackPrompt':
-      return (config.value.fall_backs || []) as PromptTemplateItem[]
-    default:
-      return [] as PromptTemplateItem[]
-  }
-}
-
-function getTemplateContent(item: PromptTemplateItem) {
-  if (props.field === 'rewritePromptUser') return item.user || item.content || ''
-  return item.content || ''
-}
-
-function formatTemplateType(type?: string) {
-  const typeMap: Record<string, string> = {
-    'system-prompt': '系统提示词',
-    'agent-system-prompt': 'Agent 系统提示词',
-    rewrite: '改写提示词',
-    'fall-back': '回退提示词',
-    'context-template': '上下文模板'
-  }
-  return typeMap[type || ''] || type || '-'
-}
-
-function getTemplatePreview(item: PromptTemplateItem) {
-  return (item.content || item.user || '-').slice(0, 120)
-}
-
-async function loadTemplates() {
-  loading.value = true
-  try {
-    const res = await resource.postPromptTemplateConfig({})
-    if (res.isSuccess && res.result) {
-      config.value = res.result as PromptTemplateConfig
-      options.value = getTemplateItems(props.field)
-      selectedTemplate.value = options.value[0] || null
-    }
-  } finally {
-    loading.value = false
-  }
-}
-
-function confirmTemplate() {
-  if (!selectedTemplate.value) return
-  emit('confirm', getTemplateContent(selectedTemplate.value))
-  visible.value = false
-}
-
-watch(
-  () => visible.value,
-  (value) => {
-    if (!value) return
-    selectedTemplate.value = null
-    loadTemplates()
-  }
-)
-
-watch(
-  () => [props.field, props.mode],
-  () => {
-    if (!config.value) return
-    options.value = getTemplateItems(props.field)
-    selectedTemplate.value = options.value[0] || null
-  }
-)
-</script>
-
-<style scoped>
-.prompt-template-dialog {
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-}
-
-.prompt-template-dialog__meta {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  font-size: 14px;
-  font-weight: 600;
-  color: #111827;
-}
-
-.prompt-template-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 12px;
-  max-height: 62vh;
-  overflow: auto;
-  padding-right: 4px;
-}
-
-.prompt-template-grid .el-empty {
-  grid-column: 1 / -1;
-}
-
-.prompt-template-card {
-  padding: 16px;
-  border-radius: 18px;
-  border: 1px solid #e5e7eb;
-  background: #fff;
-  cursor: pointer;
-  transition: border-color 0.15s ease;
-  box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05);
-}
-
-.prompt-template-card.is-active {
-  border-color: #3b82f6;
-}
-
-.prompt-template-card__top {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 10px;
-}
-
-.prompt-template-card__title {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  flex-wrap: wrap;
-  font-size: 18px;
-  font-weight: 700;
-  color: #111827;
-}
-
-.prompt-template-card__desc {
-  margin-top: 6px;
-  font-size: 13px;
-  line-height: 1.6;
-  color: #6b7280;
-}
-
-.prompt-template-card__meta {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px 12px;
-  margin-top: 12px;
-  font-size: 12px;
-  color: #6b7280;
-}
-
-.prompt-template-card__content {
-  margin-top: 12px;
-  padding: 12px;
-  border-radius: 12px;
-  background: #f8fafc;
-  color: #334155;
-  font-size: 13px;
-  line-height: 1.6;
-  white-space: pre-wrap;
-  max-height: 160px;
-  overflow: auto;
-}
-
-.drawer-footer {
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  justify-content: space-between;
-  gap: 10px;
-}
-
-@media (max-width: 768px) {
-  .prompt-template-grid {
-    grid-template-columns: 1fr;
-  }
-}
-</style>

+ 0 - 6
apps/web/src/views/agent/components/promptTypes.ts

@@ -1,6 +0,0 @@
-export type PromptFieldKey =
-	| 'systemPrompt'
-	| 'contextTemplate'
-	| 'rewritePromptSystem'
-	| 'rewritePromptUser'
-	| 'fallbackPrompt'

+ 7 - 6
apps/web/src/views/chat/api/chat.api.ts

@@ -3,7 +3,7 @@ import { agentApplication, aiChat, aiModel, knowledge } from '@repo/api-service'
 import type { ChatTargetConfig, ChatTargetType } from '../types'
 
 export interface ChatRequestParams {
-	conversationId: string
+	sessionId: string
 	query: string
 	// 根据具体使用的聊天类型,可能需要其他参数,这里以 AgentChat 为例提供默认值
 	knowledgeBaseIds?: string[]
@@ -34,11 +34,11 @@ export function getChatUrl(type: ChatTargetType) {
 export function buildChatRequestBody(
 	type: ChatTargetType,
 	config: ChatTargetConfig,
-	conversationId: string,
+	sessionId: string,
 	query: string
 ) {
 	const base = {
-		session_id: conversationId,
+		session_id: sessionId,
 		query,
 		summary_model_id: config.summaryModelId || '',
 		disable_title: config.disableTitle,
@@ -73,8 +73,9 @@ export function buildChatRequestBody(
  * 发送聊天消息
  */
 export async function sendChatMessage(params: ChatRequestParams) {
+	// 这里默认使用 AgentChat,可以根据业务逻辑切换为 postChatKnowledgeChat 或 postChatModelChat
 	return aiChat.postChatAgentChat({
-		session_id: params.conversationId,
+		session_id: params.sessionId,
 		query: params.query,
 		knowledge_base_ids: params.knowledgeBaseIds || [],
 		knowledge_ids: params.knowledgeIds || [],
@@ -105,11 +106,11 @@ export async function getSessionList(pageIndex: number, pageSize: number) {
 }
 
 export async function getSessionMessages(
-	conversationId: string,
+	sessionId: string,
 	pageIndex: number = 1,
 	pageSize: number = 100
 ) {
-	return aiChat.postSessionSessionMessages({ sessionId: conversationId, pageIndex, pageSize })
+	return aiChat.postSessionSessionMessages({ sessionId, pageIndex, pageSize })
 }
 
 export async function getAgentOptions(keyword = ''): Promise<ChatOptionItem[]> {

+ 188 - 213
apps/web/src/views/chat/index.vue

@@ -9,23 +9,20 @@
 		<div class="chat-main">
 			<ChatHeader :title="activeConversationTitle">
 				<template #actions>
+					<el-select v-model="activeTargetType" class="chat-target-select" size="small"
+						@change="handleTargetTypeChange">
+						<el-option label="知识库问答" value="knowledge" />
+						<el-option label="智能体问答" value="agent" />
+						<el-option label="模型聊天" value="model" />
+					</el-select>
+					<el-button :icon="Setting" type="text" plain @click="openSettingsDialog" />
 				</template>
 			</ChatHeader>
 
-			<MessageList ref="messageListRef" :messages="messages" :loading="isLoading" @retry="handleRetry" />
+			<MessageList ref="messageListRef" :messages="messages" :loading="isLoading" />
 
-			<ChatInput v-model="senderValue" :loading="isLoading" :attachments="currentAttachments" @submit="handleSend"
-				@cancel="handleCancel">
+			<ChatInput v-model="senderValue" :loading="isLoading" @submit="handleSend" @cancel="handleCancel">
 				<template #prefix-extra>
-					<!-- 切换对话模式 -->
-					<el-select v-model="activeTargetType" class="chat-target-select" size="small"
-						@change="handleTargetTypeChange">
-						<el-option :label="t('pages.chat.targetKnowledge')" value="knowledge" />
-						<el-option :label="t('pages.chat.targetAgent')" value="agent" />
-						<el-option :label="t('pages.chat.targetModel')" value="model" />
-					</el-select>
-					<el-button :icon="Setting" type="text" plain @click="openSettingsDialog" />
-					<!-- 选择图片 -->
 					<el-badge :value="currentAttachments.length" :hidden="!currentAttachments.length">
 						<el-button v-if="showImageUploadButton" round plain color="#626aef"
 							@click="imageUploadDialogVisible = true">
@@ -42,67 +39,64 @@
 		<el-dialog v-model="renameDialogVisible" :title="t('pages.chat.renameDialogTitle')" width="400px">
 			<el-input v-model="renameInput" :placeholder="t('pages.chat.renamePlaceholder')" />
 			<template #footer>
-				<el-button @click="renameDialogVisible = false">{{ t('pages.chat.cancel') }}</el-button>
+				<el-button @click="renameDialogVisible = false">{{ t('common.cancel') }}</el-button>
 				<el-button type="primary" @click="handleRename">{{ t('common.confirm') }}</el-button>
 			</template>
 		</el-dialog>
 
-		<el-dialog v-model="settingsDialogVisible" :title="t('pages.chat.settingsTitle')" width="720px">
+		<el-dialog v-model="settingsDialogVisible" title="对话设置" width="720px">
 			<el-form label-position="top" :model="settingsDraft">
-				<el-form-item :label="t('pages.chat.settingsKnowledgeBase')">
+				<el-form-item label="知识库">
 					<el-select v-model="settingsDraft.knowledgeBaseIds" multiple filterable allow-create default-first-option
-						:placeholder="t('pages.chat.selectPlaceholder')" style="width: 100%">
+						placeholder="请选择或输入知识库 ID" style="width: 100%">
 						<el-option v-for="item in knowledgeBaseOptions" :key="item.value" :label="item.label" :value="item.value" />
 					</el-select>
 				</el-form-item>
-				<el-form-item :label="t('pages.chat.settingsKnowledge')">
+				<el-form-item label="知识文件 ID">
 					<el-select v-model="settingsDraft.knowledgeIds" multiple filterable allow-create default-first-option
-						:placeholder="t('pages.chat.selectPlaceholder')" style="width: 100%" />
+						placeholder="请选择或输入知识文件 ID" style="width: 100%" />
 				</el-form-item>
-				<el-form-item v-if="activeTargetType === 'agent'" :label="t('pages.chat.settingsAgent')">
-					<el-select v-model="settingsDraft.agentId" filterable :placeholder="t('pages.chat.selectAgentPlaceholder')"
-						style="width: 100%" @change="handleAgentSelectChange">
+				<el-form-item v-if="activeTargetType === 'agent'" label="智能体">
+					<el-select v-model="settingsDraft.agentId" filterable placeholder="请选择智能体" style="width: 100%"
+						@change="handleAgentSelectChange">
 						<el-option v-for="item in agentOptions" :key="item.value" :label="item.label" :value="item.value" />
 					</el-select>
 				</el-form-item>
-				<el-form-item :label="t('pages.chat.settingsSummaryModel')">
-					<el-select v-model="settingsDraft.summaryModelId" filterable
-						:placeholder="t('pages.chat.selectSummaryModelPlaceholder')" style="width: 100%">
+				<el-form-item label="摘要模型">
+					<el-select v-model="settingsDraft.summaryModelId" filterable placeholder="请选择摘要模型" style="width: 100%">
 						<el-option v-for="item in modelOptions" :key="item.value" :label="item.label" :value="item.value" />
 					</el-select>
 				</el-form-item>
-				<el-form-item :label="t('pages.chat.settingsSessionParams')">
+				<el-form-item label="会话参数">
 					<div class="settings-switches">
-						<el-switch v-model="settingsDraft.enableMemory" :active-text="t('pages.chat.enableMemory')" />
-						<el-switch v-model="settingsDraft.disableTitle" :active-text="t('pages.chat.disableTitle')" />
+						<el-switch v-model="settingsDraft.enableMemory" active-text="启用记忆" />
+						<el-switch v-model="settingsDraft.disableTitle" active-text="禁用自动标题" />
 					</div>
 				</el-form-item>
-				<el-form-item v-if="activeTargetType === 'agent'" :label="t('pages.chat.settingsAgentSwitches')">
+				<el-form-item v-if="activeTargetType === 'agent'" label="Agent 开关">
 					<div class="settings-switches">
-						<el-switch v-model="settingsDraft.agentEnabled" :active-text="t('pages.chat.enableAgentMode')" />
-						<el-switch v-model="settingsDraft.webSearchEnabled" :active-text="t('pages.chat.enableWebSearch')" />
+						<el-switch v-model="settingsDraft.agentEnabled" active-text="启用 Agent 模式" />
+						<el-switch v-model="settingsDraft.webSearchEnabled" active-text="启用网络搜索" />
+					</div>
+					<div class="settings-note">
+						<el-tag :type="allowImageUpload ? 'success' : 'info'" effect="light">
+							{{ allowImageUpload ? '当前 Agent 支持图片上传' : '当前 Agent 未开启图片上传' }}
+						</el-tag>
 					</div>
 				</el-form-item>
-				<div v-if="activeTargetType === 'agent'" class="settings-note">
-					<el-tag :type="allowImageUpload ? 'success' : 'info'" effect="light">
-						{{ allowImageUpload ? t('pages.chat.agentImageUploadEnabled') : t('pages.chat.agentImageUploadDisabled') }}
-					</el-tag>
-				</div>
 			</el-form>
 			<template #footer>
-				<el-button @click="settingsDialogVisible = false">{{ t('pages.chat.cancel') }}</el-button>
-				<el-button type="primary" :loading="settingsSaving" @click="handleSaveSettings">{{ t('pages.chat.save')
-				}}</el-button>
+				<el-button @click="settingsDialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="settingsSaving" @click="handleSaveSettings">保存</el-button>
 			</template>
 		</el-dialog>
 
-		<el-dialog v-model="imageUploadDialogVisible" :title="t('pages.chat.imageUploadTitle')" width="560px">
+		<el-dialog v-model="imageUploadDialogVisible" title="上传图片" width="560px">
 			<FileUploadInput v-model="currentAttachments" :multiple="true" :file-types="['image']" :allow-link-input="false"
-				:tip="t('pages.chat.imageUploadTip')" style="width: 100%" />
+				tip="仅支持图片上传,上传后会作为当前对话的图片参数。" style="width: 100%" />
 			<template #footer>
-				<el-button @click="imageUploadDialogVisible = false">{{ t('pages.chat.cancel') }}</el-button>
-				<el-button type="primary" @click="imageUploadDialogVisible = false">{{ t('pages.chat.imageUploadDone')
-				}}</el-button>
+				<el-button @click="imageUploadDialogVisible = false">关闭</el-button>
+				<el-button type="primary" @click="imageUploadDialogVisible = false">完成</el-button>
 			</template>
 		</el-dialog>
 	</div>
@@ -126,13 +120,12 @@ import {
 	getModelOptions,
 	getSessionList,
 	getSessionMessages,
-	updateSessionName,
 	type ChatOptionItem
 } from './api/chat.api'
 import ChatHeader from './components/ChatHeader.vue'
-import ChatInput from '@/components/Chat/ChatInput.vue'
+import ChatInput from './components/ChatInput.vue'
 import ChatSidebar from './components/ChatSidebar.vue'
-import MessageList from '@/components/Chat/MessageList.vue'
+import MessageList from './components/MessageList.vue'
 import type {
 	BubbleMessage,
 	ChatReference,
@@ -171,7 +164,11 @@ const renameInput = ref('')
 const renamingConvId = ref('')
 const senderValue = ref('')
 const messageListRef = ref()
-const activeStreamToken = ref('')
+
+const activeSessionId = computed(() => {
+	const conv = conversations.value.find((c) => c.id === activeConversationId.value)
+	return conv ? conv.sessionId : ''
+})
 
 const activeConversationTitle = computed(() => {
 	const conv = conversations.value.find((c) => c.id === activeConversationId.value)
@@ -195,15 +192,11 @@ const currentChatConfig = computed<ChatTargetConfig>(() => ({
 	images: currentAttachments.value.map((item) => item.path || item.id || item.name).filter(Boolean)
 }))
 
-function getDefaultAgentId() {
-	return agentOptions.value[0]?.value || ''
-}
-
 onMounted(async () => {
 	await loadChatOptions()
 	await loadConversations()
 	if (conversations.value.length > 0 && !activeConversationId.value) {
-		await handleSelectConversation(conversations.value?.[0]?.id!)
+		await handleSelectConversation(conversations.value[0].id)
 	}
 })
 
@@ -215,7 +208,7 @@ function createDefaultConfig(type: ChatTargetType = 'agent'): ChatTargetConfig {
 		summaryModelId: '',
 		disableTitle: false,
 		enableMemory: true,
-		agentId: type === 'agent' ? getDefaultAgentId() : '',
+		agentId: '',
 		agentEnabled: true,
 		webSearchEnabled: false,
 		images: []
@@ -237,19 +230,8 @@ function cloneConfig(config: ChatTargetConfig): ChatTargetConfig {
 	}
 }
 
-function getConversationConfig(conversationId: string) {
-	const cachedConfig = sessionConfigMap[conversationId]
-	if (cachedConfig) return cachedConfig
-
-	const conversation = conversations.value.find((item) => item.id === conversationId)
-	if (conversation?.targetConfig) {
-		return {
-			...createDefaultConfig(conversation.targetType || activeTargetType.value),
-			...cloneConfig(conversation.targetConfig as ChatTargetConfig)
-		}
-	}
-
-	return createDefaultConfig(activeTargetType.value)
+function getConversationConfig(sessionId: string) {
+	return sessionConfigMap[sessionId] || createDefaultConfig(activeTargetType.value)
 }
 
 function syncSettingsDraft(config: ChatTargetConfig) {
@@ -259,10 +241,10 @@ function syncSettingsDraft(config: ChatTargetConfig) {
 	Object.assign(settingsDraft, next)
 }
 
-function persistConversationConfig(conversationId = activeConversationId.value) {
-	if (!conversationId) return
-	sessionConfigMap[conversationId] = cloneConfig(currentChatConfig.value)
-	const conv = conversations.value.find((item) => item.id === conversationId)
+function persistConversationConfig(sessionId = activeSessionId.value) {
+	if (!sessionId) return
+	sessionConfigMap[sessionId] = cloneConfig(currentChatConfig.value)
+	const conv = conversations.value.find((item) => item.sessionId === sessionId)
 	if (conv) {
 		conv.targetType = activeTargetType.value
 		conv.targetConfig = cloneConfig(currentChatConfig.value)
@@ -279,16 +261,13 @@ async function loadChatOptions() {
 		agentOptions.value = agents
 		knowledgeBaseOptions.value = knowledgeBases
 		modelOptions.value = models
-		if (activeTargetType.value === 'agent' && !settingsDraft.agentId) {
-			settingsDraft.agentId = getDefaultAgentId()
-		}
 	} catch (error) {
 		console.error('Failed to load chat options', error)
 	}
 }
 
 function openSettingsDialog() {
-	syncSettingsDraft(getConversationConfig(activeConversationId.value))
+	syncSettingsDraft(getConversationConfig(activeSessionId.value))
 	settingsDialogVisible.value = true
 }
 
@@ -338,10 +317,23 @@ async function handleSaveSettings() {
 	}
 }
 
+function escapeHtml(value: string) {
+	return value
+		.replaceAll('&', '&amp;')
+		.replaceAll('<', '&lt;')
+		.replaceAll('>', '&gt;')
+		.replaceAll('"', '&quot;')
+		.replaceAll("'", '&#39;')
+}
+
 function normalizeText(value?: string) {
 	return `${value || ''}`.trim()
 }
 
+function formatText(value?: string) {
+	return escapeHtml(normalizeText(value)).replace(/\n/g, '<br>')
+}
+
 function extractThinkParts(text?: string) {
 	const raw = `${text || ''}`
 	if (!raw) return { thinking: '', answer: '' }
@@ -359,60 +351,116 @@ function extractThinkParts(text?: string) {
 	}
 }
 
+function renderThinkBlock(text: string) {
+	return `
+		<div class="chat-block chat-block--think">
+			<div class="chat-block__title">
+				<svg viewBox="0 0 1024 1024" width="16" height="16" aria-hidden="true">
+					<path fill="currentColor" d="M741.7 188.6c31.6 201.8-19.8 348.1-117.7 409.1-97.9 61-228.3 39.6-288 0-58.9 121.8-18.4 152.2-18.4 152.2l10 90.4h313l8.5-100c0 0 342.5-207.2 66-512.4z"/>
+				</svg>
+				<span>${escapeHtml(t('pages.chat.thinkTitle'))}</span>
+			</div>
+			<div class="chat-block__body">${formatText(text)}</div>
+		</div>
+	`
+}
+
+function renderToolCallBlock(tool: ChatToolCall) {
+	return `
+		<div class="chat-block chat-block--tool">
+			<div class="chat-block__title">Tool Call${tool.toolName ? `: ${escapeHtml(tool.toolName)}` : ''}</div>
+			${tool.toolCallId ? `<div class="chat-block__meta">#${escapeHtml(tool.toolCallId)}</div>` : ''}
+			${tool.arguments ? `<pre class="chat-block__code">${escapeHtml(JSON.stringify(tool.arguments, null, 2))}</pre>` : ''}
+		</div>
+	`
+}
+
+function renderToolResultBlock(result: ChatToolResult) {
+	return `
+		<div class="chat-block chat-block--tool-result">
+			<div class="chat-block__title">
+				Tool Result${result.toolName ? `: ${escapeHtml(result.toolName)}` : ''}
+				${result.success === false ? '<span class="chat-block__status chat-block__status--error">失败</span>' : ''}
+			</div>
+			${result.thought ? `<div class="chat-block__body">${formatText(result.thought)}</div>` : ''}
+			${result.output ? `<pre class="chat-block__code">${formatText(result.output)}</pre>` : ''}
+			${result.error ? `<div class="chat-block__error">${formatText(result.error)}</div>` : ''}
+		</div>
+	`
+}
+
+function renderReferenceBlock(reference: ChatReference) {
+	return `
+		<div class="chat-reference">
+			<div class="chat-reference__title">${escapeHtml(reference.knowledgeTitle || reference.knowledgeFilename || reference.knowledgeId || '引用')}</div>
+			${reference.knowledgeDescription ? `<div class="chat-reference__desc">${formatText(reference.knowledgeDescription)}</div>` : ''}
+			${reference.matchedContent ? `<div class="chat-reference__content">${formatText(reference.matchedContent)}</div>` : ''}
+		</div>
+	`
+}
+
+function composeAiContent(message: BubbleMessage) {
+	const parts: string[] = []
+	const answer = normalizeText(message.answerText || message.content || '')
+	const thinkParts = extractThinkParts(answer)
+	const thinking = normalizeText(message.thinking || thinkParts.thinking)
+	const answerText = normalizeText(thinkParts.answer || answer)
+
+	if (thinking) {
+		parts.push(renderThinkBlock(thinking))
+	}
+
+	if (Array.isArray(message.toolCalls) && message.toolCalls.length) {
+		parts.push(
+			`<div class="chat-section">${message.toolCalls.map((item) => renderToolCallBlock(item)).join('')}</div>`
+		)
+	}
+
+	if (Array.isArray(message.toolResults) && message.toolResults.length) {
+		parts.push(
+			`<div class="chat-section">${message.toolResults.map((item) => renderToolResultBlock(item)).join('')}</div>`
+		)
+	}
+
+	if (Array.isArray(message.references) && message.references.length) {
+		parts.push(
+			`<div class="chat-section chat-section--references">
+				<div class="chat-section__title">引用</div>
+				${message.references.map((item) => renderReferenceBlock(item)).join('')}
+			</div>`
+		)
+	}
+
+	if (answerText) {
+		parts.push(`<div class="chat-answer">${formatText(answerText)}</div>`)
+	}
+
+	return parts.join('')
+}
+
 function updateMessageContent(message: BubbleMessage) {
-	message.content = normalizeText(message.answerText || message.output)
-}
-
-function hasRenderableContent(message: BubbleMessage) {
-	return !!(
-		normalizeText(message.content) ||
-		normalizeText(message.answerText) ||
-		normalizeText(message.thinking) ||
-		(message.toolCalls || []).length ||
-		(message.toolResults || []).length ||
-		(message.references || []).length ||
-		normalizeText(message.error)
-	)
+	message.content = composeAiContent(message)
 }
 
 function createUserMessage(content: string, updateTime?: string): BubbleMessage {
-	const id = `user-${Date.now()}-${Math.random()}`
 	return {
-		id,
-		key: id,
+		key: `user-${Date.now()}-${Math.random()}`,
 		role: 'user',
 		placement: 'end',
-		content: content,
-		rawText: content,
+		content: formatText(content),
 		loading: false,
 		shape: 'corner',
 		variant: 'outlined',
 		isMarkdown: false,
 		typing: false,
 		isFog: false,
-		streamCompleted: true,
 		updateTime
 	}
 }
 
-function getAttachmentFileIds() {
-	return currentAttachments.value.map((item) => item.path || item.id || item.name).filter(Boolean) as string[]
-}
-
-function createUserMessageWithAttachments(content: string, updateTime?: string): BubbleMessage {
-	const message = createUserMessage(content, updateTime)
-	const fileIds = getAttachmentFileIds()
-	if (fileIds.length) {
-		message.message_files = fileIds
-	}
-	return message
-}
-
 function createAiMessage(updateTime?: string): BubbleMessage {
-	const id = `ai-${Date.now()}-${Math.random()}`
 	return {
-		id,
-		key: id,
+		key: `ai-${Date.now()}-${Math.random()}`,
 		role: 'ai',
 		placement: 'start',
 		content: '',
@@ -426,9 +474,8 @@ function createAiMessage(updateTime?: string): BubbleMessage {
 		shape: 'corner',
 		variant: 'filled',
 		isMarkdown: true,
-		typing: false,
+		typing: { step: 3, interval: 25 },
 		isFog: true,
-		streamCompleted: false,
 		updateTime
 	}
 }
@@ -487,11 +534,6 @@ function applyStructuredEventToMessage(message: BubbleMessage, event: ChatSseMes
 				message.answerText = `${message.answerText || ''}${event.content}`
 			}
 			break
-		case 'answer':
-			if (event.content) {
-				message.answerText = `${message.answerText || ''}${event.content}`
-			}
-			break
 		case 'thinking': {
 			const thought = normalizeText(event.content || data.thought || data.content)
 			if (thought) {
@@ -513,13 +555,10 @@ function applyStructuredEventToMessage(message: BubbleMessage, event: ChatSseMes
 		case 'references':
 			message.references = [...(message.references || []), ...normalizeReferences(event)]
 			break
-		case 'error':
-			message.error = normalizeText(event.content || data.error || data.message)
-			break
 		case 'session_title': {
 			const title = normalizeText(data.title || event.content)
 			if (title) {
-				const conv = conversations.value.find((item) => item.id === activeConversationId.value)
+				const conv = conversations.value.find((item) => item.sessionId === activeSessionId.value)
 				if (conv) {
 					conv.title = title
 				}
@@ -535,25 +574,16 @@ function applyStructuredEventToMessage(message: BubbleMessage, event: ChatSseMes
 			break
 	}
 	updateMessageContent(message)
-	message.loading = !hasRenderableContent(message) && event.response_type !== 'complete'
-	message.typing = false
+	message.loading = event.response_type !== 'complete'
 	message.isFog = false
-	if (event.done) {
-		message.streamCompleted = true
-	}
 }
 
-function parseHistoryRecord(item: Record<string, any>): BubbleMessage[] {
+function parseHistoryRecord(item: Record<string, any>, index: number): BubbleMessage[] {
 	const updateTime = item.updateTime || item.creationTime
 	const messagesForRow: BubbleMessage[] = []
-	const attachmentFiles = (item?.message_files || '').split(',')
 
 	if (item.query) {
-		const userMessage = createUserMessage(item.query, updateTime)
-		if (attachmentFiles.length) {
-			userMessage.message_files = attachmentFiles
-		}
-		messagesForRow.push(userMessage)
+		messagesForRow.push(createUserMessage(item.query, updateTime))
 	}
 
 	const hasStructuredData =
@@ -571,8 +601,7 @@ function parseHistoryRecord(item: Record<string, any>): BubbleMessage[] {
 	}
 
 	const aiMessage = createAiMessage(updateTime)
-	aiMessage.id = item.id || item.msgId || aiMessage.id
-	aiMessage.key = aiMessage.id
+	aiMessage.key = item.id || item.msgId || `ai-${index}-${Date.now()}`
 	aiMessage.assistantMessageId = item.assistant_message_id || item.assistantMessageId || item.msgId || ''
 	aiMessage.answerText = normalizeText(item.answer || item.content || item.output)
 	aiMessage.thinking = normalizeText(item.thinking || item.thought)
@@ -596,7 +625,6 @@ function parseHistoryRecord(item: Record<string, any>): BubbleMessage[] {
 	updateMessageContent(aiMessage)
 	aiMessage.loading = false
 	aiMessage.isFog = false
-	aiMessage.streamCompleted = true
 	messagesForRow.push(aiMessage)
 
 	return messagesForRow
@@ -613,13 +641,13 @@ async function loadConversations(isRefresh = false) {
 		const res = await getSessionList(currentPage.value, pageSize.value)
 		if (res.isSuccess && res.result?.model) {
 			const newList = res.result.model.map((item) => {
-				const config = sessionConfigMap[item.id]
+				const config = sessionConfigMap[item.sessionId]
 				return {
 					id: item.id,
 					sessionId: item.sessionId,
 					title: item.name,
 					updatedAt: new Date(item.updateTime).getTime(),
-					targetType: config?.type,
+					targetType: config?.type || activeTargetType.value,
 					targetConfig: config ? cloneConfig(config) : undefined
 				}
 			})
@@ -650,16 +678,16 @@ async function loadMoreConversations() {
 	}
 }
 
-async function loadConversationMessages(conversationId: string) {
-	if (!conversationId) {
+async function loadConversationMessages(sessionId: string) {
+	if (!sessionId) {
 		messages.value = []
 		return
 	}
 
 	try {
-		const res = await getSessionMessages(conversationId)
+		const res = await getSessionMessages(sessionId)
 		if (res.isSuccess && res.result?.model) {
-			messages.value = res.result.model.flatMap((item) => parseHistoryRecord(item as Record<string, any>))
+			messages.value = res.result.model.flatMap((item, index) => parseHistoryRecord(item as Record<string, any>, index))
 			await nextTick()
 			messageListRef.value?.scrollToBottom?.()
 		} else {
@@ -681,10 +709,10 @@ async function handleSelectConversation(bizId: string) {
 		return
 	}
 
-	const config = getConversationConfig(targetConv.id)
+	const config = getConversationConfig(targetConv.sessionId)
 	syncSettingsDraft(config)
 	await refreshAgentUploadCapability(config.agentId)
-	await loadConversationMessages(targetConv.id)
+	await loadConversationMessages(targetConv.sessionId)
 }
 
 async function handleNewChat() {
@@ -693,13 +721,13 @@ async function handleNewChat() {
 		const res = await createSession(defaultName)
 		if (res.isSuccess && res.result) {
 			await loadConversations(true)
-			const targetConv = conversations.value.find((c) => c.id === res.result)
+			const targetConv = conversations.value.find((c) => c.sessionId === res.result || c.id === res.result)
 			if (targetConv) {
-				sessionConfigMap[targetConv.id] = createDefaultConfig(activeTargetType.value)
+				sessionConfigMap[targetConv.sessionId] = createDefaultConfig(activeTargetType.value)
 				await handleSelectConversation(targetConv.id)
-				ElMessage.success(t('pages.chat.createSuccess'))
+				ElMessage.success(t('pages.chat.createSuccess') || '创建成功')
 			} else if (conversations.value.length > 0) {
-				await handleSelectConversation(conversations.value?.[0]?.id!)
+				await handleSelectConversation(conversations.value[0].id)
 			}
 		}
 	} catch (error) {
@@ -734,7 +762,7 @@ const handleConvCommand = (command: string | number, bizId: string) => {
 					}
 					await loadConversations(true)
 					if (conversations.value.length > 0 && !activeConversationId.value) {
-						await handleSelectConversation(conversations.value?.[0]?.id!)
+						await handleSelectConversation(conversations.value[0].id)
 					}
 				} catch (error) {
 					ElMessage.error(t('common.error.network'))
@@ -761,43 +789,35 @@ async function handleRename() {
 	}
 }
 
-async function handleSend(content?: string) {
-	if (!content || !content.trim() || !activeConversationId.value) {
+async function handleSend(content: string) {
+	if (!content.trim() || !activeSessionId.value) {
 		ElMessage.warning(t('pages.chat.selectConversationFirst'))
 		return
 	}
 
 	if (activeTargetType.value === 'agent' && !settingsDraft.agentId) {
-		ElMessage.warning(t('pages.chat.selectAgentFirst'))
+		ElMessage.warning('请先选择智能体')
 		return
 	}
 	if (activeTargetType.value === 'knowledge' && !settingsDraft.knowledgeBaseIds.length) {
-		ElMessage.warning(t('pages.chat.selectKnowledgeBaseFirst'))
+		ElMessage.warning('请先选择知识库')
 		return
 	}
 	if (activeTargetType.value === 'model' && !settingsDraft.summaryModelId) {
-		ElMessage.warning(t('pages.chat.selectSummaryModelFirst'))
+		ElMessage.warning('请先选择摘要模型')
 		return
 	}
 
 	senderValue.value = ''
 
-	const userMsg = createUserMessageWithAttachments(content)
+	const userMsg = createUserMessage(content)
 	messages.value.push(userMsg)
 	await nextTick()
 	messageListRef.value?.scrollToBottom?.()
 
 	const aiMsg = createAiMessage()
 	messages.value.push(aiMsg)
-	await nextTick()
-	messageListRef.value?.scrollToBottom?.()
-	const aiMessageId = aiMsg.id as string
-	const streamToken = `${aiMessageId}-${Date.now()}`
-	activeStreamToken.value = streamToken
-
-
-	const getAiMessage = () => messages.value.find((item) => item.id === aiMessageId)
-	const isActiveStream = () => activeStreamToken.value === streamToken
+	const aiMessageIndex = messages.value.length - 1
 
 	const requestBody = buildChatRequestBody(
 		activeTargetType.value,
@@ -813,8 +833,7 @@ async function handleSend(content?: string) {
 		url,
 		requestBody,
 		(event: ChatSseMessage) => {
-			if (!isActiveStream()) return
-			const msg = getAiMessage()
+			const msg = messages.value[aiMessageIndex]
 			if (!msg) return
 			applyStructuredEventToMessage(msg, event)
 			nextTick(() => {
@@ -822,66 +841,22 @@ async function handleSend(content?: string) {
 			})
 		},
 		() => {
-			if (!isActiveStream()) return
-			const msg = getAiMessage()
+			const msg = messages.value[aiMessageIndex]
 			if (msg) {
 				msg.loading = false
 				msg.isFog = false
-				msg.typing = false
-				msg.streamCompleted = true
+				msg.typing = true
 				updateMessageContent(msg)
 			}
-			nextTick(() => {
-				messageListRef.value?.scrollToBottom?.()
-			})
 		},
 		(err) => {
-			if (!isActiveStream()) return
 			console.error('Stream chat error:', err)
-			const msg = getAiMessage()
-			let errorText = t('common.error.network')
-			if (msg) {
-				msg.loading = false
-				msg.isFog = false
-				msg.typing = false
-				msg.streamCompleted = true
-				msg.error = normalizeText(err?.message || err?.name || t('common.error.network'))
-				errorText = msg.error || errorText
-				updateMessageContent(msg)
-			}
-			ElMessage.error(errorText)
-			nextTick(() => {
-				messageListRef.value?.scrollToBottom?.()
-			})
+			ElMessage.error(t('common.error.network'))
+			messages.value.splice(aiMessageIndex, 1)
 		}
 	)
 }
 
-async function sendMessage(content: string) {
-	await handleSend(content)
-}
-
-async function handleRetry(message: BubbleMessage) {
-	if (isLoading.value) {
-		ElMessage.warning(t('pages.chat.requestInProgress'))
-		return
-	}
-
-	const messageIndex = messages.value.findIndex((item) => item.id === message.id)
-	if (messageIndex <= 0) return
-
-	const userMessage = [...messages.value.slice(0, messageIndex)].reverse().find((item) => item.role === 'user')
-	const query = normalizeText(userMessage?.rawText || userMessage?.content)
-	if (!query) {
-		ElMessage.warning(t('pages.chat.retrySourceNotFound'))
-		return
-	}
-
-	messages.value = messages.value.slice(0, messageIndex)
-	senderValue.value = query
-	await sendMessage(query)
-}
-
 function handleCancel() {
 	cancelRequest()
 }

+ 275 - 238
apps/web/src/views/knowledge/WikiManage.vue

@@ -1,21 +1,23 @@
 <template>
 	<div class="wiki-manage">
 		<div class="wiki-layout" :class="{ 'wiki-layout--graph-only': isGraphMode }">
-			<aside v-if="showWikiDetails" class="wiki-sidebar" :class="{ 'wiki-sidebar--collapsed': sidebarCollapsed }">
+			<aside v-if="showWikiDetails" class="wiki-sidebar">
 				<div class="wiki-sidebar__header">
-					<div v-if="!sidebarCollapsed" class="wiki-sidebar__heading">
+					<div class="wiki-sidebar__heading">
 						<div class="wiki-sidebar__title">页面列表</div>
 						<div class="wiki-sidebar__subtitle">按页面筛选并快速定位图谱节点</div>
 					</div>
-					<el-button class="collapse-btn" text @click="sidebarCollapsed = !sidebarCollapsed">
-						{{ sidebarCollapsed ? '展开' : '收起' }}
-					</el-button>
 				</div>
 
-				<div v-if="!sidebarCollapsed" class="wiki-sidebar__body">
+				<div class="wiki-sidebar__body">
 					<div class="wiki-sidebar__toolbar">
-						<el-input v-model="filters.keyword" clearable placeholder="搜索 Wiki 页面" @keyup.enter="refreshPages"
-							@clear="refreshPages">
+						<el-input
+							v-model="filters.keyword"
+							clearable
+							placeholder="搜索 Wiki 页面"
+							@keyup.enter="refreshPages"
+							@clear="refreshPages"
+						>
 							<template #append>
 								<el-button @click="refreshPages">
 									<el-icon>
@@ -24,7 +26,12 @@
 								</el-button>
 							</template>
 						</el-input>
-						<el-select v-model="filters.page_type" clearable placeholder="页面类型" @change="refreshPages">
+						<el-select
+							v-model="filters.page_type"
+							clearable
+							placeholder="页面类型"
+							@change="refreshPages"
+						>
 							<el-option label="全部类型" value="" />
 							<el-option label="摘要" value="summary" />
 							<el-option label="实体" value="entity" />
@@ -32,7 +39,12 @@
 							<el-option label="索引" value="index" />
 							<el-option label="日志" value="log" />
 						</el-select>
-						<el-select v-model="filters.status" clearable placeholder="页面状态" @change="refreshPages">
+						<el-select
+							v-model="filters.status"
+							clearable
+							placeholder="页面状态"
+							@change="refreshPages"
+						>
 							<el-option label="全部状态" value="" />
 							<el-option label="激活" value="active" />
 							<el-option label="草稿" value="draft" />
@@ -40,43 +52,70 @@
 						</el-select>
 					</div>
 
-
-					<el-scrollbar height="500px">
+					<el-scrollbar class="wiki-sidebar__scroll">
 						<div class="wiki-sidebar__list" v-loading="pageListLoading">
-							<div v-for="item in pageList" :key="item.id" class="wiki-page-card"
-								:class="{ 'wiki-page-card--active': selectedPage?.slug === item.slug }"
-								@click="handleSelectPage(item.slug)">
-								<div class="wiki-page-card__top">
-									<div class="wiki-page-card__title" :title="item.title">{{ item.title }}</div>
-									<el-tag size="small" :type="getPageTypeTagType(item.page_type)">
-										{{ getPageTypeLabel(item.page_type) }}
-									</el-tag>
-								</div>
-								<div class="wiki-page-card__meta">
-									<span>{{ item.slug }}</span>
-									<span>{{ item.version ? `v${item.version}` : '-' }}</span>
-								</div>
-							</div>
-							<el-empty v-if="!pageListLoading && !pageList.length" description="暂无 Wiki 页面"
-								class="page-empty page-empty--compact" />
+							<el-menu
+								class="wiki-page-menu"
+								:default-active="activePageSlug"
+								@select="handleSelectPage"
+							>
+								<el-menu-item
+									v-for="item in pageList"
+									:key="item.id"
+									:index="item.slug"
+									class="wiki-page-menu__item"
+								>
+									<div class="wiki-menu-item">
+										<div class="wiki-menu-item__main">
+											<span class="wiki-menu-item__title" :title="item.title">{{
+												item.title
+											}}</span>
+											<span class="wiki-menu-item__slug" :title="item.slug">{{ item.slug }}</span>
+										</div>
+										<div class="wiki-menu-item__extra">
+											<el-tag size="small" :type="getPageTypeTagType(item.page_type)">
+												{{ getPageTypeLabel(item.page_type) }}
+											</el-tag>
+											<span class="wiki-menu-item__version">{{
+												item.version ? `v${item.version}` : '-'
+											}}</span>
+										</div>
+									</div>
+								</el-menu-item>
+							</el-menu>
+							<el-empty
+								v-if="!pageListLoading && !pageList.length"
+								description="暂无 Wiki 页面"
+								class="page-empty page-empty--compact"
+							/>
 						</div>
 					</el-scrollbar>
 
 					<div class="wiki-sidebar__footer">
-						<el-pagination v-if="pagination.total > 0" v-model:current-page="pagination.page"
-							v-model:page-size="pagination.pageSize" small background layout="total, prev, pager, next"
-							:total="pagination.total" :page-sizes="[20, 50, 100]" @current-change="loadPages"
-							@size-change="handlePageSizeChange" />
+						<el-pagination
+							v-if="pagination.total > 0"
+							v-model:current-page="pagination.page"
+							v-model:page-size="pagination.pageSize"
+							small
+							background
+							layout="total, prev, pager, next"
+							:total="pagination.total"
+							:page-sizes="[20, 50, 100]"
+							@current-change="loadPages"
+							@size-change="handlePageSizeChange"
+						/>
 					</div>
 				</div>
 			</aside>
 
-			<section class="wiki-main-scroll">
+			<el-scrollbar class="wiki-main-scroll">
 				<div v-if="showGraphSection" class="graph-card">
 					<div class="graph-card__header">
 						<div>
 							<div class="graph-card__title">知识图谱</div>
-							<div class="graph-card__desc">基于 Wiki 页面与关系边构建的图谱视图,可拖拽、缩放。</div>
+							<div class="graph-card__desc">
+								基于 Wiki 页面与关系边构建的图谱视图,可拖拽、缩放。
+							</div>
 							<div class="graph-card__summary">
 								<div v-for="item in graphSummary" :key="item.label" class="graph-stat">
 									<span class="graph-stat__label">{{ item.label }}</span>
@@ -98,8 +137,8 @@
 					<div class="detail-card__header">
 						<div class="detail-card__title">页面信息</div>
 						<div class="detail-card__actions">
-							<el-button text @click="loadIndexPage">首页</el-button>
-							<el-button text @click="loadLogPage">日志页</el-button>
+							<el-button size="small" text @click="loadIndexPage">首页</el-button>
+							<el-button size="small" text @click="openLogDialog">日志</el-button>
 						</div>
 					</div>
 
@@ -115,7 +154,9 @@
 							</div>
 							<div class="page-summary__row">
 								<span class="page-summary__label">类型</span>
-								<span class="page-summary__value">{{ getPageTypeLabel(selectedPage.page_type) }}</span>
+								<span class="page-summary__value">{{
+									getPageTypeLabel(selectedPage.page_type)
+								}}</span>
 							</div>
 							<div class="page-summary__row">
 								<span class="page-summary__label">状态</span>
@@ -138,21 +179,25 @@
 						<div class="content-section">
 							<div class="content-section__title">摘要</div>
 							<div class="content-section__body">
-								<Markdown :content="selectedPage.summary || '暂无摘要'" />
+								<XMarkdown :markdown="selectedPage.summary || '暂无摘要'" />
 							</div>
 						</div>
 
 						<div class="content-section">
 							<div class="content-section__title">正文</div>
 							<div class="content-section__body content-section__body--code">
-								<Markdown :content="selectedPage.content || '暂无内容'" />
+								<XMarkdown :markdown="selectedPage.content || '暂无内容'" />
 							</div>
 						</div>
 
 						<div class="content-section">
 							<div class="content-section__title">元数据</div>
 							<div v-if="selectedPageMetadataEntries.length" class="metadata-grid">
-								<div v-for="item in selectedPageMetadataEntries" :key="item.key" class="metadata-grid__item">
+								<div
+									v-for="item in selectedPageMetadataEntries"
+									:key="item.key"
+									class="metadata-grid__item"
+								>
 									<div class="metadata-grid__label">{{ item.key }}</div>
 									<div class="metadata-grid__value">{{ item.value }}</div>
 								</div>
@@ -162,68 +207,58 @@
 					</template>
 					<el-empty v-else description="请选择 Wiki 页面" class="page-empty" />
 				</div>
+			</el-scrollbar>
+		</div>
 
-				<div v-if="showWikiDetails" class="detail-card">
-					<div class="detail-card__header">
-						<div class="detail-card__title">日志信息</div>
-					</div>
-
-					<div v-if="logPage" class="log-summary">
-						<div class="page-summary">
-							<div class="page-summary__row">
-								<span class="page-summary__label">日志标题</span>
-								<span class="page-summary__value">{{ logPage.title }}</span>
-							</div>
-							<div class="page-summary__row">
-								<span class="page-summary__label">Slug</span>
-								<span class="page-summary__value">{{ logPage.slug }}</span>
-							</div>
-							<div class="page-summary__row">
-								<span class="page-summary__label">版本</span>
-								<span class="page-summary__value">v{{ logPage.version }}</span>
-							</div>
+		<el-dialog
+			v-if="showWikiDetails"
+			v-model="logDialogVisible"
+			title="日志信息"
+			width="760px"
+			destroy-on-close
+			class="wiki-log-dialog"
+		>
+			<el-scrollbar class="wiki-log-dialog__scroll" v-loading="logLoading">
+				<div v-if="logPage" class="log-summary">
+					<div class="page-summary page-summary--compact">
+						<div class="page-summary__row">
+							<span class="page-summary__label">日志标题</span>
+							<span class="page-summary__value">{{ logPage.title }}</span>
 						</div>
-
-						<div class="content-section">
-							<div class="content-section__title">日志摘要</div>
-							<div class="content-section__body">
-								<Markdown :content="logPage.summary || '暂无日志摘要'" />
-							</div>
+						<div class="page-summary__row">
+							<span class="page-summary__label">Slug</span>
+							<span class="page-summary__value">{{ logPage.slug }}</span>
 						</div>
+						<div class="page-summary__row">
+							<span class="page-summary__label">版本</span>
+							<span class="page-summary__value">v{{ logPage.version }}</span>
+						</div>
+					</div>
 
-						<div class="content-section">
-							<div class="content-section__title">日志正文</div>
-							<div class="content-section__body content-section__body--code">
-								<Markdown :content="logPage.content || '暂无日志内容'" />
-							</div>
+					<div class="content-section">
+						<div class="content-section__title">日志摘要</div>
+						<div class="content-section__body">
+							<XMarkdown :markdown="logPage.summary || '暂无日志摘要'" />
 						</div>
 					</div>
 
-					<div class="recent-block">
-						<div class="content-section__title">最近更新</div>
-						<div class="recent-list">
-							<div v-for="item in stats.recent_updates" :key="item.id" class="recent-item"
-								@click="item.slug && handleSelectPage(item.slug)">
-								<div class="recent-item__title">{{ item.title }}</div>
-								<div class="recent-item__meta">
-									<span>{{ getPageTypeLabel(item.page_type) }}</span>
-									<span>v{{ item.version }}</span>
-								</div>
-							</div>
-							<el-empty v-if="!stats.recent_updates.length" description="暂无更新" class="page-empty page-empty--compact" />
+					<div class="content-section">
+						<div class="content-section__title">日志正文</div>
+						<div class="content-section__body content-section__body--code">
+							<XMarkdown :markdown="logPage.content || '暂无日志内容'" />
 						</div>
 					</div>
 				</div>
-			</section>
-		</div>
+				<el-empty v-else description="暂无日志信息" class="page-empty page-empty--compact" />
+			</el-scrollbar>
+		</el-dialog>
 	</div>
 </template>
 
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { Search } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-import { Markdown } from "@repo/ui"
+import { XMarkdown } from 'vue-element-plus-x'
 import * as d3 from 'd3'
 import { wiki } from '@repo/api-service'
 import type { KnowledgeBaseItem } from './types'
@@ -297,15 +332,15 @@ const props = withDefaults(
 )
 
 const isGraphMode = computed(() => props.mode === 'graph')
-const showWikiOverview = computed(() => true)
 const showWikiDetails = computed(() => !isGraphMode.value)
 const showGraphSection = computed(() => isGraphMode.value)
 
-const sidebarCollapsed = ref(false)
 const graphContainerRef = ref<HTMLDivElement>()
 const pageListLoading = ref(false)
 const graphLoading = ref(false)
 const detailLoading = ref(false)
+const logLoading = ref(false)
+const logDialogVisible = ref(false)
 
 const filters = ref({
 	keyword: '',
@@ -361,6 +396,8 @@ const selectedPageMetadataEntries = computed(() =>
 	}))
 )
 
+const activePageSlug = computed(() => selectedPage.value?.slug || '')
+
 const graphSummary = computed(() => {
 	const duplicateCount =
 		stats.value.total_links > graphData.value.edges.length
@@ -564,6 +601,7 @@ async function loadIndexPage() {
 
 async function loadLogPage() {
 	if (!props.currentBase.id) return
+	logLoading.value = true
 	try {
 		const res = await wiki.postWikiLog({ knowledge_base_id: props.currentBase.id })
 		if (res?.isSuccess) {
@@ -571,9 +609,16 @@ async function loadLogPage() {
 		}
 	} catch {
 		logPage.value = null
+	} finally {
+		logLoading.value = false
 	}
 }
 
+async function openLogDialog() {
+	logDialogVisible.value = true
+	await loadLogPage()
+}
+
 function highlightSelectedNode(slug?: string) {
 	const root = graphContainerRef.value
 	if (!root) return
@@ -590,7 +635,10 @@ function highlightSelectedNode(slug?: string) {
 		const isActive = node.dataset.slug === slug
 		const isRelated = relationSet.has(node.dataset.slug || '')
 
-		node.setAttribute('stroke', isActive ? '#ffffff' : isRelated ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.78)')
+		node.setAttribute(
+			'stroke',
+			isActive ? '#ffffff' : isRelated ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.78)'
+		)
 		node.setAttribute('stroke-width', isActive ? '3.5' : isRelated ? '2.2' : '1.5')
 		node.setAttribute('opacity', slug && !isActive && !isRelated ? '0.35' : '1')
 	})
@@ -651,9 +699,12 @@ function renderGraph() {
 	const root = svg.append('g')
 
 	svg.call(
-		d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.4, 2.5]).on('zoom', (event) => {
-			root.attr('transform', event.transform)
-		})
+		d3
+			.zoom<SVGSVGElement, unknown>()
+			.scaleExtent([0.4, 2.5])
+			.on('zoom', (event) => {
+				root.attr('transform', event.transform)
+			})
 	)
 
 	const link = root
@@ -717,7 +768,10 @@ function renderGraph() {
 		)
 		.force('charge', d3.forceManyBody().strength(-320))
 		.force('center', d3.forceCenter(width / 2, height / 2))
-		.force('collision', d3.forceCollide<ForceNode>().radius((d) => Math.max(24, 16 + d.linkCount * 1.2)))
+		.force(
+			'collision',
+			d3.forceCollide<ForceNode>().radius((d) => Math.max(24, 16 + d.linkCount * 1.2))
+		)
 		.on('tick', () => {
 			link
 				.attr('x1', (d) => (d.source as ForceNode).x || 0)
@@ -757,7 +811,7 @@ async function loadAll() {
 	if (isGraphMode.value) {
 		tasks.push(loadGraph())
 	} else {
-		tasks.push(loadPages(), loadLogPage())
+		tasks.push(loadPages())
 	}
 	await Promise.all(tasks)
 }
@@ -781,7 +835,6 @@ watch(
 	{ immediate: true }
 )
 
-
 onMounted(() => {
 	window.addEventListener('resize', renderGraph)
 })
@@ -796,39 +849,17 @@ onBeforeUnmount(() => {
 .wiki-manage {
 	display: flex;
 	flex-direction: column;
-	gap: 16px;
-}
-
-.wiki-overview {
-	display: grid;
-	grid-template-columns: repeat(4, minmax(0, 1fr));
-	gap: 12px;
-}
-
-.stat-card {
-	padding: 16px;
-	border: 1px solid var(--border-light);
-	border-radius: 16px;
-	background: linear-gradient(180deg, var(--bg-base) 0%, var(--bg-container) 100%);
-}
-
-.stat-card__label {
-	font-size: 12px;
-	color: var(--text-secondary);
-}
-
-.stat-card__value {
-	margin-top: 8px;
-	font-size: 28px;
-	font-weight: 700;
-	color: var(--text-primary);
+	height: calc(100vh - 250px);
+	min-height: 520px;
+	min-width: 0;
 }
 
 .wiki-layout {
 	display: grid;
-	grid-template-columns: auto minmax(0, 1fr);
-	gap: 16px;
-	min-height: calc(100vh - 320px);
+	grid-template-columns: 320px minmax(0, 1fr);
+	gap: 12px;
+	flex: 1;
+	min-height: 0;
 }
 
 .wiki-layout--graph-only {
@@ -840,14 +871,10 @@ onBeforeUnmount(() => {
 	display: flex;
 	flex-direction: column;
 	border: 1px solid var(--border-light);
-	border-radius: 18px;
+	border-radius: 10px;
 	background: var(--bg-base);
 	overflow: hidden;
-	transition: width 0.22s ease;
-}
-
-.wiki-sidebar--collapsed {
-	width: 64px;
+	min-height: 0;
 }
 
 .wiki-sidebar__header {
@@ -855,122 +882,154 @@ onBeforeUnmount(() => {
 	align-items: center;
 	justify-content: space-between;
 	gap: 10px;
-	padding: 14px;
+	padding: 10px 12px;
 	border-bottom: 1px solid var(--border-light);
 }
 
 .wiki-sidebar__title {
-	font-size: 16px;
+	font-size: 14px;
 	font-weight: 700;
 	color: var(--text-primary);
 }
 
 .wiki-sidebar__subtitle {
-	margin-top: 4px;
+	margin-top: 2px;
 	font-size: 12px;
-	line-height: 1.6;
+	line-height: 1.4;
 	color: var(--text-secondary);
 }
 
-.collapse-btn {
-	flex-shrink: 0;
-}
-
 .wiki-sidebar__body {
 	display: flex;
 	flex: 1;
 	flex-direction: column;
 	min-height: 0;
-	padding: 12px;
-	gap: 12px;
+	padding: 10px;
+	gap: 10px;
 }
 
 .wiki-sidebar__toolbar {
 	display: flex;
 	flex-direction: column;
-	gap: 10px;
+	gap: 8px;
+	flex-shrink: 0;
 }
 
-.wiki-sidebar__list {
+.wiki-sidebar__scroll {
 	flex: 1;
-	display: flex;
-	flex-direction: column;
-	gap: 10px;
-	overflow: auto;
+	min-height: 0;
+}
+
+.wiki-sidebar__list {
 	min-height: 0;
 }
 
 .wiki-sidebar__footer {
 	display: flex;
 	justify-content: center;
-	padding-top: 4px;
+	padding-top: 2px;
+	flex-shrink: 0;
 }
 
-.wiki-page-card {
-	padding: 12px;
-	border: 1px solid var(--border-light);
-	border-radius: 12px;
-	background: var(--bg-container);
-	cursor: pointer;
-	transition: all 0.2s ease;
+.wiki-page-menu {
+	border-right: 0;
+	background: transparent;
+}
+
+.wiki-page-menu__item {
+	height: auto;
+	min-height: 58px;
+	margin-bottom: 4px;
+	padding: 7px 8px !important;
+	border-radius: 8px;
+	line-height: 1.3;
 }
 
-.wiki-page-card:hover,
-.wiki-page-card--active {
-	border-color: var(--el-color-primary);
-	background: rgba(129, 0, 66, 0.06);
-	box-shadow: var(--shadow-sm);
+.wiki-page-menu__item.is-active {
+	background: rgba(129, 0, 66, 0.08);
 }
 
-.wiki-page-card__top,
-.wiki-page-card__meta {
+.wiki-menu-item {
 	display: flex;
 	align-items: center;
 	justify-content: space-between;
-	gap: 10px;
+	gap: 8px;
+	width: 100%;
+	min-width: 0;
 }
 
-.wiki-page-card__title {
-	font-weight: 700;
-	color: var(--text-primary);
+.wiki-menu-item__main {
+	display: flex;
+	flex: 1;
+	flex-direction: column;
+	gap: 3px;
+	min-width: 0;
+}
+
+.wiki-menu-item__title,
+.wiki-menu-item__slug {
 	overflow: hidden;
 	text-overflow: ellipsis;
 	white-space: nowrap;
 }
 
-.wiki-page-card__meta {
-	margin-top: 8px;
+.wiki-menu-item__title {
+	font-size: 13px;
+	font-weight: 600;
+	color: var(--text-primary);
+}
+
+.wiki-menu-item__slug {
 	font-size: 12px;
 	color: var(--text-secondary);
 }
 
-.wiki-main-scroll {
+.wiki-menu-item__extra {
 	display: flex;
+	flex-shrink: 0;
 	flex-direction: column;
-	gap: 16px;
+	align-items: flex-end;
+	gap: 4px;
+}
+
+.wiki-menu-item__version {
+	font-size: 12px;
+	line-height: 1;
+	color: var(--text-secondary);
+}
+
+.wiki-main-scroll {
 	min-width: 0;
-	max-height: calc(100vh - 320px);
-	overflow: auto;
-	padding-right: 4px;
+	min-height: 0;
+	border: 1px solid var(--border-light);
+	border-radius: 10px;
+	background: var(--bg-base);
+}
+
+.wiki-main-scroll :deep(.el-scrollbar__view) {
+	min-height: 100%;
+	padding: 12px;
+	box-sizing: border-box;
 }
 
 .graph-card,
 .detail-card {
-	padding: 18px;
-	border: 1px solid var(--border-light);
-	border-radius: 18px;
 	background: var(--bg-base);
 }
 
+.detail-card {
+	min-height: 100%;
+}
+
 .page-empty {
-	min-height: calc(100vh - 420px);
+	min-height: 260px;
 	display: flex;
 	align-items: center;
 	justify-content: center;
 }
 
 .page-empty--compact {
-	min-height: 180px;
+	min-height: 140px;
 }
 
 .graph-card__header,
@@ -978,13 +1037,13 @@ onBeforeUnmount(() => {
 	display: flex;
 	align-items: flex-start;
 	justify-content: space-between;
-	gap: 16px;
-	margin-bottom: 12px;
+	gap: 12px;
+	margin-bottom: 10px;
 }
 
 .graph-card__title,
 .detail-card__title {
-	font-size: 16px;
+	font-size: 15px;
 	font-weight: 700;
 	color: var(--text-primary);
 }
@@ -1060,14 +1119,23 @@ onBeforeUnmount(() => {
 
 .page-summary {
 	display: grid;
-	gap: 10px;
-	margin-bottom: 16px;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 6px 14px;
+	margin-bottom: 12px;
+	padding: 10px;
+	border-radius: 8px;
+	background: var(--bg-container);
+}
+
+.page-summary--compact {
+	grid-template-columns: minmax(0, 1fr);
 }
 
 .page-summary__row {
 	display: grid;
-	grid-template-columns: 88px minmax(0, 1fr);
-	gap: 12px;
+	grid-template-columns: 64px minmax(0, 1fr);
+	gap: 8px;
+	min-width: 0;
 }
 
 .page-summary__label {
@@ -1076,27 +1144,28 @@ onBeforeUnmount(() => {
 }
 
 .page-summary__value {
+	font-size: 13px;
 	color: var(--text-primary);
 	word-break: break-word;
 }
 
 .content-section {
-	margin-bottom: 16px;
+	margin-bottom: 12px;
 }
 
 .content-section__title {
-	margin-bottom: 8px;
+	margin-bottom: 6px;
 	font-size: 13px;
 	font-weight: 700;
 	color: var(--text-primary);
 }
 
 .content-section__body {
-	padding: 12px;
-	border-radius: 10px;
+	padding: 10px;
+	border-radius: 8px;
 	background: var(--bg-container);
 	color: var(--text-primary);
-	line-height: 1.7;
+	line-height: 1.6;
 	white-space: pre-wrap;
 	word-break: break-word;
 }
@@ -1110,13 +1179,13 @@ onBeforeUnmount(() => {
 .metadata-grid {
 	display: grid;
 	grid-template-columns: repeat(2, minmax(0, 1fr));
-	gap: 10px;
+	gap: 8px;
 }
 
 .metadata-grid__item {
-	padding: 12px;
+	padding: 10px;
 	border: 1px solid var(--border-light);
-	border-radius: 10px;
+	border-radius: 8px;
 	background: var(--bg-container);
 }
 
@@ -1132,69 +1201,33 @@ onBeforeUnmount(() => {
 	white-space: pre-wrap;
 }
 
-.recent-block {
-	margin-top: 8px;
-}
-
-.recent-list {
-	display: flex;
-	flex-direction: column;
-	gap: 10px;
-	max-height: 360px;
-	overflow: auto;
-}
-
-.recent-item {
-	padding: 12px;
-	border: 1px solid var(--border-light);
-	border-radius: 10px;
-	background: var(--bg-container);
-	cursor: pointer;
-	transition: all 0.2s ease;
-}
-
-.recent-item:hover {
-	border-color: var(--el-color-primary);
-	background: rgba(129, 0, 66, 0.06);
-}
-
-.recent-item__title {
-	font-weight: 600;
-	color: var(--text-primary);
-}
-
-.recent-item__meta {
-	margin-top: 6px;
-	display: flex;
-	gap: 10px;
-	font-size: 12px;
-	color: var(--text-secondary);
+.wiki-log-dialog__scroll {
+	max-height: min(68vh, 680px);
 }
 
 @media (max-width: 1200px) {
-	.wiki-overview {
-		grid-template-columns: repeat(2, minmax(0, 1fr));
-	}
-
 	.metadata-grid {
 		grid-template-columns: minmax(0, 1fr);
 	}
 }
 
 @media (max-width: 960px) {
+	.wiki-manage {
+		height: auto;
+		min-height: 0;
+	}
 
-	.wiki-layout,
-	.wiki-overview {
+	.wiki-layout {
 		grid-template-columns: minmax(0, 1fr);
 	}
 
-	.wiki-sidebar,
-	.wiki-sidebar--collapsed {
+	.wiki-sidebar {
 		width: 100%;
+		max-height: 420px;
 	}
 
 	.wiki-main-scroll {
-		max-height: none;
+		min-height: 520px;
 	}
 
 	.graph-card__header,
@@ -1205,5 +1238,9 @@ onBeforeUnmount(() => {
 	.graph-canvas {
 		height: 420px;
 	}
+
+	.page-summary {
+		grid-template-columns: minmax(0, 1fr);
+	}
 }
 </style>