Prechádzať zdrojové kódy

feat: 完善样本中心知识库导入功能 - 实现三步创建流程(连接/选择/创建本地知识库) - 新增推送到样本中心功能(文档段落批量入库) - 新增SyncDocuments后端接口 - 支持任务进度轮询展示 - 去除前端TODO占位代码

mengboxin137-blip 5 dní pred
rodič
commit
b63109721c

+ 1 - 0
apps/knowledge/urls.py

@@ -11,6 +11,7 @@ urlpatterns = [
     path('workspace/sample-center/knowledge-bases/detail', SampleCenterView.GetKnowledgeBase.as_view()),
     path('workspace/sample-center/knowledge-bases/batch-import', SampleCenterView.BatchImport.as_view()),
     path('workspace/sample-center/batch-import/task', SampleCenterView.GetImportTask.as_view()),
+    path('workspace/<str:workspace_id>/knowledge/<str:knowledge_id>/sync-to-sample-center', SampleCenterView.SyncDocuments.as_view()),
     path('workspace/knowledge/document/template/export', views.Template.as_view()),
     path('workspace/knowledge/document/table_template/export', views.TableTemplate.as_view()),
     path('workspace/store/knowledge_template', views.KnowledgeView.StoreKnowledge.as_view()),

+ 103 - 1
apps/knowledge/views/sample_center.py

@@ -62,7 +62,7 @@ class SampleCenterView(APIView):
             return result.success(data)
 
     class BatchImport(APIView):
-        """提交批量入库任务"""
+        """提交批量入库任务(直接转发到样本中心)"""
         authentication_classes = [TokenAuth]
 
         def post(self, request: Request):
@@ -113,3 +113,105 @@ class SampleCenterView(APIView):
             )
             data = client.get_import_task(task_id)
             return result.success(data)
+
+    class SyncDocuments(APIView):
+        """将本地知识库文档段落推送到样本中心"""
+        authentication_classes = [TokenAuth]
+
+        def post(self, request: Request, workspace_id: str, knowledge_id: str):
+            from knowledge.models import Knowledge, Document, Paragraph, DocumentTag, Tag
+
+            document_ids = request.data.get('document_ids', [])
+            if not document_ids:
+                raise AppApiException(400, _('document_ids is required'))
+
+            # 获取知识库信息
+            try:
+                knowledge = Knowledge.objects.get(id=knowledge_id, workspace_id=workspace_id)
+            except Knowledge.DoesNotExist:
+                raise AppApiException(404, _('Knowledge base not found'))
+
+            # 获取样本中心配置
+            sample_center_config = knowledge.meta.get('sample_center', {})
+            base_url = sample_center_config.get('base_url', '')
+            app_id = sample_center_config.get('app_id', '')
+            app_secret = sample_center_config.get('app_secret', '')
+            kb_id = sample_center_config.get('kb_id', '')
+
+            if not all([base_url, app_id, app_secret, kb_id]):
+                raise AppApiException(400, _('Knowledge base is not linked to a sample center'))
+
+            # 查询文档
+            documents = Document.objects.filter(
+                id__in=document_ids,
+                knowledge_id=knowledge_id
+            )
+            if not documents.exists():
+                raise AppApiException(400, _('No valid documents found'))
+
+            # 查询段落
+            paragraphs = Paragraph.objects.filter(
+                document_id__in=document_ids,
+                is_active=True
+            ).order_by('document_id', 'position')
+
+            if not paragraphs.exists():
+                raise AppApiException(400, _('No paragraphs found in selected documents'))
+
+            # 查询文档标签
+            doc_tags = {}
+            tag_mappings = DocumentTag.objects.filter(
+                document_id__in=document_ids
+            ).select_related('tag')
+            for mapping in tag_mappings:
+                doc_id = str(mapping.document_id)
+                if doc_id not in doc_tags:
+                    doc_tags[doc_id] = []
+                doc_tags[doc_id].append(f"{mapping.tag.key}:{mapping.tag.value}")
+
+            # 构建文档名称映射
+            doc_name_map = {str(doc.id): doc.name for doc in documents}
+
+            # 将段落转换为样本中心的 parents 格式
+            # 每个段落作为一个 parent 条目
+            parents = []
+            for idx, paragraph in enumerate(paragraphs):
+                doc_id = str(paragraph.document_id)
+                parent_item = {
+                    'index': idx,
+                    'parent_id': kb_id,
+                    'hierarchy': doc_name_map.get(doc_id, ''),
+                    'text': paragraph.content,
+                    'metadata': {
+                        'source_knowledge_id': str(knowledge_id),
+                        'source_document_id': doc_id,
+                        'source_paragraph_id': str(paragraph.id),
+                        'title': paragraph.title or '',
+                    },
+                    'doc_id': doc_id,
+                    'tag_list': doc_tags.get(doc_id, []),
+                }
+                parents.append(parent_item)
+
+            # 生成任务号
+            task_no = f"IMP{uuid.uuid4().hex[:16].upper()}"
+
+            # 调用样本中心批量入库接口
+            client = get_sample_center_client(
+                base_url=base_url,
+                app_id=app_id,
+                app_secret=app_secret,
+            )
+            data = client.batch_import(
+                kb_id=kb_id,
+                task_no=task_no,
+                parents=parents,
+            )
+
+            return result.success({
+                'task_id': data.get('task_id', ''),
+                'task_no': task_no,
+                'status': data.get('status', 'pending'),
+                'total_paragraphs': len(parents),
+                'total_documents': documents.count(),
+            })

