ソースを参照

fix: 修改消息异常问题

jiaxing.liao 1 週間 前
コミット
c7835cb65d

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

@@ -12,6 +12,7 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    AiUiCard: typeof import('./src/components/Chat/AiUiCard.vue')['default']
     ChatInput: typeof import('./src/components/Chat/ChatInput.vue')['default']
     CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
@@ -53,12 +54,12 @@ declare module 'vue' {
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
-    ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
     ElPopover: typeof import('element-plus/es')['ElPopover']
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadio: typeof import('element-plus/es')['ElRadio']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElResult: typeof import('element-plus/es')['ElResult']
     ElRow: typeof import('element-plus/es')['ElRow']
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElSegmented: typeof import('element-plus/es')['ElSegmented']
@@ -98,6 +99,7 @@ declare module 'vue' {
 
 // For TSX support
 declare global {
+  const AiUiCard: typeof import('./src/components/Chat/AiUiCard.vue')['default']
   const ChatInput: typeof import('./src/components/Chat/ChatInput.vue')['default']
   const CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
   const ElAlert: typeof import('element-plus/es')['ElAlert']
@@ -139,12 +141,12 @@ declare global {
   const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
   const ElOption: typeof import('element-plus/es')['ElOption']
   const ElPagination: typeof import('element-plus/es')['ElPagination']
-  const ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
   const ElPopover: typeof import('element-plus/es')['ElPopover']
   const ElProgress: typeof import('element-plus/es')['ElProgress']
   const ElRadio: typeof import('element-plus/es')['ElRadio']
   const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
   const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+  const ElResult: typeof import('element-plus/es')['ElResult']
   const ElRow: typeof import('element-plus/es')['ElRow']
   const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
   const ElSegmented: typeof import('element-plus/es')['ElSegmented']

+ 2 - 0
apps/web/index.html

@@ -16,6 +16,8 @@
     <script defer src="/Views/SharedInclude/Permissions.js"></script>
     <script defer src="/Views/SharedInclude/BpmTools.js"></script>
     <script defer src="/Content/Scripts/systemConfig.js"></script>
+    <script defer src="/Content/Lib/layer/layer.js"></script>
+    <script defer src="/Content/Scripts/layerConfig.js"></script>
   </head>
   <body>
     <div id="app"></div>

+ 81 - 0
apps/web/public/ChatCard.vue

@@ -0,0 +1,81 @@
+<template>
+	<!-- 聊天卡片核心内容 -->
+	<div class="login-card">
+		<h3 class="card-title">请完成身份登录</h3>
+		<p class="card-desc">为保障你的使用安全,需要验证身份后才能继续使用服务</p>
+		<!-- 点击跳转登录页,替换 href 为你的登录链接即可 -->
+		<a href="javascript:alert('跳转到登录页面');" class="login-btn">立即登录</a>
+	</div>
+</template>
+
+<script>
+export default {
+	methods: {
+		handleLogin() {
+			alert('跳转到登录页面')
+		}
+	}
+}
+</script>
+
+<style scoped>
+/* 基础样式 - 适配聊天卡片,无多余边距 */
+* {
+	margin: 0;
+	padding: 0;
+	box-sizing: border-box;
+	font-family:
+		-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+/* 聊天卡片主体容器 */
+.login-card {
+	width: 100%;
+	max-width: 320px; /* 聊天界面标准宽度 */
+	background: #ffffff;
+	border-radius: 12px;
+	padding: 20px;
+	box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+	border: 1px solid #f0f0f0;
+}
+
+/* 标题样式 */
+.card-title {
+	font-size: 16px;
+	font-weight: 600;
+	color: #333333;
+	margin-bottom: 8px;
+	text-align: center;
+}
+
+/* 提示文字 */
+.card-desc {
+	font-size: 14px;
+	color: #666666;
+	text-align: center;
+	line-height: 1.5;
+	margin-bottom: 20px;
+}
+
+/* 登录按钮 */
+.login-btn {
+	display: block;
+	width: 100%;
+	height: 42px;
+	line-height: 42px;
+	background: #007aff; /* 主流聊天工具主色调 */
+	color: #ffffff;
+	text-align: center;
+	border-radius: 8px;
+	font-size: 15px;
+	font-weight: 500;
+	text-decoration: none;
+	transition: background 0.2s ease;
+	border: none;
+	cursor: pointer;
+}
+
+.login-btn:hover {
+	background: #0066cc;
+}
+</style>

+ 309 - 0
apps/web/src/components/Chat/AiUiCard.vue

@@ -0,0 +1,309 @@
+<template>
+	<el-result
+		v-if="shouldShowFallback"
+		class="ai-ui-card-result"
+		icon="error"
+		title="404"
+		sub-title="卡片加载失败"
+	/>
+	<iframe
+		v-else
+		ref="iframeRef"
+		class="ai-ui-card-iframe"
+		:src="iframeUrl"
+		:style="iframeStyle"
+		frameborder="0"
+		scrolling="no"
+		@error="handleIframeError"
+		@load="handleIframeLoad"
+	/>
+</template>
+
+<script setup lang="ts">
+import type { CSSProperties } from 'vue'
+import { computed, onBeforeUnmount, reactive, ref, watch, onMounted } from 'vue'
+
+interface BpmToolsWindow extends Window {
+	BpmTools?: {
+		$$make_ai_card_item_iframe?: (html: string) => string | undefined
+		$$open_ai_card_page?: (html: string, callback: (res: string) => void) => void
+	}
+}
+
+const win = window as BpmToolsWindow
+
+const props = defineProps<{
+	children: any
+	attrs: Record<string, any>
+}>()
+
+const emit = defineEmits<{
+	(e: 'submit', payload: string): void
+}>()
+
+const html = computed(() => {
+	const text = extractTextFromVNodes(props.children).trim()
+	const { attrs } = props
+	const keys = Object.keys(attrs)
+	const str = `<ai-ui-card ${keys.map((k) => `${k}="${attrs[k] || ''}"`).join(' ')}>${text || ''}</ai-ui-card>`
+	console.log('card-dom:', str)
+	return str
+})
+
+/**
+ * 获取children的文本
+ * @param vnodes
+ */
+function extractTextFromVNodes(vnodes: any): string {
+	if (!vnodes) return ''
+	if (typeof vnodes === 'string') return vnodes
+	let text = ''
+	if (typeof vnodes === 'function') {
+		text = vnodes()?.[0]
+	}
+	return text
+}
+
+const iframeUrl = computed(() => {
+	return win?.BpmTools?.$$make_ai_card_item_iframe?.(html.value) || ''
+})
+
+const iframeRef = ref<HTMLIFrameElement | null>(null)
+const isIframeLoadFailed = ref(false)
+const iframeSize = reactive({
+	width: 0,
+	height: 0
+})
+
+let resizeObserver: ResizeObserver | null = null
+let mutationObserver: MutationObserver | null = null
+let measureRaf = 0
+let validateToken = 0
+
+const shouldShowFallback = computed(() => !iframeUrl.value || isIframeLoadFailed.value)
+
+const iframeStyle = computed(
+	(): CSSProperties => ({
+		width: '100%',
+		height: iframeSize.height ? `${iframeSize.height}px` : '0',
+		visibility: iframeSize.height ? 'visible' : 'hidden'
+	})
+)
+
+watch(
+	iframeUrl,
+	(url) => {
+		const token = (validateToken += 1)
+		isIframeLoadFailed.value = !url
+		iframeSize.width = 0
+		iframeSize.height = 0
+		cleanupIframeWatchers()
+
+		validateIframeUrl(url, token)
+	},
+	{ immediate: true }
+)
+
+onBeforeUnmount(() => {
+	validateToken += 1
+	cleanupIframeWatchers()
+	if (measureRaf) {
+		cancelAnimationFrame(measureRaf)
+		measureRaf = 0
+	}
+})
+
+function cleanupIframeWatchers() {
+	resizeObserver?.disconnect()
+	mutationObserver?.disconnect()
+	resizeObserver = null
+	mutationObserver = null
+}
+
+function handleIframeError() {
+	isIframeLoadFailed.value = true
+	cleanupIframeWatchers()
+}
+
+function handleIframeLoad() {
+	if (isIframeLoadFailed.value) {
+		return
+	}
+
+	cleanupIframeWatchers()
+
+	const doc = getIframeDocument()
+	if (!doc?.body) {
+		return
+	}
+
+	doc.documentElement.style.overflow = 'hidden'
+	doc.body.style.margin = '0'
+	doc.body.style.overflow = 'hidden'
+
+	resizeObserver = new ResizeObserver(() => {
+		scheduleMeasureIframe()
+	})
+	resizeObserver.observe(doc.documentElement)
+	resizeObserver.observe(doc.body)
+	observeBodyChildren(doc.body)
+
+	mutationObserver = new MutationObserver(() => {
+		observeBodyChildren(doc.body)
+		scheduleMeasureIframe()
+	})
+	mutationObserver.observe(doc.body, {
+		attributes: true,
+		characterData: true,
+		childList: true,
+		subtree: true
+	})
+
+	scheduleMeasureIframe()
+}
+
+async function validateIframeUrl(url: string, token: number) {
+	if (!url) {
+		isIframeLoadFailed.value = true
+		return
+	}
+
+	const parsedUrl = getSameOriginHttpUrl(url)
+	if (!parsedUrl) return
+
+	try {
+		const response = await fetch(parsedUrl, {
+			method: 'HEAD',
+			credentials: 'same-origin'
+		})
+
+		if (token === validateToken && !response.ok) {
+			isIframeLoadFailed.value = true
+			cleanupIframeWatchers()
+		}
+	} catch {
+		if (token === validateToken) {
+			isIframeLoadFailed.value = true
+			cleanupIframeWatchers()
+		}
+	}
+}
+
+function getSameOriginHttpUrl(url: string) {
+	try {
+		const parsedUrl = new URL(url, window.location.href)
+		if (!['http:', 'https:'].includes(parsedUrl.protocol)) return ''
+		if (parsedUrl.origin !== window.location.origin) return ''
+		return parsedUrl.href
+	} catch {
+		return ''
+	}
+}
+
+function getIframeDocument() {
+	const iframe = iframeRef.value
+	if (!iframe) return null
+
+	try {
+		return iframe.contentDocument || iframe.contentWindow?.document || null
+	} catch {
+		return null
+	}
+}
+
+function observeBodyChildren(body: HTMLElement) {
+	if (!resizeObserver) return
+
+	Array.from(body.children).forEach((child) => {
+		resizeObserver?.observe(child)
+	})
+}
+
+function scheduleMeasureIframe() {
+	if (measureRaf) {
+		cancelAnimationFrame(measureRaf)
+	}
+
+	measureRaf = requestAnimationFrame(() => {
+		measureRaf = requestAnimationFrame(() => {
+			measureRaf = 0
+			measureIframe()
+		})
+	})
+}
+
+function measureIframe() {
+	const doc = getIframeDocument()
+	if (!doc?.body) return
+
+	const size = getContentSize(doc)
+	iframeSize.height = size.height
+}
+
+function getContentSize(doc: Document) {
+	const body = doc.body
+	const root = doc.documentElement
+	const children = Array.from(body.children)
+
+	if (!children.length) {
+		return {
+			width: Math.ceil(Math.max(body.scrollWidth, root.scrollWidth)),
+			height: Math.ceil(Math.max(body.scrollHeight, root.scrollHeight))
+		}
+	}
+
+	const bodyRect = body.getBoundingClientRect()
+	let left = 0
+	let top = 0
+	let right = 0
+	let bottom = 0
+
+	children.forEach((child) => {
+		const rect = child.getBoundingClientRect()
+		left = Math.min(left, rect.left - bodyRect.left)
+		top = Math.min(top, rect.top - bodyRect.top)
+		right = Math.max(right, rect.right - bodyRect.left)
+		bottom = Math.max(bottom, rect.bottom - bodyRect.top)
+	})
+
+	return {
+		width: '100%',
+		height: Math.ceil(Math.max(bottom - top, body.scrollHeight, root.scrollHeight, 1))
+	}
+}
+
+/**
+ * 处理iframe消息
+ * @param event
+ */
+const handleMessage = (event: MessageEvent) => {
+	const data = event.data || {}
+	if (data?.type === 'ai-ui-card' && data?.data) {
+		emit('submit', data.data)
+	}
+}
+
+onBeforeUnmount(() => {
+	window.removeEventListener('message', handleMessage)
+})
+
+onMounted(() => {
+	window.addEventListener('message', handleMessage)
+})
+</script>
+
+<style scoped>
+.ai-ui-card-iframe {
+	display: block;
+	max-width: 100%;
+	min-width: 360px;
+	border: 0;
+	overflow: hidden;
+}
+
+.ai-ui-card-result {
+	width: 100%;
+	min-width: 360px;
+	padding: 16px 0;
+}
+</style>

+ 129 - 36
apps/web/src/components/Chat/MessageList.vue

@@ -5,7 +5,7 @@
 			ref="bubbleListRef"
 			class="item-list"
 			:list="bubbleListItems"
-			auto-scroll
+			:auto-scroll="autoScrollEnabled"
 			max-height="100%"
 			show-back-button
 			:btn-loading="loading"
@@ -38,13 +38,7 @@
 							color="var(--text-primary)"
 						>
 							<template #content="{ content }">
-								<SMarkdown
-									:markdown="content"
-									:customAttrs="markdownCustomAttrs"
-									allow-html
-									enableLatex
-									enableBreaks
-								/>
+								<SMarkdown :markdown="content" allow-html enableLatex enableBreaks />
 							</template>
 						</Thinking>
 
@@ -138,23 +132,23 @@
 						</div>
 					</div>
 
-						<div v-if="getInlineErrors(item).length" class="inline-errors">
-							<div v-for="(err, idx) in getInlineErrors(item)" :key="idx" class="inline-error-item">
-								<el-tag type="danger" size="small" effect="plain" class="inline-error-tag">
-									{{ t('pages.chat.failedTag') }}
-								</el-tag>
-								<span class="inline-error-text">{{ err }}</span>
-							</div>
+					<div v-if="getInlineErrors(item).length" class="inline-errors">
+						<div v-for="(err, idx) in getInlineErrors(item)" :key="idx" class="inline-error-item">
+							<el-tag type="danger" size="small" effect="plain" class="inline-error-tag">
+								{{ t('pages.chat.failedTag') }}
+							</el-tag>
+							<span class="inline-error-text">{{ err }}</span>
 						</div>
+					</div>
 					<SMarkdown
 						v-if="getDisplayText(item)"
 						:class="['msg-content-text', { 'msg-content-text--error': isErrorMessage(item) }]"
 						:markdown="getDisplayText(item)"
-						:customAttrs="markdownCustomAttrs"
 						allow-html
 						enableLatex
 						enableBreaks
 						need-view-code-btn
+						@card-submit="(val) => emit('card-submit', val)"
 					/>
 					<div v-if="item?.stopped" class="msg-stop-indicator">(已停止)</div>
 				</div>
@@ -223,7 +217,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { BubbleList, Thinking } from 'vue-element-plus-x'
 import { useI18n } from '@/composables/useI18n'
 import { ElMessage } from 'element-plus'
@@ -250,16 +244,9 @@ const props = withDefaults(
 const emit = defineEmits<{
 	retry: [message: BubbleMessage]
 	addToKb: [text: string]
+	'card-submit': [text: string]
 }>()
 
-const markdownCustomAttrs = {
-	kb: (_node: any, attrs: Record<string, any>) => ({
-		class: 'kb-reference-node',
-		'data-doc': attrs.doc || '',
-		'data-chunk-id': attrs.chunk_id || ''
-	})
-}
-
 const getMessageImages = (message: BubbleMessage) => {
 	const files = message.message_files
 	const ids = Array.isArray(files) ? files : files ? [files] : []
@@ -272,9 +259,16 @@ const bubbleListRef = ref<{
 	scrollToBubble: (index: number) => void
 } | null>(null)
 const chatContentRef = ref<HTMLElement | null>(null)
+const autoScrollEnabled = ref(true)
 let scrollFrameId: number | null = null
 let scrollResizeObserver: ResizeObserver | null = null
+let scrollMutationObserver: MutationObserver | null = null
+let scrollStabilityTimerId: number | null = null
+let boundScrollContainer: HTMLElement | null = null
+let isProgrammaticScroll = false
 const scrollTimeoutIds = new Set<number>()
+const AUTO_SCROLL_BOTTOM_THRESHOLD = 120
+const SCROLL_STABILITY_MS = 3000
 
 const bubbleListItems = computed(() =>
 	props.messages.map((item) => ({
@@ -290,43 +284,128 @@ const clearScheduledScroll = () => {
 	}
 	scrollResizeObserver?.disconnect()
 	scrollResizeObserver = null
+	scrollMutationObserver?.disconnect()
+	scrollMutationObserver = null
+	if (scrollStabilityTimerId !== null) {
+		window.clearTimeout(scrollStabilityTimerId)
+		scrollStabilityTimerId = null
+	}
 	scrollTimeoutIds.forEach((id) => window.clearTimeout(id))
 	scrollTimeoutIds.clear()
 }
 
+const unbindScrollContainer = () => {
+	boundScrollContainer?.removeEventListener('scroll', handleScroll)
+	boundScrollContainer = null
+}
+
 const getScrollContainer = () => {
 	const root = chatContentRef.value
 	if (!root) return null
 	return root.querySelector<HTMLElement>('.el-bubble-list')
 }
 
+const getDistanceToBottom = (scrollContainer: HTMLElement) =>
+	scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight
+
+const updateAutoScrollState = () => {
+	const scrollContainer = getScrollContainer()
+	if (!scrollContainer) {
+		autoScrollEnabled.value = true
+		return
+	}
+
+	autoScrollEnabled.value = getDistanceToBottom(scrollContainer) <= AUTO_SCROLL_BOTTOM_THRESHOLD
+}
+
+function handleScroll() {
+	// 程序化滚动触发的事件不更新自动滚动状态
+	if (isProgrammaticScroll) return
+	updateAutoScrollState()
+	if (!autoScrollEnabled.value) {
+		clearScheduledScroll()
+	}
+}
+
+const bindScrollContainer = async () => {
+	await nextTick()
+	const scrollContainer = getScrollContainer()
+	if (scrollContainer === boundScrollContainer) {
+		updateAutoScrollState()
+		return
+	}
+
+	unbindScrollContainer()
+	boundScrollContainer = scrollContainer
+	boundScrollContainer?.addEventListener('scroll', handleScroll, { passive: true })
+	updateAutoScrollState()
+}
+
 const scrollToBottomOnce = () => {
+	isProgrammaticScroll = true
 	bubbleListRef.value?.scrollToBottom()
 
 	const scrollContainer = getScrollContainer()
 	if (scrollContainer) {
 		scrollContainer.scrollTop = scrollContainer.scrollHeight
 	}
+	autoScrollEnabled.value = true
+	// 延迟释放标志,确保 scroll 事件在当前帧内不会误判
+	requestAnimationFrame(() => {
+		isProgrammaticScroll = false
+	})
 }
 
 const keepBottomDuringLayout = () => {
 	const scrollContainer = getScrollContainer()
-	if (!scrollContainer || typeof ResizeObserver === 'undefined') return
+	if (!scrollContainer) return
 
+	// ResizeObserver: 观察滚动容器本身和直接子元素的尺寸变化
 	scrollResizeObserver?.disconnect()
 	scrollResizeObserver = new ResizeObserver(() => {
-		scrollToBottomOnce()
+		if (autoScrollEnabled.value) {
+			scrollToBottomOnce()
+			resetStabilityTimer()
+		}
 	})
+	scrollResizeObserver.observe(scrollContainer)
 	Array.from(scrollContainer.children).forEach((child) => {
 		scrollResizeObserver?.observe(child)
 	})
 
-	const timeoutId = window.setTimeout(() => {
-		scrollTimeoutIds.delete(timeoutId)
+	// MutationObserver: 观察整个子树的 DOM 变化(Markdown 渲染、图片加载、虚拟滚动新增项等)
+	scrollMutationObserver?.disconnect()
+	scrollMutationObserver = new MutationObserver(() => {
+		if (autoScrollEnabled.value) {
+			scrollToBottomOnce()
+			resetStabilityTimer()
+		}
+		// 动态观察新增的子元素(虚拟滚动可能动态添加)
+		Array.from(scrollContainer.children).forEach((child) => {
+			scrollResizeObserver?.observe(child)
+		})
+	})
+	scrollMutationObserver.observe(scrollContainer, {
+		characterData: true,
+		childList: true,
+		subtree: true
+	})
+
+	// 内容稳定后自动断开(无 DOM 变化 3s 后停止观察,避免长期性能开销)
+	resetStabilityTimer()
+}
+
+const resetStabilityTimer = () => {
+	if (scrollStabilityTimerId !== null) {
+		window.clearTimeout(scrollStabilityTimerId)
+	}
+	scrollStabilityTimerId = window.setTimeout(() => {
+		scrollStabilityTimerId = null
 		scrollResizeObserver?.disconnect()
 		scrollResizeObserver = null
-	}, 1200)
-	scrollTimeoutIds.add(timeoutId)
+		scrollMutationObserver?.disconnect()
+		scrollMutationObserver = null
+	}, SCROLL_STABILITY_MS)
 }
 
 const scrollToBottom = async () => {
@@ -338,14 +417,18 @@ const scrollToBottom = async () => {
 
 	scrollFrameId = window.requestAnimationFrame(() => {
 		scrollFrameId = null
-		scrollToBottomOnce()
-		keepBottomDuringLayout()
+		if (autoScrollEnabled.value) {
+			scrollToBottomOnce()
+			keepBottomDuringLayout()
+		}
 	})
 
 	for (const delay of [60, 180, 360]) {
 		const timeoutId = window.setTimeout(() => {
 			scrollTimeoutIds.delete(timeoutId)
-			scrollToBottomOnce()
+			if (autoScrollEnabled.value) {
+				scrollToBottomOnce()
+			}
 		}, delay)
 		scrollTimeoutIds.add(timeoutId)
 	}
@@ -354,15 +437,23 @@ const scrollToBottom = async () => {
 watch(
 	() => props.messages.length,
 	(length, oldLength) => {
+		bindScrollContainer()
 		if (length > 0 && length !== oldLength) {
-			scrollToBottom()
+			if (autoScrollEnabled.value) {
+				scrollToBottom()
+			}
 		}
 	},
 	{ flush: 'post' }
 )
 
+onMounted(() => {
+	bindScrollContainer()
+})
+
 onBeforeUnmount(() => {
 	clearScheduledScroll()
+	unbindScrollContainer()
 })
 
 const THINK_TAG_RE = /<think\b[^>]*>([\s\S]*?)(?:<\/think>|$)/gi
@@ -474,6 +565,7 @@ const getInlineErrors = (message: BubbleMessage) => message.inlineErrors || []
 defineExpose({
 	scrollToTop: () => bubbleListRef.value?.scrollToTop(),
 	scrollToBottom,
+	isNearBottom: () => autoScrollEnabled.value,
 	scrollToBubble: (index: number) => bubbleListRef.value?.scrollToBubble(index)
 })
 
@@ -815,4 +907,5 @@ const handleAddToKb = (message: BubbleMessage) => {
 	color: var(--el-color-danger);
 	line-height: 1.5;
 	word-break: break-all;
-}</style>
+}
+</style>

+ 209 - 4
apps/web/src/components/Chat/SMarkdown.vue

@@ -1,5 +1,5 @@
 <template>
-	<XMarkdown v-bind="xMarkdownProps">
+	<XMarkdown v-bind="xMarkdownProps" :custom-attrs="markdownCustomAttrs">
 		<!-- 知识引用 -->
 		<template #kb="{ doc }">
 			<span class="kb-reference">
@@ -7,6 +7,10 @@
 				<span class="kb-reference__doc">{{ doc }}</span>
 			</span>
 		</template>
+		<!-- UI信息卡片 -->
+		<template #ai-ui-card="{ children, ...attrs }">
+			<AiUiCard :attrs="attrs" :children="children" @submit="(val) => emit('card-submit', val)" />
+		</template>
 		<!-- 图片预览 -->
 		<template #img="{ ...props }">
 			<el-image
@@ -21,10 +25,11 @@
 	</XMarkdown>
 </template>
 
-<script setup lang="ts">
+<script setup lang="tsx">
 import { computed } from 'vue'
 import { XMarkdown } from 'vue-element-plus-x'
 import { Document } from '@element-plus/icons-vue'
+import AiUiCard from './AiUiCard.vue'
 
 import type { MarkdownProps } from 'vue-element-plus-x/types/XMarkdownCore/shared'
 
@@ -33,11 +38,35 @@ defineOptions({
 })
 
 const props = defineProps<MarkdownProps>()
+const emit = defineEmits<{
+	(e: 'card-submit', payload: string): void
+}>()
 
 const KB_SELF_CLOSING_TAG_RE = /<kb\b([^>]*)\/>/gi
 
-const normalizeMarkdownContent = (content?: string) =>
-	`${content || ''}`.replace(KB_SELF_CLOSING_TAG_RE, '<kb$1></kb>')
+const normalizeMarkdownContent = (content?: string) => {
+	let text = `${content || ''}`
+
+	// kb 自闭合标签补全
+	text = text.replace(KB_SELF_CLOSING_TAG_RE, '<kb$1></kb>')
+
+	// ai-ui-card: 流式输出时,不完整的标签先隐藏,待闭合标签完整后才渲染
+	text = stripIncompleteAiCard(text)
+
+	return text
+}
+
+function stripIncompleteAiCard(text: string): string {
+	const openIdx = text.lastIndexOf('<ai-ui-card')
+	if (openIdx === -1) return text
+
+	const closeIdx = text.lastIndexOf('</ai-ui-card>')
+	// 闭合标签在开标签之后 → 标签已完整,保留原文
+	if (closeIdx > openIdx) return text
+
+	// 标签未闭合(流式输出中)→ 去掉从开标签到末尾的部分
+	return text.slice(0, openIdx)
+}
 
 const getMarkdownContent = computed(() => {
 	return normalizeMarkdownContent(props.markdown)
@@ -47,6 +76,18 @@ const xMarkdownProps = computed(() => ({
 	...props,
 	markdown: getMarkdownContent.value
 }))
+
+const markdownCustomAttrs = {
+	kb: (_node: any, attrs: Record<string, any>) => ({
+		class: 'kb-reference-node',
+		'data-doc': attrs.doc || '',
+		'data-chunk-id': attrs.chunk_id || ''
+	})
+	// 'ai-ui-card': (_node: any, attrs: Record<string, any>) => {
+	// 	console.log('ai-ui-card', attrs)
+	// 	return {}
+	// }
+}
 </script>
 
 <style scoped lang="less">
@@ -79,4 +120,168 @@ const xMarkdownProps = computed(() => ({
 	text-overflow: ellipsis;
 	white-space: nowrap;
 }
+
+/* -------- ai-ui-card styles -------- */
+.ai-ui-card {
+	display: block;
+}
+
+:deep(.ui-card) {
+	margin: 12px 0;
+	padding: 16px;
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 12px;
+	background: var(--el-bg-color);
+	max-width: 400px;
+	font-size: 14px;
+	line-height: 1.5;
+	color: var(--el-text-color-primary);
+}
+
+:deep(.ui-card__header) {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 14px;
+}
+
+:deep(.ui-card__icon) {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 36px;
+	height: 36px;
+	border-radius: 10px;
+	background: var(--el-color-primary-light-9);
+	color: var(--el-color-primary);
+	font-size: 18px;
+	flex-shrink: 0;
+}
+
+:deep(.ui-card__header-text) {
+	min-width: 0;
+	flex: 1;
+}
+
+:deep(.ui-card__title) {
+	font-size: 15px;
+	font-weight: 600;
+	color: var(--el-text-color-primary);
+	line-height: 1.3;
+}
+
+:deep(.ui-card__desc) {
+	margin-top: 2px;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	line-height: 1.4;
+}
+
+:deep(.ui-card__field) {
+	margin-bottom: 12px;
+}
+
+:deep(.ui-card__label) {
+	display: block;
+	margin-bottom: 4px;
+	font-size: 13px;
+	font-weight: 500;
+	color: var(--el-text-color-regular);
+}
+
+:deep(.ui-card__input-wrap) {
+	width: 100%;
+}
+
+:deep(.ui-card__input) {
+	display: block;
+	width: 100%;
+	height: 36px;
+	padding: 0 12px;
+	border: 1px solid var(--el-border-color);
+	border-radius: 8px;
+	background: var(--el-fill-color-blank);
+	color: var(--el-text-color-primary);
+	font-size: 14px;
+	line-height: 36px;
+	outline: none;
+	transition: border-color 0.2s;
+	box-sizing: border-box;
+
+	&:focus {
+		border-color: var(--el-color-primary);
+		box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
+	}
+
+	&::placeholder {
+		color: var(--el-text-color-placeholder);
+	}
+}
+
+:deep(.ui-card__actions) {
+	display: flex;
+	gap: 8px;
+	margin-top: 14px;
+}
+
+:deep(.ui-card__btn) {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	height: 34px;
+	padding: 0 16px;
+	border: 1px solid var(--el-border-color);
+	border-radius: 8px;
+	background: var(--el-bg-color);
+	color: var(--el-text-color-regular);
+	font-size: 13px;
+	cursor: pointer;
+	transition: all 0.2s;
+	line-height: 1;
+
+	&:hover {
+		border-color: var(--el-color-primary-light-3);
+		color: var(--el-color-primary);
+		background: var(--el-color-primary-light-9);
+	}
+}
+
+:deep(.ui-card__btn--primary) {
+	background: var(--el-color-primary);
+	border-color: var(--el-color-primary);
+	color: #fff;
+
+	&:hover {
+		background: var(--el-color-primary-light-3);
+		border-color: var(--el-color-primary-light-3);
+		color: #fff;
+	}
+}
+
+:deep(.ui-card__data) {
+	margin: 8px 0;
+	padding: 10px 12px;
+	border-radius: 8px;
+	background: var(--el-fill-color);
+	font-size: 12px;
+	line-height: 1.6;
+	white-space: pre-wrap;
+	word-break: break-all;
+	color: var(--el-text-color-regular);
+	overflow: auto;
+}
+
+:deep(.ui-card__success) {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	padding: 10px 0 0;
+	color: var(--el-color-success);
+	font-size: 14px;
+	font-weight: 500;
+}
+
+:deep(.ui-card__success-icon) {
+	font-size: 16px;
+}
 </style>

+ 1 - 2
apps/web/src/views/WorkflowExecution.vue

@@ -224,8 +224,7 @@ const sourceMap = {
 const summary = computed(() => [
 	{ label: t('pages.execution.summaryLabels.processing'), value: `${stats.processTotal}` },
 	{ label: t('pages.execution.summaryLabels.success'), value: `${stats.successTotal}` },
-	{ label: t('pages.execution.summaryLabels.failed'), value: `${stats.errorTotal}` },
-	{ label: t('pages.execution.summaryLabels.currentPage'), value: `${executions.value.length}` }
+	{ label: t('pages.execution.summaryLabels.failed'), value: `${stats.errorTotal}` }
 ])
 
 const tips = computed(() => [

+ 262 - 0
apps/web/src/views/chat/AGENTS.md

@@ -0,0 +1,262 @@
+# Chat 模块 — AI 智能对话页面
+
+## 概述
+
+AI 智能对话页面,支持三种对话模式:**知识库问答**、**智能体问答**、**模型聊天**。核心特性包括:SSE 流式响应、思考链(Thinking)展示、工具调用可视化、知识引用、AI UI 卡片(可嵌入外部交互)、图片上传、会话管理(创建/删除/重命名/分页加载)等。
+
+---
+
+## 文件结构
+
+```
+views/chat/
+├── index.vue              # 主页面(1334行),状态中枢,消息流编排
+├── types.ts               # 核心类型定义(BubbleMessage, ChatSseMessage, Conversation 等)
+├── api/
+│   └── chat.api.ts        # API 层封装(会话CRUD、聊天URL构建、选项数据获取)
+├── composables/
+│   └── useChatStream.ts   # SSE 流式请求(hook-fetch + SSE 解码)
+├── components/
+│   ├── ChatSidebar.vue    # 左侧会话列表(新建/选择/重命名/删除/无限滚动)
+│   ├── ChatHeader.vue     # 顶部标题栏
+│   ├── AddKbModal.vue     # 添加到知识库弹窗
+│   └── SettingModal.vue   # 设置弹窗(预留)
+└── param.md               # 后端 API 参数说明文档
+
+components/Chat/           # 共享聊天组件(非 chat 视图专属)
+├── MessageList.vue        # 消息列表渲染(BubbleList + Thinking + 工具卡片 + Markdown)
+├── SMarkdown.vue          # Markdown 渲染(XMarkdown + kb/ai-ui-card/img 自定义 slot)
+├── ChatInput.vue          # 输入框(Sender + 附件预览 + header/prefix/action 插槽)
+└── AiUiCard.vue           # AI UI 卡片容器(iframe 嵌入外部 HTML 交互组件)
+```
+
+---
+
+## 架构与数据流
+
+```
+用户输入
+  ↓
+index.vue handleSend()
+  ├── createUserMessageWithAttachments() → messages.push(userMsg)
+  ├── createAiMessage() → messages.push(aiMsg)
+  ├── buildChatRequestBody() → 构建请求体(根据 targetType 决定参数)
+  └── useChatStream.streamChat()
+        ├── POST → /api/ai/chat/{agent-chat|knowledge-chat|model-chat}
+        ├── SSE 流式接收 → parseSseBlock() → onChunk(ChatSseMessage)
+        └── applyStructuredEventToMessage() → 按 response_type 分发到对应字段
+              ├── agent_query / answer → message.answerText
+              ├── thinking → message.thinking
+              ├── tool_call → message.toolCalls[]
+              ├── tool_result → message.toolResults[]
+              ├── references → message.references[]
+              ├── error → message.inlineErrors[]
+              ├── session_title → conversations[].title
+              └── complete → message.streamCompleted = true
+
+消息渲染
+  ↓
+MessageList.vue → BubbleList
+  ├── Thinking 组件(思考内容,Markdown 渲染)
+  ├── 工具调用卡片(tool_call / tool_result)
+  ├── SMarkdown(Markdown 内容 + 自定义标签)
+  │     ├── <kb> → 知识引用 slot
+  │     ├── <ai-ui-card> → AiUiCard 组件(iframe)
+  │     └── <img> → el-image 预览
+  └── 操作栏(复制、添加到知识库)
+```
+
+---
+
+## 三种对话模式
+
+| 模式       | targetType  | API 路径                      | 关键参数                                                    |
+| ---------- | ----------- | ----------------------------- | ----------------------------------------------------------- |
+| 知识库问答 | `knowledge` | `/api/ai/chat/knowledge-chat` | `knowledge_base_ids`, `knowledge_ids`                       |
+| 智能体问答 | `agent`     | `/api/ai/chat/agent-chat`     | `agent_id`, `images`, `agent_enabled`, `web_search_enabled` |
+| 模型聊天   | `model`     | `/api/ai/chat/model-chat`     | `summary_model_id`                                          |
+
+**公共参数**:`session_id`, `query`, `summary_model_id`, `disable_title`, `enable_memory`, `channel`(固定 `web`)
+
+---
+
+## SSE 响应协议 (response_type)
+
+| 类型            | 说明           | 附带数据                                                                              |
+| --------------- | -------------- | ------------------------------------------------------------------------------------- |
+| `agent_query`   | Agent 开始处理 | `assistant_message_id`, `session_id`                                                  |
+| `thinking`      | 思考内容       | `content`                                                                             |
+| `tool_call`     | 工具调用       | `data: { tool_call_id, tool_name, arguments }`                                        |
+| `tool_result`   | 工具结果       | `data: { success, output, error, thought, duration_ms, content_items, display_type }` |
+| `references`    | 知识引用       | `data: { knowledge_references: [...] }`                                               |
+| `answer`        | 回答内容       | `content`                                                                             |
+| `error`         | 错误信息       | `content`, `data.error`                                                               |
+| `session_title` | 会话标题       | `data: { title, session_id }`                                                         |
+| `complete`      | 流结束         | `data: { total_duration_ms, total_steps }`                                            |
+
+**注意**:回答中可能包含 `<think>...</think>` 标签,需通过 `parseAnswerThinkState()` 提取思考内容。
+
+---
+
+## 核心状态
+
+```typescript
+// index.vue 主要响应式状态
+const conversations = ref<Conversation[]>([])        // 会话列表(分页)
+const sessionConfigMap = reactive<Record<string, ChatTargetConfig>>({})  // 每个会话的独立配置
+const activeConversationId = ref('')                 // 当前激活会话
+const activeTargetType = ref<ChatTargetType>('agent') // 当前对话模式
+const messages = ref<BubbleMessage[]>([])            // 当前消息列表
+const settingsDraft = reactive<ChatTargetConfig>(...) // 当前设置草稿(知识库/模型/智能体选择)
+const currentAttachments = ref<WorkflowUploadFile[]>([]) // 当前附件
+const isLoading = ref(false)                         // 流式请求中
+```
+
+**会话配置持久化**:
+
+- `sessionConfigMap` 内存缓存每个会话的配置
+- 选择会话时通过 `getConversationConfig()` 恢复配置(缓存 > 历史 > 默认)
+- `cloneConfig()` 深拷贝配置对象避免引用污染
+
+---
+
+## AiUiCard 卡片系统
+
+`<ai-ui-card>` 是 AI 返回的特殊 HTML 标签,XMarkdown 自动将其转为 slot,由 `AiUiCard.vue` 渲染。
+
+**渲染方式**:iframe 嵌入外部 HTML 页面
+
+- 通过 `window.BpmTools.$$make_ai_card_item_iframe(html)` 获取 iframe URL(宿主环境注入)
+- iframe 内容自适应高度:`ResizeObserver` + `MutationObserver` 监听 DOM 变化
+- 卡片提交事件:iframe `postMessage` → `AiUiCard.vue` → `emit('submit')` → `MessageList` → `index.vue`
+
+**通信协议**:
+
+```javascript
+// iframe 内向父页面发送数据
+window.parent.postMessage({ type: 'ai-ui-card', data: '用户填写的JSON字符串' }, '*')
+```
+
+**示例 AI 输出**:
+
+```html
+<ai-ui-card type="system-login" event="ask">
+	{"loginType": "3", "loginWay": "All", "usn": "", "pwd": ""}
+</ai-ui-card>
+```
+
+---
+
+## 关键组件 API
+
+### MessageList.vue
+
+```typescript
+// Props
+messages: BubbleMessage[]
+loading: boolean
+copy?: boolean         // 是否显示复制按钮(默认 true)
+canAddToKb?: boolean   // 是否显示添加到知识库按钮(默认 true)
+
+// Emits
+retry: [message: BubbleMessage]
+addToKb: [text: string]
+'card-submit': [text: string]
+
+// Expose
+scrollToTop(): void
+scrollToBottom(): void
+isNearBottom(): boolean
+scrollToBubble(index: number): void
+```
+
+### ChatInput.vue
+
+```typescript
+// Props(v-model)
+modelValue: string
+loading: boolean
+attachments: WorkflowUploadFile[]
+
+// Emits
+submit: [content: string]
+cancel: []
+
+// Slots
+#header     — 知识库/知识选择区域
+#prefix-extra — 智能体选择、附件上传按钮
+#action     — 模型选择
+```
+
+### SMarkdown.vue
+
+```typescript
+// Props(透传 XMarkdown)
+markdown: string
+customAttrs?: Record<string, any>
+allowHtml?: boolean
+enableLatex?: boolean
+enableBreaks?: boolean
+
+// Emits
+'card-submit': [payload: { type: string; event: string; data: Record<string, any> }]
+
+// 自定义标签 slot
+#kb        — 知识引用
+#ai-ui-card — AI UI 卡片
+#img       — 图片预览
+```
+
+---
+
+## 自动滚动机制
+
+MessageList 实现了智能自动滚动:
+
+1. **监听滚动容器**:通过 `getScrollContainer()` 获取 `.el-bubble-list` 容器
+2. **判断是否贴近底部**:`getDistanceToBottom() <= 120px` 时启用自动滚动
+3. **用户手动上滚**:禁用自动滚动,不再强制追底
+4. **ResizeObserver**:布局变化时保持追底(限时 1200ms)
+5. **多次延迟滚动**:`[60ms, 180ms, 360ms]` 三次延迟确保异步渲染完成
+6. **加载历史消息时**:需要滚动到最底部
+7. **会话切换时**:滚动到会话最底部
+8. **发起新的对话时**:滚动到会话最底部
+
+---
+
+## 会话管理
+
+- **分页加载**:`currentPage` + `pageSize=20`,`hasMore` 控制是否还有下一页
+- **无限滚动**:`ChatSidebar` 滚动到底部触发 `@load-more`
+- **新建会话**:调用 `createSession()` → 刷新列表 → 自动选中
+- **删除会话**:`ElMessageBox.confirm` → `deleteSession()` → 刷新
+- **重命名**:弹出 `el-dialog` → `updateSessionName()`
+- **历史消息加载**:`getSessionMessages(sessionId)` → `parseHistoryRecord()` 解析
+
+---
+
+## 国际化 (i18n)
+
+所有文本通过 `useI18n()` 的 `t()` 函数获取,key 前缀为 `pages.chat.*`。
+
+常用 key:
+
+- `pages.chat.newChat` / `pages.chat.newConversation`
+- `pages.chat.history` / `pages.chat.noHistory`
+- `pages.chat.rename` / `pages.chat.delete`
+- `pages.chat.toolCallTitle` / `pages.chat.toolResultTitle` / `pages.chat.thinkTitle`
+- `pages.chat.emptyTitle` / `pages.chat.emptySubtitle`
+- `pages.chat.selectAgentPlaceholder` / `pages.chat.selectModelPlaceholder`
+- `pages.chat.copySuccess` / `pages.chat.failedTag`
+
+---
+
+## 开发约定
+
+1. **消息数据结构**:`BubbleMessage` 是唯一的消息类型,所有字段可选,通过 `role: 'user' | 'ai'` 区分
+2. **流式数据**:只通过 `useChatStream` 处理,不要在组件中直接调用 SSE API
+3. **Markdown 渲染**:所有 Markdown 内容必须通过 `SMarkdown` 组件,不要直接使用 `v-html`
+4. **暗黑模式**:样式使用 CSS 变量(`var(--text-primary)`, `var(--bg-container)`, `var(--border-light)` 等),确保暗黑模式兼容
+5. **组件通信**:使用 `emit` 向上传递事件,避免 `provide/inject` 传递业务数据
+6. **API 层**:所有后端调用集中在 `api/chat.api.ts`,使用 `@repo/api-service` 的封装模块
+7. **配置隔离**:每个会话维护独立的 `ChatTargetConfig`,切换会话时通过 `syncSettingsDraft()` 同步

+ 13 - 20
apps/web/src/views/chat/index.vue

@@ -24,6 +24,7 @@
 				:loading="isLoading"
 				@retry="handleRetry"
 				@add-to-kb="handleAddKb"
+				@card-submit="handleSend"
 			>
 				<div
 					v-if="settingsDraft.type === 'agent' && agentPromptsItems?.length"
@@ -149,7 +150,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { PictureFilled } from '@element-plus/icons-vue'
 import { useI18n } from '@/composables/useI18n'
@@ -218,27 +219,19 @@ const activeStreamToken = ref('')
 const chatInputRef = ref<InstanceType<typeof ChatInput>>()
 const agentPromptsItems = ref<PromptsItemsProps[]>()
 const addkbRef = ref<InstanceType<typeof AddKbModal>>()
-let scrollToBottomRafId: number | null = null
 let scrollToBottomPending = false
 
-const clearPendingScrollToBottom = () => {
-	scrollToBottomPending = false
-	if (scrollToBottomRafId !== null) {
-		window.cancelAnimationFrame(scrollToBottomRafId)
-		scrollToBottomRafId = null
-	}
-}
-
 const scrollToBottom = async () => {
 	if (scrollToBottomPending) return
 	scrollToBottomPending = true
 	await nextTick()
+	scrollToBottomPending = false
+	messageListRef.value?.scrollToBottom?.()
+}
 
-	scrollToBottomRafId = window.requestAnimationFrame(() => {
-		scrollToBottomRafId = null
-		scrollToBottomPending = false
-		messageListRef.value?.scrollToBottom?.()
-	})
+const scrollToBottomIfNearBottom = () => {
+	if (messageListRef.value?.isNearBottom?.() === false) return
+	scrollToBottom()
 }
 
 /**
@@ -296,10 +289,6 @@ onMounted(async () => {
 	}
 })
 
-onBeforeUnmount(() => {
-	clearPendingScrollToBottom()
-})
-
 /**
  * 创建默认的聊天目标配置
  * @param type - 聊天目标类型,默认为 'agent'
@@ -1120,12 +1109,16 @@ const handleSend = async (content?: string) => {
 
 	const userMsg = createUserMessageWithAttachments(content)
 	messages.value.push(userMsg)
+	await scrollToBottom()
+
 
 	const aiMsg = createAiMessage()
 	messages.value.push(aiMsg)
 	const aiMessageKey = aiMsg.key as string
 	const streamToken = `${aiMessageKey}-${Date.now()}`
 	activeStreamToken.value = streamToken
+	await scrollToBottom()
+
 
 	const getAiMessage = () => messages.value.find((item) => item.key === aiMessageKey)
 	const isActiveStream = () => activeStreamToken.value === streamToken
@@ -1149,7 +1142,7 @@ const handleSend = async (content?: string) => {
 			const msg = getAiMessage()
 			if (!msg) return
 			applyStructuredEventToMessage(msg, event)
-			scrollToBottom()
+			scrollToBottomIfNearBottom()
 		},
 		// onComplete
 		() => {

+ 48 - 2
apps/web/src/views/knowledge/components/KnowledgeBaseEditModal.vue

@@ -127,6 +127,30 @@
 						</div>
 					</el-tab-pane>
 
+					<el-tab-pane label="向量存储" name="vectorStore">
+						<div class="tab-intro">从全局向量存储配置中选择, 可为空, 默认/为空:系统默认。</div>
+						<div class="collapse-body">
+							<el-form-item label="向量存储">
+								<el-select
+									v-model="form.vector_store_id"
+									placeholder="系统默认"
+									clearable
+									:disabled="!!editingId"
+								>
+									<el-option
+										v-for="item in vectorStoreOptions"
+										:key="item.value"
+										:label="item.label"
+										:value="item.value"
+									/>
+								</el-select>
+								<div class="field-tip">
+									创建后不可更改。如需迁移,请创建一个绑定到目标存储的新 KB 并重新索引。
+								</div>
+							</el-form-item>
+						</div>
+					</el-tab-pane>
+
 					<el-tab-pane v-if="isDocumentType" label="解析引擎" name="parser">
 						<div class="tab-intro">为不同文件类型选择文档解析引擎</div>
 						<div class="collapse-body">
@@ -460,7 +484,7 @@
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
 import { ElMessage } from 'element-plus'
-import { aiModel, knowledge, storageProvider } from '@repo/api-service'
+import { aiModel, knowledge, storageProvider, vector } from '@repo/api-service'
 import type { KnowledgeBaseForm, KnowledgeModelOption, ParserEngineRule } from '../types'
 
 const emit = defineEmits<{ (e: 'refresh'): void }>()
@@ -504,6 +528,7 @@ const languageOptions = [
 	{ label: '德语', value: 'de' }
 ]
 const storageProviderOptions = ref<{ label: string; value: string }[]>([])
+const vectorStoreOptions = ref<{ label: string; value: string }[]>([])
 const knowledgeTypeOptions = [
 	{ label: '文档', value: 'document' },
 	{ label: '问答', value: 'faq' }
@@ -558,6 +583,7 @@ const createDefaultForm = (): KnowledgeBaseForm => ({
 	token_limit: 0,
 	languages: [],
 	storage_provider: 'local',
+	vector_store_id: '',
 	wiki_extraction_granularity: 'standard',
 	wiki_max_pages_per_ingest: 0,
 	wiki_synthesis_model_id: ''
@@ -627,6 +653,22 @@ async function fetchStorageProviders() {
 	}
 }
 
+async function fetchVectorStores() {
+	try {
+		const res = await vector.postPageList({ keyword: '', pageIndex: 1, pageSize: 200 })
+		if (res?.isSuccess && res.result) {
+			const data = (res.result as any)?.model
+			const items = Array.isArray(data) ? data : data?.list || data?.items || data?.data || []
+			vectorStoreOptions.value = items.map((item: any) => ({
+				label: item.name || item.display_name || item.id,
+				value: item.id
+			}))
+		}
+	} catch {
+		// silent
+	}
+}
+
 function applyModelDefaults() {
 	if (!form.embedding_model_id && embeddingModels.value.length) {
 		form.embedding_model_id = embeddingModels.value[0]!.id
@@ -720,6 +762,7 @@ function handleKnowledgeBaseTypeChange(type: KnowledgeBaseForm['type']) {
 	form.token_limit = 0
 	form.languages = []
 	form.storage_provider = 'local'
+	form.vector_store_id = ''
 	form.wiki_extraction_granularity = 'standard'
 	form.wiki_max_pages_per_ingest = 0
 	applyModelDefaults()
@@ -793,6 +836,7 @@ async function openEditDrawer(id: string) {
 		languages: chunkingConfig.languages?.length ? [...chunkingConfig.languages] : [],
 		storage_provider:
 			detail.storage_provider_config?.provider || detail.storage_config?.provider || 'local',
+		vector_store_id: detail.vector_store_id || '',
 		wiki_extraction_granularity: detail.wiki_config?.extraction_granularity || 'standard',
 		wiki_max_pages_per_ingest: detail.wiki_config?.max_pages_per_ingest ?? 0,
 		wiki_synthesis_model_id: detail.wiki_config?.synthesis_model_id || ''
@@ -858,7 +902,8 @@ function buildCommonPayload() {
 		question_generation_config: {
 			enabled: questionGenerationEnabled,
 			question_count: form.question_count
-		}
+		},
+		vector_store_id: form.vector_store_id || undefined
 	}
 }
 
@@ -928,6 +973,7 @@ defineExpose({
 onMounted(async () => {
 	await fetchModels()
 	await fetchStorageProviders()
+	await fetchVectorStores()
 	resetForm()
 })
 </script>

+ 2 - 0
apps/web/src/views/knowledge/types.ts

@@ -45,6 +45,7 @@ export interface KnowledgeBaseConfig {
 	summary_model_id?: string
 	type: KnowledgeBaseType
 	updateTime?: string
+	vector_store_id?: string
 	vlm_config?: { enabled: boolean; model_id: string }
 	wiki_config?: {
 		extraction_granularity: string
@@ -86,6 +87,7 @@ export interface KnowledgeBaseForm {
 	token_limit: number
 	languages: string[]
 	storage_provider: string
+	vector_store_id: string
 	wiki_extraction_granularity: string
 	wiki_max_pages_per_ingest: number
 	wiki_synthesis_model_id: string

+ 2 - 1
apps/web/src/views/vector/VectorEditDrawer.vue

@@ -8,7 +8,7 @@
 	>
 		<el-form ref="formRef" :model="form" :rules="formRules" label-position="top">
 			<el-form-item :label="t('pages.vectorStore.name')" prop="name">
-				<el-input v-model="form.name" :placeholder="t('pages.vectorStore.namePlaceholder')" />
+				<el-input v-model="form.name" :placeholder="t('pages.vectorStore.namePlaceholder')" autocomplete="off" />
 			</el-form-item>
 
 			<el-form-item :label="t('pages.vectorStore.engineType')" prop="engine_type">
@@ -47,6 +47,7 @@
 							:placeholder="field.default || field.description || field.name"
 							:type="field.sensitive ? 'password' : 'text'"
 							:show-password="field.sensitive"
+							:autocomplete="field.sensitive ? 'new-password' : 'off'"
 							:disabled="isEdit && field.immutable"
 						/>
 					</el-form-item>