Bladeren bron

feat: 新增向量模块

jiaxing.liao 1 week geleden
bovenliggende
commit
a1fc275353

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

@@ -53,6 +53,7 @@ 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']
@@ -62,6 +63,7 @@ declare module 'vue' {
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElSegmented: typeof import('element-plus/es')['ElSegmented']
     ElSelect: typeof import('element-plus/es')['ElSelect']
+    ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
     ElSlider: typeof import('element-plus/es')['ElSlider']
     ElSpace: typeof import('element-plus/es')['ElSpace']
     ElSplitter: typeof import('element-plus/es')['ElSplitter']
@@ -137,6 +139,7 @@ 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']
@@ -146,6 +149,7 @@ declare global {
   const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
   const ElSegmented: typeof import('element-plus/es')['ElSegmented']
   const ElSelect: typeof import('element-plus/es')['ElSelect']
+  const ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
   const ElSlider: typeof import('element-plus/es')['ElSlider']
   const ElSpace: typeof import('element-plus/es')['ElSpace']
   const ElSplitter: typeof import('element-plus/es')['ElSplitter']

+ 1 - 0
apps/web/src/assets/icons/vector.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/><circle cx="8" cy="16" r="1" fill="currentColor"/><circle cx="12" cy="18" r="1" fill="currentColor"/><circle cx="16" cy="16" r="1" fill="currentColor"/></svg>

+ 6 - 0
apps/web/src/config/menu.ts

@@ -79,6 +79,12 @@ export const getSidebarBottomMenu = (t: TranslateFn): SidebarBottomMenuItem[] =>
 		icon: 'storage',
 		label: t('sidebar.menu.storage')
 	},
+	{
+		type: 'route',
+		path: '/vector-store',
+		icon: 'vector',
+		label: t('sidebar.menu.vectorStore')
+	},
 	{
 		type: 'route',
 		path: '/mcp',

+ 46 - 0
apps/web/src/i18n/locales/en-us.ts

@@ -183,6 +183,7 @@ export default {
 			models: 'Model Management',
 			webSearch: 'Web Search',
 			storage: 'Storage Engine',
+			vectorStore: 'Vector Store',
 			mcp: 'MCP Services',
 			prompts: 'Prompt Templates',
 			skills: 'Skills',
@@ -1186,6 +1187,51 @@ export default {
 			deleteConfirm: 'Are you sure you want to delete this agent?',
 			deleteConfirmTitle: 'Confirm'
 		},
+		vectorStore: {
+			title: 'Vector Store',
+			description: 'Register and manage vector database instances for knowledge base search',
+			create: 'Create Vector Store',
+			edit: 'Edit Vector Store',
+			detail: 'Vector Store Details',
+			searchPlaceholder: 'Search name',
+			refresh: 'Refresh',
+			name: 'Name',
+			namePlaceholder: 'Enter vector store name',
+			engineType: 'Engine Type',
+			engineTypePlaceholder: 'Select engine type',
+			connectionAddr: 'Connection Address',
+			indexName: 'Index Name',
+			shardsReplicas: 'Shards/Replicas',
+			source: 'Source',
+			sourceSystem: 'System',
+			sourceUser: 'User',
+			creationTime: 'Created At',
+			updateTime: 'Updated At',
+			connectionConfig: 'Connection Config',
+			indexConfig: 'Index Config',
+			username: 'Username',
+			version: 'Version',
+			shards: 'Shards',
+			replicas: 'Replicas',
+			testConnection: 'Test Connection',
+			testSuccess: 'Connection successful',
+			testFailed: 'Connection failed',
+			deleteConfirm: 'Are you sure you want to delete this vector store?',
+			deleteConfirmTitle: 'Confirm',
+			deleteSuccess: 'Deleted successfully',
+			deleteFailed: 'Delete failed',
+			createSuccess: 'Created successfully',
+			createFailed: 'Create failed',
+			updateSuccess: 'Updated successfully',
+			updateFailed: 'Update failed',
+			loadTypesFailed: 'Failed to load engine types',
+			loadListFailed: 'Failed to load list',
+			getDetailFailed: 'Failed to get details',
+			selectEngineTypeFirst: 'Please select an engine type first',
+			testConfigSuccess: 'Connection test successful',
+			testConfigFailed: 'Connection test failed',
+			empty: 'No vector stores'
+		},
 		editor: {
 			workspace: 'Workspace',
 			tagPlaceholder: 'Press Enter to add tags',

+ 46 - 0
apps/web/src/i18n/locales/zh-cn.ts

@@ -183,6 +183,7 @@ export default {
 			models: '模型管理',
 			webSearch: '网络搜索',
 			storage: '存储引擎',
+			vectorStore: '向量存储',
 			mcp: 'MCP服务',
 			prompts: '提示词模板',
 			skills: 'Skills技能',
@@ -1092,6 +1093,51 @@ export default {
 			deleteConfirm: '确定删除该智能体吗?',
 			deleteConfirmTitle: '确认'
 		},
+		vectorStore: {
+			title: '向量存储',
+			description: '注册和管理用于知识库搜索的向量数据库实例',
+			create: '新建向量存储',
+			edit: '编辑向量存储',
+			detail: '向量存储详情',
+			searchPlaceholder: '搜索名称',
+			refresh: '刷新',
+			name: '名称',
+			namePlaceholder: '请输入向量存储名称',
+			engineType: '引擎类型',
+			engineTypePlaceholder: '请选择引擎类型',
+			connectionAddr: '连接地址',
+			indexName: '索引名称',
+			shardsReplicas: '分片/副本',
+			source: '来源',
+			sourceSystem: '系统',
+			sourceUser: '用户',
+			creationTime: '创建时间',
+			updateTime: '更新时间',
+			connectionConfig: '连接配置',
+			indexConfig: '索引配置',
+			username: '用户名',
+			version: '版本',
+			shards: '分片数',
+			replicas: '副本数',
+			testConnection: '测试连接',
+			testSuccess: '连接成功',
+			testFailed: '连接失败',
+			deleteConfirm: '确定删除该向量存储吗?',
+			deleteConfirmTitle: '确认',
+			deleteSuccess: '删除成功',
+			deleteFailed: '删除失败',
+			createSuccess: '创建成功',
+			createFailed: '创建失败',
+			updateSuccess: '更新成功',
+			updateFailed: '更新失败',
+			loadTypesFailed: '加载引擎类型失败',
+			loadListFailed: '加载列表失败',
+			getDetailFailed: '获取详情失败',
+			selectEngineTypeFirst: '请先选择引擎类型',
+			testConfigSuccess: '连接测试成功',
+			testConfigFailed: '连接测试失败',
+			empty: '暂无向量存储'
+		},
 		editor: {
 			status: {
 				published: '已发布',

+ 6 - 0
apps/web/src/router/index.ts

@@ -26,6 +26,7 @@ const PromptPage = () => import('@/views/prompt/index.vue')
 const WebSearchPage = () => import('@/views/web-search/index.vue')
 const McpPage = () => import('@/views/mcp/index.vue')
 const StoragePage = () => import('@/views/storage/index.vue')
+const VectorStorePage = () => import('@/views/vector/index.vue')
 const SkillsPage = () => import('@/views/skills/index.vue')
 const Workspace = () => import('@/views/workspace/index.vue')
 
@@ -125,6 +126,11 @@ const routes = [
 				name: 'StoragePage',
 				component: StoragePage
 			},
+			{
+				path: 'vector-store',
+				name: 'VectorStorePage',
+				component: VectorStorePage
+			},
 			{
 				path: 'web-search',
 				name: 'WebSearchPage',

+ 0 - 1
apps/web/src/views/knowledge/WikiManage.vue

@@ -72,7 +72,6 @@
 					<div v-if="selectedPage" class="wiki-detail__meta">
 						<span>{{ getPageTypeLabel(selectedPage.page_type) }}</span>
 						<span>v{{ selectedPage.version }}</span>
-						<span>{{ selectedPage.slug }}</span>
 					</div>
 				</div>
 

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

@@ -340,7 +340,7 @@
 								</el-form-item>
 							</div>
 
-							<el-collapse class="chunk-advanced-collapse mt-12px">
+							<el-collapse class="chunk-advanced-collapse mt-12px" expand-icon-position="left">
 								<el-collapse-item title="高级" name="advanced">
 									<div class="chunk-advanced-panel">
 										<el-form-item label="Token 上限">
@@ -495,7 +495,8 @@ const separatorOptions = [
 	{ label: '中文分号', value: ';', displayValue: ';' },
 	{ label: '空格', value: ' ', displayValue: ' ' }
 ]
-const DEFAULT_SEPARATORS = separatorOptions.map((item) => item.value)
+// 默认不选空格
+const DEFAULT_SEPARATORS = separatorOptions.map((item) => item.value).filter((item) => item !== ' ')
 const parserEngineOptions = ['builtin', 'markitdown', 'simple']
 const languageOptions = [
 	{ label: '中文', value: 'zh' },

+ 109 - 0
apps/web/src/views/vector/VectorDetailDrawer.vue

@@ -0,0 +1,109 @@
+<template>
+	<el-drawer
+		:model-value="visible"
+		:title="t('pages.vectorStore.detail')"
+		direction="rtl"
+		size="520px"
+		@update:model-value="$emit('update:visible', $event)"
+	>
+		<template v-if="detailData">
+			<el-descriptions :column="1" border>
+				<el-descriptions-item :label="t('pages.vectorStore.name')">{{
+					detailData.name
+				}}</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.engineType')">
+					<el-tag effect="plain" size="small">{{
+						formatEngineType(detailData.engine_type)
+					}}</el-tag>
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.connectionAddr')">
+					{{ detailData.connection_config?.addr || '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.username')">
+					{{ detailData.connection_config?.username || '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.version')">
+					{{ detailData.connection_config?.version || '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.indexName')">
+					{{ detailData.index_config?.index_name || '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.shards')">
+					{{ detailData.index_config?.number_of_shards ?? '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.replicas')">
+					{{ detailData.index_config?.number_of_replicas ?? '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.source')">
+					<el-tag
+						:type="detailData.source === 'system' ? 'warning' : 'info'"
+						effect="light"
+						size="small"
+					>
+						{{
+							detailData.source === 'system'
+								? t('pages.vectorStore.sourceSystem')
+								: t('pages.vectorStore.sourceUser')
+						}}
+					</el-tag>
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.creationTime')">
+					{{ detailData.creationTime || '-' }}
+				</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.vectorStore.updateTime')">
+					{{ detailData.updateTime || '-' }}
+				</el-descriptions-item>
+			</el-descriptions>
+		</template>
+		<el-skeleton v-else :rows="8" animated />
+	</el-drawer>
+</template>
+
+<script setup lang="ts">
+import { vector } from '@repo/api-service'
+import { ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useI18n } from '@/composables/useI18n'
+import type { EngineTypeConfig, VectorStoreItem } from './types'
+
+const props = defineProps<{
+	visible: boolean
+	targetId: string
+	engineTypes: EngineTypeConfig[]
+}>()
+
+defineEmits<{
+	'update:visible': [value: boolean]
+}>()
+
+const { t } = useI18n()
+const detailData = ref<VectorStoreItem | null>(null)
+
+function formatEngineType(type: string) {
+	const config = props.engineTypes.find((item) => item.type === type)
+	return config?.display_name || type.toUpperCase()
+}
+
+async function loadDetail(id: string) {
+	detailData.value = null
+	try {
+		const res = await vector.postInfo({ id })
+		if (res?.isSuccess && res.result) {
+			detailData.value = res.result as VectorStoreItem
+		}
+	} catch {
+		ElMessage.error(t('pages.vectorStore.getDetailFailed'))
+	}
+}
+
+watch(
+	() => props.visible,
+	(val) => {
+		if (val && props.targetId) {
+			loadDetail(props.targetId)
+		} else if (!val) {
+			detailData.value = null
+		}
+	}
+)
+</script>

+ 304 - 0
apps/web/src/views/vector/VectorEditDrawer.vue

@@ -0,0 +1,304 @@
+<template>
+	<el-drawer
+		:model-value="visible"
+		:title="isEdit ? t('pages.vectorStore.edit') : t('pages.vectorStore.create')"
+		direction="rtl"
+		size="640px"
+		@update:model-value="$emit('update:visible', $event)"
+	>
+		<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-form-item>
+
+			<el-form-item :label="t('pages.vectorStore.engineType')" prop="engine_type">
+				<el-select
+					v-model="form.engine_type"
+					:placeholder="t('pages.vectorStore.engineTypePlaceholder')"
+					class="w-full"
+					:disabled="isEdit"
+					@change="handleEngineTypeChange"
+				>
+					<el-option
+						v-for="item in engineTypes"
+						:key="item.type"
+						:label="item.display_name"
+						:value="item.type"
+					/>
+				</el-select>
+			</el-form-item>
+
+			<template v-if="currentTypeConfig">
+				<div class="section-title">{{ t('pages.vectorStore.connectionConfig') }}</div>
+				<div class="form-grid">
+					<el-form-item
+						v-for="field in currentTypeConfig.connection_fields"
+						:key="field.name"
+						:label="field.description || field.name"
+					>
+						<el-switch
+							v-if="field.type === 'boolean'"
+							v-model="form.connection_config[field.name]"
+							:disabled="isEdit && field.immutable"
+						/>
+						<el-input
+							v-else
+							v-model="form.connection_config[field.name]"
+							:placeholder="field.default || field.description || field.name"
+							:type="field.sensitive ? 'password' : 'text'"
+							:show-password="field.sensitive"
+							:disabled="isEdit && field.immutable"
+						/>
+					</el-form-item>
+				</div>
+
+				<div class="section-title">{{ t('pages.vectorStore.indexConfig') }}</div>
+				<div class="form-grid">
+					<el-form-item
+						v-for="field in currentTypeConfig.index_fields"
+						:key="field.name"
+						:label="field.description || field.name"
+					>
+						<el-switch
+							v-if="field.type === 'boolean'"
+							v-model="form.index_config[field.name]"
+							:disabled="isEdit && field.immutable"
+						/>
+						<el-input
+							v-else-if="field.type === 'number'"
+							v-model.number="form.index_config[field.name]"
+							:placeholder="String(field.default || '')"
+							:disabled="isEdit && field.immutable"
+						/>
+						<el-select
+							v-else-if="field.enum && field.enum.length"
+							v-model="form.index_config[field.name]"
+							:placeholder="field.description || field.name"
+							class="w-full"
+							:disabled="isEdit && field.immutable"
+						>
+							<el-option
+								v-for="opt in field.enum"
+								:key="opt"
+								:label="opt"
+								:value="opt"
+							/>
+						</el-select>
+						<el-input
+							v-else
+							v-model="form.index_config[field.name]"
+							:placeholder="field.default || field.description || field.name"
+							:disabled="isEdit && field.immutable"
+						/>
+					</el-form-item>
+				</div>
+			</template>
+		</el-form>
+
+		<template #footer>
+			<div class="drawer-footer">
+				<el-button @click="$emit('update:visible', false)">{{ t('common.cancel') }}</el-button>
+				<el-button type="primary" plain :loading="testingConnection" @click="handleTestConnection">
+					{{ t('pages.vectorStore.testConnection') }}
+				</el-button>
+				<el-button type="primary" :loading="saving" @click="handleSave">
+					{{ t('common.save') }}
+				</el-button>
+			</div>
+		</template>
+	</el-drawer>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, computed, watch } from 'vue'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+import { vector } from '@repo/api-service'
+import { useI18n } from '@/composables/useI18n'
+import type { EngineTypeConfig, VectorStoreItem } from './types'
+
+const props = defineProps<{
+	visible: boolean
+	isEdit: boolean
+	editId: string
+	engineTypes: EngineTypeConfig[]
+	initialData?: VectorStoreItem | null
+}>()
+
+const emit = defineEmits<{
+	'update:visible': [value: boolean]
+	saved: []
+}>()
+
+const { t } = useI18n()
+
+const formRef = ref<FormInstance>()
+const saving = ref(false)
+const testingConnection = ref(false)
+
+const form = reactive({
+	name: '',
+	engine_type: '',
+	connection_config: {} as Record<string, any>,
+	index_config: {} as Record<string, any>
+})
+
+const formRules: FormRules = {
+	name: [{ required: true, message: t('pages.vectorStore.namePlaceholder'), trigger: 'blur' }],
+	engine_type: [{ required: true, message: t('pages.vectorStore.engineTypePlaceholder'), trigger: 'change' }]
+}
+
+const currentTypeConfig = computed(() =>
+	props.engineTypes.find((item) => item.type === form.engine_type) || null
+)
+
+function initFormByType(typeConfig: EngineTypeConfig) {
+	const conn: Record<string, any> = {}
+	for (const field of typeConfig.connection_fields) {
+		conn[field.name] = field.default || ''
+	}
+	const idx: Record<string, any> = {}
+	for (const field of typeConfig.index_fields) {
+		if (field.type === 'number') {
+			idx[field.name] = field.default ? Number(field.default) : 0
+		} else {
+			idx[field.name] = field.default || ''
+		}
+	}
+	form.connection_config = conn
+	form.index_config = idx
+}
+
+function handleEngineTypeChange() {
+	const config = currentTypeConfig.value
+	if (config) {
+		initFormByType(config)
+	}
+}
+
+function resetForm() {
+	form.name = ''
+	form.engine_type = ''
+	form.connection_config = {}
+	form.index_config = {}
+	formRef.value?.resetFields()
+}
+
+function loadFromData(data: VectorStoreItem) {
+	form.name = data.name
+	form.engine_type = data.engine_type
+	form.connection_config = { ...data.connection_config }
+	form.index_config = { ...data.index_config }
+}
+
+watch(() => props.visible, (val) => {
+	if (val) {
+		if (props.isEdit && props.initialData) {
+			loadFromData(props.initialData)
+		} else {
+			resetForm()
+		}
+	}
+})
+
+async function handleSave() {
+	if (!formRef.value) return
+	await formRef.value.validate()
+
+	saving.value = true
+	try {
+		if (props.isEdit) {
+			const res = await vector.postUpdate({ id: props.editId, name: form.name })
+			if (res?.isSuccess) {
+				ElMessage.success(t('pages.vectorStore.updateSuccess'))
+				emit('update:visible', false)
+				emit('saved')
+			} else {
+				ElMessage.error(t('pages.vectorStore.updateFailed'))
+			}
+		} else {
+			const res = await vector.postCreate({
+				name: form.name,
+				engine_type: form.engine_type,
+				connection_config: {
+					addr: form.connection_config.addr || '',
+					username: form.connection_config.username || '',
+					password: form.connection_config.password || ''
+				},
+				index_config: {
+					index_name: form.index_config.index_name || '',
+					number_of_shards: Number(form.index_config.number_of_shards) || 1,
+					number_of_replicas: Number(form.index_config.number_of_replicas) || 0
+				}
+			})
+			if (res?.isSuccess) {
+				ElMessage.success(t('pages.vectorStore.createSuccess'))
+				emit('update:visible', false)
+				emit('saved')
+			} else {
+				ElMessage.error(t('pages.vectorStore.createFailed'))
+			}
+		}
+	} catch {
+		ElMessage.error(props.isEdit ? t('pages.vectorStore.updateFailed') : t('pages.vectorStore.createFailed'))
+	} finally {
+		saving.value = false
+	}
+}
+
+async function handleTestConnection() {
+	if (!form.engine_type) {
+		ElMessage.warning(t('pages.vectorStore.selectEngineTypeFirst'))
+		return
+	}
+	testingConnection.value = true
+	try {
+		const res = await vector.postConnectTestConfig({
+			engine_type: form.engine_type,
+			connection_config: {
+				addr: form.connection_config.addr || '',
+				username: form.connection_config.username || '',
+				password: form.connection_config.password || ''
+			}
+		})
+		if (res?.isSuccess) {
+			ElMessage.success(t('pages.vectorStore.testConfigSuccess'))
+		} else {
+			ElMessage.error(t('pages.vectorStore.testConfigFailed'))
+		}
+	} catch {
+		ElMessage.error(t('pages.vectorStore.testConfigFailed'))
+	} finally {
+		testingConnection.value = false
+	}
+}
+</script>
+
+<style scoped lang="less">
+.section-title {
+	font-size: 14px;
+	font-weight: 600;
+	color: var(--text-primary);
+	margin: 16px 0 12px;
+	padding-bottom: 8px;
+	border-bottom: 1px solid var(--border-light);
+}
+
+.form-grid {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 0 12px;
+}
+
+.drawer-footer {
+	display: flex;
+	justify-content: flex-end;
+	gap: 8px;
+	flex-wrap: wrap;
+}
+
+@media (max-width: 960px) {
+	.form-grid {
+		grid-template-columns: minmax(0, 1fr);
+	}
+}
+</style>

+ 473 - 0
apps/web/src/views/vector/index.vue

@@ -0,0 +1,473 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>{{ t('pages.vectorStore.title') }}</h1>
+				<p>{{ t('pages.vectorStore.description') }}</p>
+			</div>
+		</div>
+
+		<div class="vector-manager" v-loading="pageLoading">
+			<!-- 搜索和操作栏 -->
+			<div class="toolbar">
+				<el-form :model="searchForm" inline class="search-form">
+					<el-form-item>
+						<el-input
+							v-model="searchForm.keyword"
+							:placeholder="t('pages.vectorStore.searchPlaceholder')"
+							clearable
+							@clear="handleSearch"
+							@keyup.enter="handleSearch"
+						>
+							<template #prefix>
+								<el-icon><Search /></el-icon>
+							</template>
+						</el-input>
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="handleSearch">
+							<el-icon><Search /></el-icon>
+							{{ t('common.search') }}
+						</el-button>
+						<el-button @click="handleReset">
+							<el-icon><RefreshRight /></el-icon>
+							{{ t('common.reset') }}
+						</el-button>
+					</el-form-item>
+				</el-form>
+				<div class="toolbar-actions">
+					<el-button type="primary" @click="openCreate">
+						<el-icon><Plus /></el-icon>
+						{{ t('pages.vectorStore.create') }}
+					</el-button>
+				</div>
+			</div>
+
+			<!-- 卡片列表 -->
+			<div class="card-grid">
+				<div v-for="item in list" :key="item.id" class="card">
+					<div class="card-head">
+						<div class="card-head__top">
+							<div class="title-block">
+								<div class="title">{{ item.name }}</div>
+								<div class="subtitle">{{ formatEngineType(item.engine_type) }}</div>
+							</div>
+							<div class="actions">
+								<el-dropdown>
+									<span class="actions-trigger">
+										<el-icon><MoreFilled /></el-icon>
+									</span>
+									<template #dropdown>
+										<el-dropdown-menu>
+											<el-dropdown-item @click="handleTestConnection(item)">
+												{{ t('pages.vectorStore.testConnection') }}
+											</el-dropdown-item>
+											<el-dropdown-item @click="openEdit(item)">
+												{{ t('common.edit') }}
+											</el-dropdown-item>
+											<el-dropdown-item @click="openDetail(item)">
+												{{ t('common.details') }}
+											</el-dropdown-item>
+											<el-dropdown-item divided @click="handleDeleteConfirm(item)">
+												{{ t('common.delete') }}
+											</el-dropdown-item>
+										</el-dropdown-menu>
+									</template>
+								</el-dropdown>
+							</div>
+						</div>
+					</div>
+
+					<div class="card-info">
+						<div class="info-row">
+							<span class="info-label">{{ t('pages.vectorStore.connectionAddr') }}</span>
+							<span class="info-value">{{ item.connection_config?.addr || '-' }}</span>
+						</div>
+						<div class="info-row">
+							<span class="info-label">创建时间</span>
+							<span class="info-value">{{ item.creationTime || '-' }}</span>
+						</div>
+					</div>
+
+					<!-- <div class="tags">
+						<el-tag :type="item.source === 'system' ? 'warning' : 'info'" effect="light">
+							{{
+								item.source === 'system'
+									? t('pages.vectorStore.sourceSystem')
+									: t('pages.vectorStore.sourceUser')
+							}}
+						</el-tag>
+					</div> -->
+				</div>
+				<el-empty v-if="!list.length" class="empty" :description="t('pages.vectorStore.empty')" />
+			</div>
+
+			<!-- 分页 -->
+			<div class="pagination" v-if="pagination.totalCount">
+				<el-pagination
+					v-model:current-page="pagination.pageIndex"
+					v-model:page-size="pagination.pageSize"
+					background
+					layout="total, prev, pager, next"
+					:total="pagination.totalCount"
+					@current-change="loadList"
+				/>
+			</div>
+
+			<!-- 创建/编辑抽屉 -->
+			<VectorEditDrawer
+				v-model:visible="editDrawerVisible"
+				:is-edit="isEdit"
+				:edit-id="editId"
+				:engine-types="engineTypes"
+				:initial-data="editData"
+				@saved="loadList"
+			/>
+
+			<!-- 详情抽屉 -->
+			<VectorDetailDrawer
+				v-model:visible="detailDrawerVisible"
+				:target-id="detailTargetId"
+				:engine-types="engineTypes"
+			/>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { MoreFilled, Plus, Refresh, RefreshRight, Search } from '@element-plus/icons-vue'
+import { vector } from '@repo/api-service'
+import { useI18n } from '@/composables/useI18n'
+import VectorEditDrawer from './VectorEditDrawer.vue'
+import VectorDetailDrawer from './VectorDetailDrawer.vue'
+import type { EngineTypeConfig, VectorStoreItem } from './types'
+
+const { t } = useI18n()
+
+// --- 状态 ---
+const pageLoading = ref(false)
+const editDrawerVisible = ref(false)
+const detailDrawerVisible = ref(false)
+const isEdit = ref(false)
+const editId = ref('')
+const editData = ref<VectorStoreItem | null>(null)
+const detailTargetId = ref('')
+const list = ref<VectorStoreItem[]>([])
+const engineTypes = ref<EngineTypeConfig[]>([])
+
+const searchForm = reactive({ keyword: '' })
+const pagination = reactive({ pageIndex: 1, pageSize: 20, totalCount: 0 })
+
+// --- 方法 ---
+function formatEngineType(type: string) {
+	const config = engineTypes.value.find((item) => item.type === type)
+	return config?.display_name || type.toUpperCase()
+}
+
+async function loadTypes() {
+	try {
+		const res = await vector.postTypes({})
+		if (res?.isSuccess && res.result) {
+			engineTypes.value = res.result as EngineTypeConfig[]
+		}
+	} catch {
+		ElMessage.error(t('pages.vectorStore.loadTypesFailed'))
+	}
+}
+
+async function loadList() {
+	pageLoading.value = true
+	try {
+		const res = await vector.postPageList({
+			keyword: searchForm.keyword,
+			pageIndex: pagination.pageIndex,
+			pageSize: pagination.pageSize
+		})
+		if (res?.isSuccess && res.result) {
+			const data = res.result?.model as any
+			if (Array.isArray(data)) {
+				list.value = data
+				pagination.totalCount = data.length
+			} else {
+				list.value = data.list || data.items || data.data || []
+				pagination.totalCount = data.totalCount ?? data.total ?? list.value.length
+			}
+		}
+	} catch {
+		ElMessage.error(t('pages.vectorStore.loadListFailed'))
+	} finally {
+		pageLoading.value = false
+	}
+}
+
+function handleSearch() {
+	pagination.pageIndex = 1
+	loadList()
+}
+
+function handleReset() {
+	searchForm.keyword = ''
+	pagination.pageIndex = 1
+	loadList()
+}
+
+function openCreate() {
+	isEdit.value = false
+	editId.value = ''
+	editData.value = null
+	editDrawerVisible.value = true
+}
+
+function openEdit(row: VectorStoreItem) {
+	isEdit.value = true
+	editId.value = row.id
+	editData.value = row
+	editDrawerVisible.value = true
+}
+
+function openDetail(row: VectorStoreItem) {
+	detailTargetId.value = row.id
+	detailDrawerVisible.value = true
+}
+
+async function handleDeleteConfirm(row: VectorStoreItem) {
+	try {
+		await ElMessageBox.confirm(
+			t('pages.vectorStore.deleteConfirm'),
+			t('pages.vectorStore.deleteConfirmTitle'),
+			{ type: 'warning' }
+		)
+		await handleDelete(row)
+	} catch {
+		// cancelled
+	}
+}
+
+async function handleDelete(row: VectorStoreItem) {
+	try {
+		const res = await vector.postOpenApiDelete({ id: row.id })
+		if (res?.isSuccess) {
+			ElMessage.success(t('pages.vectorStore.deleteSuccess'))
+			await loadList()
+		} else {
+			ElMessage.error(t('pages.vectorStore.deleteFailed'))
+		}
+	} catch {
+		ElMessage.error(t('pages.vectorStore.deleteFailed'))
+	}
+}
+
+async function handleTestConnection(row: VectorStoreItem) {
+	try {
+		const res = await vector.postConnectTestId({ id: row.id })
+		if (res?.isSuccess) {
+			ElMessage.success(`${row.name} ${t('pages.vectorStore.testSuccess')}`)
+		} else {
+			ElMessage.error(`${row.name} ${t('pages.vectorStore.testFailed')}`)
+		}
+	} catch {
+		ElMessage.error(`${row.name} ${t('pages.vectorStore.testFailed')}`)
+	}
+}
+
+onMounted(async () => {
+	pageLoading.value = true
+	try {
+		await Promise.all([loadTypes(), loadList()])
+	} finally {
+		pageLoading.value = false
+	}
+})
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 24px;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 18px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+	}
+
+	p {
+		margin: 6px 0 0;
+		color: var(--text-tertiary);
+	}
+}
+
+.vector-manager {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+}
+
+.toolbar {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 16px;
+	flex-wrap: wrap;
+}
+
+.search-form {
+	:deep(.el-form-item) {
+		margin-bottom: 0;
+	}
+
+	:deep(.el-form-item + .el-form-item) {
+		margin-left: 0;
+	}
+}
+
+.toolbar-actions {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	flex-shrink: 0;
+
+	.el-button + .el-button {
+		margin-left: 0;
+	}
+}
+
+.card-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+	gap: 16px;
+	min-height: 200px;
+
+	.empty {
+		grid-column: 1 / -1;
+	}
+}
+
+.card {
+	overflow: hidden;
+	padding: 16px;
+	border: 1px solid var(--border-light);
+	border-radius: 20px;
+	background: var(--bg-base);
+	display: flex;
+	flex-direction: column;
+	gap: 14px;
+	box-shadow: var(--shadow-sm);
+	transition:
+		transform 0.22s ease,
+		box-shadow 0.22s ease,
+		border-color 0.22s ease;
+}
+
+.card:hover {
+	transform: translateY(-3px);
+	border-color: var(--border-base);
+	box-shadow: var(--shadow-md);
+}
+
+.card-head {
+	padding: 14px 16px;
+	border-radius: 18px;
+	background: var(--card-bg-unselected);
+}
+
+.card-head__top {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 12px;
+}
+
+.title-block {
+	min-width: 0;
+}
+
+.title {
+	font-size: 18px;
+	font-weight: 700;
+	line-height: 1.25;
+	color: var(--text-strong);
+	word-break: break-word;
+}
+
+.subtitle {
+	margin-top: 6px;
+	font-size: 13px;
+	color: var(--text-secondary);
+	line-height: 1.6;
+}
+
+.card-info {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+	padding: 0 4px;
+}
+
+.info-row {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	font-size: 13px;
+}
+
+.info-label {
+	color: var(--text-tertiary);
+	flex-shrink: 0;
+	min-width: 70px;
+}
+
+.info-value {
+	color: var(--text-primary);
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.tags {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+
+.actions {
+	display: flex;
+	justify-content: flex-end;
+}
+
+.actions-trigger {
+	width: 30px;
+	height: 30px;
+	border-radius: 999px;
+	display: grid;
+	place-items: center;
+	color: var(--text-secondary);
+	background: var(--bg-overlay);
+	transition:
+		background 0.2s ease,
+		color 0.2s ease;
+}
+
+.actions-trigger:hover {
+	background: var(--bg-container);
+	color: var(--text-strong);
+}
+
+.pagination {
+	display: flex;
+	justify-content: flex-end;
+	padding-top: 8px;
+}
+
+@media (max-width: 960px) {
+	.toolbar {
+		flex-direction: column;
+	}
+}
+</style>

+ 30 - 0
apps/web/src/views/vector/types.ts

@@ -0,0 +1,30 @@
+export interface VectorStoreItem {
+	id: string
+	name: string
+	engine_type: string
+	source: string
+	creationTime: string
+	updateTime: string
+	connection_config: { addr: string; version?: string; username?: string }
+	index_config: { index_name: string; number_of_shards: number; number_of_replicas: number }
+}
+
+export interface FieldConfig {
+	name: string
+	type: string
+	description: string
+	default: string
+	required: boolean
+	immutable: boolean
+	sensitive: boolean
+	max?: number
+	min?: number
+	enum?: string[]
+}
+
+export interface EngineTypeConfig {
+	type: string
+	display_name: string
+	connection_fields: FieldConfig[]
+	index_fields: FieldConfig[]
+}

+ 4 - 1
packages/api-service/index.ts

@@ -3,6 +3,7 @@ import modelApi from './servers/model/api'
 import knowledgeApi from './servers/knowledge/api'
 import aiChatApi from './servers/ai-chat/api'
 import resourceApi from './servers/resource/api'
+import vectorApi from './servers/vector/api'
 
 const agent = agentApi.agent
 const agentApplication = agentApi.agentApplication
@@ -15,6 +16,7 @@ const wiki = knowledgeApi.wiki
 const aiChat = aiChatApi.aiChat
 const resource = resourceApi.resource
 const agentLog = agentApi.agentLog
+const vector = vectorApi.vector
 
 export {
 	agent,
@@ -27,5 +29,6 @@ export {
 	wiki,
 	aiChat,
 	resource,
-	agentLog
+	agentLog,
+	vector
 }

+ 5 - 0
packages/api-service/openapi2ts.config.ts

@@ -25,5 +25,10 @@ export default [
 		schemaPath: path.resolve(__dirname, './schema/ai.openapi.json'),
 		serversPath: './servers/ai-chat',
 		requestLibPath: "import request from '@repo/api-client'"
+	},
+	{
+		schemaPath: path.resolve(__dirname, './schema/vector.openapi.json'),
+		serversPath: './servers/vector',
+		requestLibPath: "import request from '@repo/api-client'"
 	}
 ]

File diff suppressed because it is too large
+ 2540 - 0
packages/api-service/schema/vector.openapi.json


File diff suppressed because it is too large
+ 695 - 696
packages/api-service/servers/knowledge/api/knowledge.ts


+ 8 - 0
packages/api-service/servers/vector/api/index.ts

@@ -0,0 +1,8 @@
+// @ts-ignore
+/* eslint-disable */
+// API 更新时间:
+// API 唯一标识:
+import * as vector from './vector'
+export default {
+  vector
+}

+ 67 - 0
packages/api-service/servers/vector/api/typings.d.ts

@@ -0,0 +1,67 @@
+declare namespace API {
+  type AgentNode = {
+    appAgentId: string
+    creationTime: string
+    creatorUserId: string
+    data: NodeData
+    height?: number
+    id: string
+    isDeleted?: boolean
+    position: { x: number; y: number }
+    selected?: boolean
+    type: 'custom' | 'start' | 'end' | 'condition' | 'task' | 'http-request'
+    updateTime?: string
+    width?: number
+    zIndex?: number
+  }
+
+  type HttpHeader = {
+    name: string
+    value: string
+  }
+
+  type NodeData = {
+    outputs: {
+      name: string
+      describe: string
+      type: 'string' | 'number' | 'boolean' | 'object' | 'array'
+    }[]
+    output_can_alter?: boolean
+    variables?: string[]
+    method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options'
+    ssl_verify?: boolean
+    isInIteration?: boolean
+    default_value?: string[]
+    body?: RequestBody
+    params?: string[]
+    title: string
+    type: 'http-request' | 'condition' | 'task'
+    error_strategy?: 'none' | 'retry' | 'abort' | 'continue'
+    retry_config?: { max_retries: number; retry_enabled: boolean; retry_interval: number }
+    url: string
+    authorization?: {
+      type: 'none' | 'bearer' | 'basic' | 'api-key'
+      config: { api_key?: string; header?: string; type?: string }
+    }
+    timeout_config?: {
+      max_write_timeout: number
+      max_read_timeout: number
+      max_connect_timeout: number
+    }
+    heads?: HttpHeader[]
+    selected?: boolean
+    desc?: string
+    isInLoop?: boolean
+  }
+
+  type RequestBody = {
+    data: RequestDataItem[]
+    type: 'json' | 'form-data' | 'x-www-form-urlencoded' | 'raw' | 'binary'
+  }
+
+  type RequestDataItem = {
+    type: 'text' | 'file' | 'json'
+    value: string
+    key?: string
+  }
+}

+ 249 - 0
packages/api-service/servers/vector/api/vector.ts

@@ -0,0 +1,249 @@
+// @ts-ignore
+/* eslint-disable */
+import request from '@repo/api-client'
+
+/** 使用原始凭据测试连接 POST /api/ai/vector-store/connect_test_config */
+export async function postConnectTestConfig(
+  body: {
+    engine_type: string
+    connection_config: { addr: string; username: string; password: string }
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: { version: string }
+    isAuthorized: boolean
+  }>('/api/ai/vector-store/connect_test_config', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
+/** 测试已保存或环境变量存储的连接 POST /api/ai/vector-store/connect_test_id */
+export async function postConnectTestId(
+  body: {
+    id: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: { version: string }
+    isAuthorized: boolean
+  }>('/api/ai/vector-store/connect_test_id', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
+/** 创建向量存储 POST /api/ai/vector-store/create */
+export async function postCreate(
+  body: {
+    name: string
+    engine_type: string
+    connection_config: { addr: string; username: string; password: string }
+    index_config: { index_name: string; number_of_shards: number; number_of_replicas: number }
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      connection_config: { addr: string; version: string; username: string }
+      creationTime: string
+      creatorUserId: string
+      engine_type: string
+      id: string
+      index_config: { number_of_shards: number; index_name: string; number_of_replicas: number }
+      isDeleted: boolean
+      name: string
+      source: string
+      updateTime: string
+      userId: string
+    }
+    isAuthorized: boolean
+  }>('/api/ai/vector-store/create', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
+/** 删除向量存储 POST /api/ai/vector-store/delete */
+export async function postOpenApiDelete(
+  body: {
+    id: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/vector-store/delete',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}
+
+/** 获取详情 POST /api/ai/vector-store/info */
+export async function postInfo(
+  body: {
+    id: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      connection_config: { addr: string; version: string; username: string }
+      creationTime: string
+      creatorUserId: string
+      engine_type: string
+      id: string
+      index_config: { number_of_shards: number; index_name: string; number_of_replicas: number }
+      isDeleted: boolean
+      name: string
+      source: string
+      updateTime: string
+      userId: string
+    }
+    isAuthorized: boolean
+  }>('/api/ai/vector-store/info', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
+/** 获取分页列表 POST /api/ai/vector-store/pageList */
+export async function postPageList(
+  body: {
+    keyword: string
+    pageIndex: number
+    pageSize: number
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      connection_fields: {
+        default: string
+        description: string
+        immutable: boolean
+        name: string
+        required: boolean
+        sensitive: boolean
+        type: string
+      }[]
+      display_name: string
+      index_fields: {
+        default: string
+        description: string
+        immutable: boolean
+        name: string
+        required: boolean
+        sensitive: boolean
+        type: string
+        max: number
+        min: number
+        enum?: string[]
+      }[]
+      type: string
+    }[]
+    isAuthorized: boolean
+  }>('/api/ai/vector-store/pageList', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
+/** 获取支持的向量存储类型列表 POST /api/ai/vector-store/types */
+export async function postTypes(body: {}, options?: { [key: string]: any }) {
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: {
+      connection_fields: {
+        default: string
+        description: string
+        immutable: boolean
+        name: string
+        required: boolean
+        sensitive: boolean
+        type: string
+      }[]
+      display_name: string
+      index_fields: {
+        default: string
+        description: string
+        immutable: boolean
+        name: string
+        required: boolean
+        sensitive: boolean
+        type: string
+        max: number
+        min: number
+        enum?: string[]
+      }[]
+      type: string
+    }[]
+    isAuthorized: boolean
+  }>('/api/ai/vector-store/types', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
+}
+
+/** 更新向量存储  POST /api/ai/vector-store/update */
+export async function postUpdate(
+  body: {
+    id: string
+    name: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/vector-store/update',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}