+ 20 - 0
ui/src/api/knowledge/sample-center.ts

@@ -107,3 +107,23 @@ export function getImportTaskStatus(
 ): Promise<Result<ImportTask>> {
   return get('workspace/sample-center/batch-import/task', params, loading)
 }
+
+/**
+ * 将本地知识库文档段落推送到样本中心
+ */
+export function syncDocumentsToSampleCenter(
+  workspace_id: string,
+  knowledge_id: string,
+  data: {
+    document_ids: string[]
+  },
+  loading?: Ref<boolean>,
+): Promise<Result<{
+  task_id: string
+  task_no: string
+  status: string
+  total_paragraphs: number
+  total_documents: number
+}>> {
+  return post(`workspace/${workspace_id}/knowledge/${knowledge_id}/sync-to-sample-center`, data, undefined, loading)
+}

+ 285 - 0
ui/src/views/document/component/SyncToSampleCenterDialog.vue

@@ -0,0 +1,285 @@
+<template>
+  <el-dialog
+    title="推送到样本中心"
+    v-model="dialogVisible"
+    width="640"
+    append-to-body
+    :close-on-click-modal="false"
+  >
+    <!-- 任务进行中 -->
+    <div v-if="taskStatus" class="sync-task-status">
+      <div class="mb-16">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="目标知识库">{{ sampleCenterConfig?.kb_name }}</el-descriptions-item>
+          <el-descriptions-item label="任务状态">
+            <el-tag :type="taskStatusType" size="small">{{ taskStatusText }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="推送段落数">{{ taskTotalParagraphs }}</el-descriptions-item>
+          <el-descriptions-item label="推送文档数">{{ taskTotalDocuments }}</el-descriptions-item>
+        </el-descriptions>
+      </div>
+
+      <!-- 轮询到的进度 -->
+      <div v-if="taskProgress.total > 0" class="mb-12">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="总条数">{{ taskProgress.total }}</el-descriptions-item>
+          <el-descriptions-item label="已处理">{{ taskProgress.processed }}</el-descriptions-item>
+          <el-descriptions-item label="成功">
+            <span style="color: var(--el-color-success)">{{ taskProgress.succeeded }}</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="失败">
+            <span style="color: var(--el-color-danger)">{{ taskProgress.failed }}</span>
+          </el-descriptions-item>
+        </el-descriptions>
+      </div>
+
+      <el-progress
+        v-if="taskStatus === 'processing' || taskStatus === 'pending'"
+        :percentage="progressPercent"
+        :status="taskStatus === 'processing' ? undefined : 'warning'"
+      />
+      <div v-if="taskStatus === 'completed'" class="mt-12">
+        <el-alert title="推送完成" type="success" :closable="false" />
+      </div>
+      <div v-if="taskStatus === 'failed' && taskError" class="mt-12">
+        <el-alert :title="taskError" type="error" :closable="false" />
+      </div>
+      <div v-if="taskFailures.length > 0" class="mt-12">
+        <el-collapse>
+          <el-collapse-item title="失败明细">
+            <div v-for="(item, idx) in taskFailures" :key="idx" class="failure-item">
+              <span>索引 {{ item.index }}:</span>
+              <span style="color: var(--el-color-danger)">{{ item.error }}</span>
+            </div>
+          </el-collapse-item>
+        </el-collapse>
+      </div>
+    </div>
+
+    <!-- 确认推送 -->
+    <div v-else>
+      <el-alert
+        title="将选中文档的段落数据推送到样本中心进行向量化入库"
+        type="info"
+        :closable="false"
+        class="mb-16"
+      />
+      <el-descriptions :column="1" border size="small">
+        <el-descriptions-item label="目标知识库">{{ sampleCenterConfig?.kb_name || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="选中文档数">{{ documents.length }}</el-descriptions-item>
+        <el-descriptions-item label="API 地址">{{ sampleCenterConfig?.base_url || '-' }}</el-descriptions-item>
+      </el-descriptions>
+      <div v-if="!sampleCenterConfig?.kb_id" class="mt-12">
+        <el-alert title="当前知识库未关联样本中心,无法推送" type="warning" :closable="false" />
+      </div>
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">
+          {{ taskStatus ? '关闭' : '取消' }}
+        </el-button>
+        <el-button
+          v-if="!taskStatus"
+          type="primary"
+          @click="submitSync"
+          :loading="submitting"
+          :disabled="!sampleCenterConfig?.kb_id"
+        >
+          确认推送
+        </el-button>
+        <el-button
+          v-if="taskStatus === 'processing' || taskStatus === 'pending'"
+          @click="pollTaskStatus"
+          :loading="polling"
+        >
+          刷新状态
+        </el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onBeforeUnmount } from 'vue'
+import { ElMessage } from 'element-plus'
+import { syncDocumentsToSampleCenter, getImportTaskStatus } from '@/api/knowledge/sample-center'
+import useStore from '@/stores'
+
+const { user } = useStore()
+
+const dialogVisible = ref(false)
+const submitting = ref(false)
+const polling = ref(false)
+const documents = ref<any[]>([])
+const knowledgeId = ref('')
+const sampleCenterConfig = ref<any>(null)
+
+// 任务状态
+const taskStatus = ref<string>('')
+const taskId = ref('')
+const taskError = ref('')
+const taskTotalParagraphs = ref(0)
+const taskTotalDocuments = ref(0)
+const taskProgress = ref({ total: 0, processed: 0, succeeded: 0, failed: 0 })
+const taskFailures = ref<any[]>([])
+
+let pollTimer: any = null
+
+const taskStatusType = computed(() => {
+  switch (taskStatus.value) {
+    case 'completed': return 'success'
+    case 'failed': return 'danger'
+    case 'processing': return 'warning'
+    default: return 'info'
+  }
+})
+
+const taskStatusText = computed(() => {
+  switch (taskStatus.value) {
+    case 'pending': return '等待处理'
+    case 'processing': return '处理中'
+    case 'completed': return '已完成'
+    case 'failed': return '失败'
+    default: return taskStatus.value
+  }
+})
+
+const progressPercent = computed(() => {
+  if (taskProgress.value.total === 0) return 0
+  return Math.round((taskProgress.value.processed / taskProgress.value.total) * 100)
+})
+
+const open = (knowledgeDetail: any, selectedDocs: any[]) => {
+  documents.value = selectedDocs
+  knowledgeId.value = knowledgeDetail?.id || ''
+  sampleCenterConfig.value = knowledgeDetail?.meta?.sample_center || null
+  taskStatus.value = ''
+  taskId.value = ''
+  taskError.value = ''
+  taskTotalParagraphs.value = 0
+  taskTotalDocuments.value = 0
+  taskProgress.value = { total: 0, processed: 0, succeeded: 0, failed: 0 }
+  taskFailures.value = []
+  dialogVisible.value = true
+}
+
+const submitSync = async () => {
+  if (!sampleCenterConfig.value?.kb_id) {
+    ElMessage.error('未找到样本中心配置信息')
+    return
+  }
+  if (documents.value.length === 0) {
+    ElMessage.warning('请选择要推送的文档')
+    return
+  }
+
+  submitting.value = true
+  try {
+    const documentIds = documents.value.map((doc) => doc.id)
+    const workspaceId = user.getWorkspaceId()
+
+    const res = await syncDocumentsToSampleCenter(
+      workspaceId,
+      knowledgeId.value,
+      { document_ids: documentIds },
+      submitting,
+    )
+
+    taskId.value = res.data?.task_id || ''
+    taskStatus.value = res.data?.status || 'pending'
+    taskTotalParagraphs.value = res.data?.total_paragraphs || 0
+    taskTotalDocuments.value = res.data?.total_documents || 0
+
+    ElMessage.success('推送任务已提交')
+
+    // 开始轮询
+    startPolling()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '推送失败')
+  } finally {
+    submitting.value = false
+  }
+}
+
+const startPolling = () => {
+  stopPolling()
+  let delay = 2000
+  const maxDelay = 30000
+
+  const poll = () => {
+    pollTimer = setTimeout(async () => {
+      await pollTaskStatus()
+      if (taskStatus.value === 'pending' || taskStatus.value === 'processing') {
+        delay = Math.min(delay * 1.5, maxDelay)
+        poll()
+      }
+    }, delay)
+  }
+  poll()
+}
+
+const stopPolling = () => {
+  if (pollTimer) {
+    clearTimeout(pollTimer)
+    pollTimer = null
+  }
+}
+
+const pollTaskStatus = async () => {
+  if (!taskId.value || !sampleCenterConfig.value) return
+
+  polling.value = true
+  try {
+    const res = await getImportTaskStatus({
+      task_id: taskId.value,
+      base_url: sampleCenterConfig.value.base_url,
+      app_id: sampleCenterConfig.value.app_id,
+      app_secret: sampleCenterConfig.value.app_secret,
+    })
+
+    const data = res.data
+    taskStatus.value = data?.status || taskStatus.value
+    if (data?.progress) {
+      taskProgress.value = data.progress
+    }
+    if (data?.error) {
+      taskError.value = data.error
+    }
+    if (data?.failures) {
+      taskFailures.value = data.failures
+    }
+
+    // 终态停止轮询
+    if (taskStatus.value === 'completed' || taskStatus.value === 'failed') {
+      stopPolling()
+    }
+  } catch (e: any) {
+    // 轮询失败不弹错误,静默处理
+    console.error('查询任务状态失败:', e)
+  } finally {
+    polling.value = false
+  }
+}
+
+const handleClose = () => {
+  stopPolling()
+  dialogVisible.value = false
+}
+
+onBeforeUnmount(() => {
+  stopPolling()
+})
+
+defineExpose({ open })
+</script>
+
+<style scoped>
+.sync-task-status {
+  min-height: 100px;
+}
+.failure-item {
+  padding: 4px 0;
+  font-size: 13px;
+}
+</style>

+ 26 - 0
ui/src/views/document/index.vue

@@ -44,6 +44,23 @@
                   @click="toImportWorkflow"
                   >{{ $t('views.document.importDocument') }}
                 </el-button>
+                <el-button
+                  v-if="knowledgeDetail?.type === 5 && permissionPrecise.doc_create(id)"
+                  type="primary"
+                  @click="
+                    router.push({
+                      path: `/knowledge/document/upload/${folderId}/${type}`,
+                      query: { id: id },
+                    })
+                  "
+                  >{{ $t('views.document.uploadDocument') }}
+                </el-button>
+                <el-button
+                  v-if="knowledgeDetail?.type === 5"
+                  @click="openSyncToSampleCenter"
+                  :disabled="multipleSelection.length === 0"
+                  >推送到样本中心
+                </el-button>
                 <el-button
                   @click="batchRefresh"
                   :disabled="multipleSelection.length === 0"
@@ -810,6 +827,8 @@
     <AddTagDialog ref="addTagDialogRef" @addTags="addTags" :apiType="apiType" />
     <!-- 执行详情 -->
     <ExecutionRecord ref="ListActionRef"></ExecutionRecord>
+    <!-- 推送到样本中心 -->
+    <SyncToSampleCenterDialog ref="syncToSampleCenterDialogRef" />
   </div>
 </template>
 <script setup lang="ts">
@@ -834,6 +853,7 @@ import TagDrawer from './tag/TagDrawer.vue'
 import TagSettingDrawer from './tag/TagSettingDrawer.vue'
 import AddTagDialog from '@/views/document/tag/MulAddTagDialog.vue'
 import ExecutionRecord from '@/views/knowledge-workflow/component/execution-record/ExecutionRecordDrawer.vue'
+import SyncToSampleCenterDialog from '@/views/document/component/SyncToSampleCenterDialog.vue'
 
 const route = useRoute()
 const router = useRouter()
@@ -1264,6 +1284,12 @@ function deleteMulDocument() {
     .catch(() => {})
 }
 
+// 推送到样本中心
+const syncToSampleCenterDialogRef = ref()
+function openSyncToSampleCenter() {
+  syncToSampleCenterDialogRef.value?.open(knowledgeDetail.value, multipleSelection.value)
+}
+
 function batchRefresh() {
   const arr: string[] = multipleSelection.value.map((v) => v.id)
   const embeddingBatchDocument = (stateList: Array<string>) => {

+ 2 - 8
ui/src/views/knowledge/component/KnowledgeListContainer.vue

@@ -443,7 +443,7 @@
   />
   <TemplateStoreDialog ref="templateStoreDialogRef" :api-type="apiType" @refresh="getList" />
   <ResourceMappingDrawer ref="resourceMappingDrawerRef"></ResourceMappingDrawer>
-  <CreateSampleCenterKnowledgeDialog ref="sampleCenterDialogRef" @select="handleSampleCenterSelect" />
+  <CreateSampleCenterKnowledgeDialog ref="sampleCenterDialogRef" @refresh="getList" />
 </template>
 
 <script lang="ts" setup>
@@ -670,13 +670,7 @@ function openCreateDialog(data: any) {
 const sampleCenterDialogRef = ref<InstanceType<typeof CreateSampleCenterKnowledgeDialog>>()
 
 function openSampleCenterDialog() {
-  sampleCenterDialogRef.value?.open()
-}
-
-function handleSampleCenterSelect(data: any) {
-  // TODO: 实现从样本中心创建知识库的逻辑
-  console.log('Selected sample center KB:', data)
-  MsgSuccess('样本中心知识库选择成功,待实现入库逻辑')
+  sampleCenterDialogRef.value?.open(folder.currentFolder)
 }
 
 function reEmbeddingKnowledge(row: any) {

+ 167 - 32
ui/src/views/knowledge/create-component/CreateSampleCenterKnowledgeDialog.vue

@@ -7,24 +7,26 @@
     :close-on-click-modal="false"
     :close-on-press-escape="false"
   >
-    <!-- 连接配置 -->
-    <el-form v-if="!connected" :model="configForm" label-width="100px" label-position="left" class="sample-center-form">
-      <el-form-item label="API 地址" required>
-        <el-input v-model="configForm.base_url" placeholder="https://sample-center.example.com" />
-      </el-form-item>
-      <el-form-item label="App ID" required>
-        <el-input v-model="configForm.app_id" placeholder="应用标识" />
-      </el-form-item>
-      <el-form-item label="App Secret" required>
-        <el-input v-model="configForm.app_secret" type="password" placeholder="应用密钥" show-password />
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" @click="connect" :loading="connecting">连接</el-button>
-      </el-form-item>
-    </el-form>
-
-    <!-- 知识库列表 -->
-    <div v-else>
+    <!-- 步骤一:连接配置 -->
+    <div v-if="step === 1">
+      <el-form :model="configForm" label-width="100px" label-position="left" class="sample-center-form">
+        <el-form-item label="API 地址" required>
+          <el-input v-model="configForm.base_url" placeholder="https://sample-center.example.com" />
+        </el-form-item>
+        <el-form-item label="App ID" required>
+          <el-input v-model="configForm.app_id" placeholder="应用标识" />
+        </el-form-item>
+        <el-form-item label="App Secret" required>
+          <el-input v-model="configForm.app_secret" type="password" placeholder="应用密钥" show-password />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="connect" :loading="connecting">连接</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 步骤二:选择知识库 -->
+    <div v-else-if="step === 2">
       <el-table :data="kbList" v-loading="loadingKB" style="width: 100%">
         <el-table-column prop="name" label="知识库名称" min-width="200" />
         <el-table-column prop="document_count" label="文档数量" width="100" />
@@ -43,29 +45,83 @@
       </el-table>
     </div>
 
+    <!-- 步骤三:填写本地知识库信息 -->
+    <div v-else-if="step === 3">
+      <el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top">
+        <el-form-item label="关联的样本中心知识库">
+          <el-input :model-value="selectedKB?.name" disabled />
+        </el-form-item>
+        <el-form-item label="知识库名称" prop="name">
+          <el-input
+            v-model="createForm.name"
+            placeholder="请输入知识库名称"
+            maxlength="64"
+            show-word-limit
+            @blur="createForm.name = createForm.name.trim()"
+          />
+        </el-form-item>
+        <el-form-item label="知识库描述" prop="desc">
+          <el-input
+            v-model="createForm.desc"
+            type="textarea"
+            placeholder="请输入知识库描述"
+            maxlength="256"
+            show-word-limit
+            :autosize="{ minRows: 3 }"
+            @blur="createForm.desc = createForm.desc.trim()"
+          />
+        </el-form-item>
+        <el-form-item label="向量模型" prop="embedding_model_id">
+          <ModelSelect
+            v-model="createForm.embedding_model_id"
+            placeholder="请选择向量模型"
+            :options="modelOptions"
+            @submit-model="loadEmbeddingModels"
+            :model-type="'EMBEDDING'"
+            showFooter
+          ></ModelSelect>
+        </el-form-item>
+      </el-form>
+    </div>
+
     <template #footer>
       <span class="dialog-footer">
-        <el-button @click="dialogVisible = false" :loading="loading">取消</el-button>
-        <el-button v-if="connected" @click="connected = false">返回</el-button>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button v-if="step > 1" @click="prevStep">上一步</el-button>
+        <el-button v-if="step === 3" type="primary" @click="submitCreate" :loading="submitting">
+          创建
+        </el-button>
       </span>
     </template>
   </el-dialog>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, reactive } from 'vue'
+import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { groupBy } from 'lodash'
 import { getSampleCenterKBList } from '@/api/knowledge/sample-center'
 import type { SampleCenterKB } from '@/api/knowledge/sample-center'
+import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
+import ModelSelect from '@/components/model-select/index.vue'
+import { MsgSuccess } from '@/utils/message'
+import useStore from '@/stores'
 
-const emit = defineEmits(['select'])
+const emit = defineEmits(['refresh'])
+const router = useRouter()
+const { user } = useStore()
 
 const dialogVisible = ref(false)
-const connected = ref(false)
+const step = ref(1)
 const connecting = ref(false)
-const loading = ref(false)
 const loadingKB = ref(false)
+const submitting = ref(false)
 const kbList = ref<SampleCenterKB[]>([])
+const selectedKB = ref<SampleCenterKB | null>(null)
+const modelOptions = ref<any[]>([])
+const currentFolder = ref<any>(null)
 
 const configForm = ref({
   base_url: '',
@@ -73,10 +129,25 @@ const configForm = ref({
   app_secret: '',
 })
 
-const open = () => {
+const createFormRef = ref<FormInstance>()
+const createForm = ref({
+  name: '',
+  desc: '',
+  embedding_model_id: '',
+})
+
+const createRules = reactive<FormRules>({
+  name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
+  embedding_model_id: [{ required: true, message: '请选择向量模型', trigger: 'change' }],
+})
+
+const open = (folder?: any) => {
+  currentFolder.value = folder
   dialogVisible.value = true
-  connected.value = false
+  step.value = 1
   kbList.value = []
+  selectedKB.value = null
+  createForm.value = { name: '', desc: '', embedding_model_id: '' }
 }
 
 const connect = async () => {
@@ -92,7 +163,7 @@ const connect = async () => {
       page_size: 100,
     })
     kbList.value = res.data?.items || []
-    connected.value = true
+    step.value = 2
   } catch (e: any) {
     ElMessage.error(e?.message || '连接失败,请检查配置')
   } finally {
@@ -101,11 +172,75 @@ const connect = async () => {
 }
 
 const selectKB = (row: SampleCenterKB) => {
-  emit('select', {
-    ...row,
-    config: configForm.value,
-  })
-  dialogVisible.value = false
+  selectedKB.value = row
+  // 用样本中心知识库名称作为默认名称
+  createForm.value.name = row.name
+  createForm.value.desc = `关联样本中心知识库:${row.name}`
+  step.value = 3
+  // 加载向量模型列表
+  loadEmbeddingModels()
+}
+
+const loadEmbeddingModels = () => {
+  loadSharedApi({ type: 'model', systemType: 'workspace' })
+    .getSelectModelList({ model_type: 'EMBEDDING' })
+    .then((res: any) => {
+      modelOptions.value = groupBy(res?.data, 'provider')
+    })
+}
+
+const prevStep = () => {
+  if (step.value === 3) {
+    step.value = 2
+  } else if (step.value === 2) {
+    step.value = 1
+  }
+}
+
+const submitCreate = async () => {
+  if (!createFormRef.value) return
+  const valid = await createFormRef.value.validate().catch(() => false)
+  if (!valid) return
+
+  submitting.value = true
+  try {
+    const knowledgeData = {
+      folder_id: currentFolder.value?.id || user.getWorkspaceId(),
+      name: createForm.value.name,
+      desc: createForm.value.desc,
+      embedding_model_id: createForm.value.embedding_model_id,
+      type: 5, // KnowledgeType.SAMPLE_CENTER
+      meta: {
+        sample_center: {
+          base_url: configForm.value.base_url,
+          app_id: configForm.value.app_id,
+          app_secret: configForm.value.app_secret,
+          kb_id: selectedKB.value?.id,
+          kb_name: selectedKB.value?.name,
+          parent_table: selectedKB.value?.parent_table,
+          child_table: selectedKB.value?.child_table,
+          metadata_schema: selectedKB.value?.metadata_schema,
+        },
+      },
+    }
+
+    const res = await loadSharedApi({ type: 'knowledge', systemType: 'workspace' })
+      .postKnowledge(knowledgeData, submitting)
+
+    await user.profile()
+    MsgSuccess('样本中心知识库创建成功')
+    dialogVisible.value = false
+    emit('refresh')
+
+    // 跳转到知识库文档页
+    router.push({
+      path: `/knowledge/${res.data.id}/${currentFolder.value?.id || 'shared'}/5/document`,
+    })
+  } catch (e: any) {
+    ElMessage.error(e?.message || '创建失败')
+  } finally {
+    submitting.value = false
+  }
 }
 
 defineExpose({ open })