From 759eafb490ae472a612f9b77d57a499c4b49ca55 Mon Sep 17 00:00:00 2001 From: MerCry Date: Tue, 10 Mar 2026 12:09:00 +0800 Subject: [PATCH] feat: update admin frontend with scene-slot-bundle, metadata schema, and mid-platform playground pages [AC-ADMIN-FE] --- ai-service-admin/src/App.vue | 124 ++-- ai-service-admin/src/api/kb.ts | 16 + ai-service-admin/src/api/metadata-schema.ts | 2 +- ai-service-admin/src/api/mid-platform.ts | 15 + ai-service-admin/src/api/scene-slot-bundle.ts | 48 ++ .../src/components/metadata/MetadataForm.vue | 6 +- ai-service-admin/src/router/index.ts | 12 +- ai-service-admin/src/types/metadata.ts | 3 + .../src/types/scene-slot-bundle.ts | 80 +++ ai-service-admin/src/types/script-flow.ts | 4 + ai-service-admin/src/types/slot-definition.ts | 100 +++ ai-service-admin/src/utils/request.ts | 2 +- .../components/DocumentList.vue | 238 ++++++- .../src/views/admin/metadata-schema/index.vue | 19 +- .../admin/mid-platform-playground/index.vue | 87 ++- .../views/admin/scene-slot-bundle/index.vue | 660 ++++++++++++++++++ .../src/views/admin/script-flow/index.vue | 200 +++++- .../src/views/admin/slot-definition/index.vue | 336 ++++++++- .../src/views/dashboard/index.vue | 20 +- ai-service-admin/src/views/kb/index.vue | 128 +++- 20 files changed, 1985 insertions(+), 115 deletions(-) create mode 100644 ai-service-admin/src/api/scene-slot-bundle.ts create mode 100644 ai-service-admin/src/types/scene-slot-bundle.ts create mode 100644 ai-service-admin/src/views/admin/scene-slot-bundle/index.vue diff --git a/ai-service-admin/src/App.vue b/ai-service-admin/src/App.vue index 4347615..49ecb27 100644 --- a/ai-service-admin/src/App.vue +++ b/ai-service-admin/src/App.vue @@ -13,59 +13,66 @@ AI Robot
@@ -98,7 +105,7 @@ import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import { useTenantStore } from '@/stores/tenant' import { getTenantList, type Tenant } from '@/api/tenant' -import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting, ChatLineRound, Grid } from '@element-plus/icons-vue' +import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting, ChatLineRound, Grid, Collection } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' const route = useRoute() @@ -168,7 +175,8 @@ onMounted(() => { align-items: center; justify-content: space-between; padding: 0 24px; - height: 60px; + height: auto; + min-height: 60px; background-color: var(--bg-secondary, #FFFFFF); border-bottom: 1px solid var(--border-color, #E2E8F0); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); @@ -208,6 +216,12 @@ onMounted(() => { } .main-nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +.nav-row { display: flex; align-items: center; gap: 4px; diff --git a/ai-service-admin/src/api/kb.ts b/ai-service-admin/src/api/kb.ts index bf54fe1..70af8a4 100644 --- a/ai-service-admin/src/api/kb.ts +++ b/ai-service-admin/src/api/kb.ts @@ -36,3 +36,19 @@ export function deleteDocument(docId: string) { method: 'delete' }) } + +export function batchUploadDocuments(data: FormData) { + return request({ + url: '/admin/kb/documents/batch-upload', + method: 'post', + data + }) +} + +export function jsonBatchUpload(kbId: string, data: FormData) { + return request({ + url: `/admin/kb/${kbId}/documents/json-batch`, + method: 'post', + data + }) +} diff --git a/ai-service-admin/src/api/metadata-schema.ts b/ai-service-admin/src/api/metadata-schema.ts index a0bc59d..8b8a6dd 100644 --- a/ai-service-admin/src/api/metadata-schema.ts +++ b/ai-service-admin/src/api/metadata-schema.ts @@ -11,7 +11,7 @@ import type { export const metadataSchemaApi = { list: (status?: 'draft' | 'active' | 'deprecated', fieldRole?: FieldRole) => - request({ + request({ method: 'GET', url: '/admin/metadata-schemas', params: { diff --git a/ai-service-admin/src/api/mid-platform.ts b/ai-service-admin/src/api/mid-platform.ts index 9ec7f80..37c843e 100644 --- a/ai-service-admin/src/api/mid-platform.ts +++ b/ai-service-admin/src/api/mid-platform.ts @@ -35,6 +35,8 @@ export interface ToolCallTrace { error_code?: string args_digest?: string result_digest?: string + arguments?: Record + result?: unknown } export interface DialogueResponse { @@ -219,3 +221,16 @@ export function sendSharedMessage(shareToken: string, userMessage: string): Prom data: { user_message: userMessage } }) } + +export interface CancelFlowResponse { + success: boolean + message: string + session_id: string +} + +export function cancelActiveFlow(sessionId: string): Promise { + return request({ + url: `/mid/sessions/${sessionId}/cancel-flow`, + method: 'post' + }) +} diff --git a/ai-service-admin/src/api/scene-slot-bundle.ts b/ai-service-admin/src/api/scene-slot-bundle.ts new file mode 100644 index 0000000..11407a3 --- /dev/null +++ b/ai-service-admin/src/api/scene-slot-bundle.ts @@ -0,0 +1,48 @@ +import request from '@/utils/request' +import type { + SceneSlotBundle, + SceneSlotBundleWithDetails, + SceneSlotBundleCreateRequest, + SceneSlotBundleUpdateRequest, +} from '@/types/scene-slot-bundle' + +export const sceneSlotBundleApi = { + list: (status?: string) => + request({ + method: 'GET', + url: '/admin/scene-slot-bundles', + params: status ? { status } : {}, + }), + + get: (id: string) => + request({ + method: 'GET', + url: `/admin/scene-slot-bundles/${id}`, + }), + + getBySceneKey: (sceneKey: string) => + request({ + method: 'GET', + url: `/admin/scene-slot-bundles/by-scene/${sceneKey}`, + }), + + create: (data: SceneSlotBundleCreateRequest) => + request({ + method: 'POST', + url: '/admin/scene-slot-bundles', + data, + }), + + update: (id: string, data: SceneSlotBundleUpdateRequest) => + request({ + method: 'PUT', + url: `/admin/scene-slot-bundles/${id}`, + data, + }), + + delete: (id: string) => + request({ + method: 'DELETE', + url: `/admin/scene-slot-bundles/${id}`, + }), +} diff --git a/ai-service-admin/src/components/metadata/MetadataForm.vue b/ai-service-admin/src/components/metadata/MetadataForm.vue index 480a060..a29d868 100644 --- a/ai-service-admin/src/components/metadata/MetadataForm.vue +++ b/ai-service-admin/src/components/metadata/MetadataForm.vue @@ -99,17 +99,19 @@ const deprecatedFields = computed(() => { }) const visibleFields = computed(() => { + // Show all fields except deprecated in edit mode, so users can change draft to active if (props.isNewObject) { return activeFields.value } - return allFields.value.filter(f => f.status !== 'draft') + return allFields.value.filter(f => f.status !== 'deprecated') }) const loadFields = async () => { loading.value = true try { const res = await metadataSchemaApi.getByScope(props.scope, props.showDeprecated) - allFields.value = res.items || [] + // Handle both formats: direct array or { items: array } + allFields.value = Array.isArray(res) ? res : (res.items || []) emit('fields-loaded', allFields.value) applyDefaults() diff --git a/ai-service-admin/src/router/index.ts b/ai-service-admin/src/router/index.ts index 053f86d..c8ef517 100644 --- a/ai-service-admin/src/router/index.ts +++ b/ai-service-admin/src/router/index.ts @@ -17,12 +17,6 @@ const routes: Array = [ component: () => import('@/views/dashboard/index.vue'), meta: { title: '控制台' } }, - { - path: '/kb', - name: 'KBManagement', - component: () => import('@/views/kb/index.vue'), - meta: { title: '知识库管理' } - }, { path: '/rag-lab', name: 'RagLab', @@ -71,6 +65,12 @@ const routes: Array = [ component: () => import('@/views/admin/slot-definition/index.vue'), meta: { title: '槽位定义管理' } }, + { + path: '/admin/scene-slot-bundles', + name: 'SceneSlotBundle', + component: () => import('@/views/admin/scene-slot-bundle/index.vue'), + meta: { title: '场景槽位包管理' } + }, { path: '/admin/intent-rules', name: 'IntentRule', diff --git a/ai-service-admin/src/types/metadata.ts b/ai-service-admin/src/types/metadata.ts index a466033..901d74e 100644 --- a/ai-service-admin/src/types/metadata.ts +++ b/ai-service-admin/src/types/metadata.ts @@ -15,6 +15,7 @@ export interface MetadataFieldDefinition { scope: MetadataScope[] is_filterable: boolean is_rank_feature: boolean + usage_description?: string field_roles: FieldRole[] status: MetadataFieldStatus created_at?: string @@ -31,6 +32,7 @@ export interface MetadataFieldCreateRequest { scope: MetadataScope[] is_filterable?: boolean is_rank_feature?: boolean + usage_description?: string field_roles?: FieldRole[] status: MetadataFieldStatus } @@ -43,6 +45,7 @@ export interface MetadataFieldUpdateRequest { scope?: MetadataScope[] is_filterable?: boolean is_rank_feature?: boolean + usage_description?: string field_roles?: FieldRole[] status?: MetadataFieldStatus } diff --git a/ai-service-admin/src/types/scene-slot-bundle.ts b/ai-service-admin/src/types/scene-slot-bundle.ts new file mode 100644 index 0000000..984861c --- /dev/null +++ b/ai-service-admin/src/types/scene-slot-bundle.ts @@ -0,0 +1,80 @@ +/** + * Scene Slot Bundle Types. + * [AC-SCENE-SLOT-01] 场景-槽位映射配置类型定义 + */ + +export type SceneSlotBundleStatus = 'draft' | 'active' | 'deprecated' +export type AskBackOrder = 'priority' | 'required_first' | 'parallel' + +export interface SceneSlotBundle { + id: string + tenant_id: string + scene_key: string + scene_name: string + description: string | null + required_slots: string[] + optional_slots: string[] + slot_priority: string[] | null + completion_threshold: number + ask_back_order: AskBackOrder + status: SceneSlotBundleStatus + version: number + created_at: string | null + updated_at: string | null +} + +export interface SlotDetail { + slot_key: string + type: string + required: boolean + ask_back_prompt: string | null + linked_field_id: string | null +} + +export interface SceneSlotBundleWithDetails extends SceneSlotBundle { + required_slot_details: SlotDetail[] + optional_slot_details: SlotDetail[] +} + +export interface SceneSlotBundleCreateRequest { + scene_key: string + scene_name: string + description?: string + required_slots?: string[] + optional_slots?: string[] + slot_priority?: string[] + completion_threshold?: number + ask_back_order?: AskBackOrder + status?: SceneSlotBundleStatus +} + +export interface SceneSlotBundleUpdateRequest { + scene_name?: string + description?: string + required_slots?: string[] + optional_slots?: string[] + slot_priority?: string[] + completion_threshold?: number + ask_back_order?: AskBackOrder + status?: SceneSlotBundleStatus +} + +export const SCENE_SLOT_BUNDLE_STATUS_OPTIONS = [ + { value: 'draft', label: '草稿', description: '配置中,未启用' }, + { value: 'active', label: '已启用', description: '运行时可用' }, + { value: 'deprecated', label: '已废弃', description: '不再使用' }, +] + +export const ASK_BACK_ORDER_OPTIONS = [ + { value: 'priority', label: '按优先级', description: '按 slot_priority 定义的顺序追问' }, + { value: 'required_first', label: '必填优先', description: '优先追问必填槽位' }, + { value: 'parallel', label: '并行追问', description: '一次追问多个缺失槽位' }, +] + +export function getStatusLabel(status: SceneSlotBundleStatus): string { + return SCENE_SLOT_BUNDLE_STATUS_OPTIONS.find(o => o.value === status)?.label || status +} + +export function getAskBackOrderLabel(order: AskBackOrder): string { + return ASK_BACK_ORDER_OPTIONS.find(o => o.value === order)?.label || order +} diff --git a/ai-service-admin/src/types/script-flow.ts b/ai-service-admin/src/types/script-flow.ts index 91b4584..837cf23 100644 --- a/ai-service-admin/src/types/script-flow.ts +++ b/ai-service-admin/src/types/script-flow.ts @@ -39,6 +39,10 @@ export interface FlowStep { intent_description?: string script_constraints?: string[] expected_variables?: string[] + allowed_kb_ids?: string[] + preferred_kb_ids?: string[] + kb_query_hint?: string + max_kb_calls_per_step?: number } export interface NextCondition { diff --git a/ai-service-admin/src/types/slot-definition.ts b/ai-service-admin/src/types/slot-definition.ts index ae8feaa..aceff73 100644 --- a/ai-service-admin/src/types/slot-definition.ts +++ b/ai-service-admin/src/types/slot-definition.ts @@ -2,13 +2,31 @@ export type SlotType = 'string' | 'number' | 'boolean' | 'enum' | 'array_enum' export type ExtractStrategy = 'rule' | 'llm' | 'user_input' export type SlotSource = 'user_confirmed' | 'rule_extracted' | 'llm_inferred' | 'default' +/** + * [AC-MRS-07-UPGRADE] 提取失败类型 + */ +export type ExtractFailureType = + | 'EXTRACT_EMPTY' + | 'EXTRACT_PARSE_FAIL' + | 'EXTRACT_VALIDATION_FAIL' + | 'EXTRACT_RUNTIME_ERROR' + export interface SlotDefinition { id: string tenant_id: string slot_key: string + display_name?: string + description?: string type: SlotType required: boolean + /** + * [AC-MRS-07-UPGRADE] 兼容字段:单提取策略,已废弃 + */ extract_strategy?: ExtractStrategy + /** + * [AC-MRS-07-UPGRADE] 提取策略链:有序数组,按顺序执行直到成功 + */ + extract_strategies?: ExtractStrategy[] validation_rule?: string ask_back_prompt?: string default_value?: string | number | boolean | string[] @@ -29,8 +47,17 @@ export interface LinkedField { export interface SlotDefinitionCreateRequest { tenant_id?: string slot_key: string + display_name?: string + description?: string type: SlotType required: boolean + /** + * [AC-MRS-07-UPGRADE] 提取策略链 + */ + extract_strategies?: ExtractStrategy[] + /** + * [AC-MRS-07-UPGRADE] 兼容字段 + */ extract_strategy?: ExtractStrategy validation_rule?: string ask_back_prompt?: string @@ -39,8 +66,17 @@ export interface SlotDefinitionCreateRequest { } export interface SlotDefinitionUpdateRequest { + display_name?: string + description?: string type?: SlotType required?: boolean + /** + * [AC-MRS-07-UPGRADE] 提取策略链 + */ + extract_strategies?: ExtractStrategy[] + /** + * [AC-MRS-07-UPGRADE] 兼容字段 + */ extract_strategy?: ExtractStrategy validation_rule?: string ask_back_prompt?: string @@ -56,6 +92,31 @@ export interface RuntimeSlotValue { updated_at?: string } +/** + * [AC-MRS-07-UPGRADE] 策略链执行步骤结果 + */ +export interface StrategyStepResult { + strategy: ExtractStrategy + success: boolean + value?: string | number | boolean | string[] + failure_type?: ExtractFailureType + failure_reason?: string + execution_time_ms: number +} + +/** + * [AC-MRS-07-UPGRADE] 策略链执行结果 + */ +export interface StrategyChainResult { + slot_key: string + success: boolean + final_value?: string | number | boolean | string[] + final_strategy?: ExtractStrategy + steps: StrategyStepResult[] + total_execution_time_ms: number + ask_back_prompt?: string +} + export const SLOT_TYPE_OPTIONS = [ { value: 'string', label: '文本' }, { value: 'number', label: '数字' }, @@ -69,3 +130,42 @@ export const EXTRACT_STRATEGY_OPTIONS = [ { value: 'llm', label: 'LLM 推断', description: '通过大语言模型推断槽位值' }, { value: 'user_input', label: '用户输入', description: '通过追问提示语让用户主动输入' } ] + +/** + * [AC-MRS-07-UPGRADE] 提取策略链验证 + */ +export function validateExtractStrategies(strategies: ExtractStrategy[]): { valid: boolean; error?: string } { + if (!strategies || strategies.length === 0) { + return { valid: true } // 空数组视为有效 + } + + const validStrategies = new Set(['rule', 'llm', 'user_input']) + + // 检查重复 + const uniqueStrategies = new Set(strategies) + if (uniqueStrategies.size !== strategies.length) { + return { valid: false, error: '提取策略链中不允许重复的策略' } + } + + // 检查有效性 + const invalid = strategies.filter(s => !validStrategies.has(s)) + if (invalid.length > 0) { + return { valid: false, error: `无效的提取策略: ${invalid.join(', ')}` } + } + + return { valid: true } +} + +/** + * [AC-MRS-07-UPGRADE] 获取有效的策略链 + * 优先使用 extract_strategies,如果不存在则兼容读取 extract_strategy + */ +export function getEffectiveStrategies(slot: SlotDefinition): ExtractStrategy[] { + if (slot.extract_strategies && slot.extract_strategies.length > 0) { + return slot.extract_strategies + } + if (slot.extract_strategy) { + return [slot.extract_strategy] + } + return [] +} diff --git a/ai-service-admin/src/utils/request.ts b/ai-service-admin/src/utils/request.ts index e80ef52..a8e8d2a 100644 --- a/ai-service-admin/src/utils/request.ts +++ b/ai-service-admin/src/utils/request.ts @@ -4,7 +4,7 @@ import { useTenantStore } from '@/stores/tenant' const service = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API || '/api', - timeout: 60000 + timeout: 180000 }) service.interceptors.request.use( diff --git a/ai-service-admin/src/views/admin/knowledge-base/components/DocumentList.vue b/ai-service-admin/src/views/admin/knowledge-base/components/DocumentList.vue index 576bb8c..6f87d6a 100644 --- a/ai-service-admin/src/views/admin/knowledge-base/components/DocumentList.vue +++ b/ai-service-admin/src/views/admin/knowledge-base/components/DocumentList.vue @@ -5,6 +5,14 @@ 上传文档 + + + 批量上传 + + + + JSON上传 + + +
@@ -165,6 +187,8 @@ const pagination = ref({ }) const fileInputRef = ref() +const batchFileInputRef = ref() +const jsonFileInputRef = ref() const uploadDialogVisible = ref(false) const editDialogVisible = ref(false) const uploading = ref(false) @@ -261,6 +285,14 @@ const handleUploadClick = () => { fileInputRef.value?.click() } +const handleBatchUploadClick = () => { + batchFileInputRef.value?.click() +} + +const handleJsonUploadClick = () => { + jsonFileInputRef.value?.click() +} + const handleFileSelect = (event: Event) => { const target = event.target as HTMLInputElement const file = target.files?.[0] @@ -289,6 +321,140 @@ const handleFileSelect = (event: Event) => { } } +const handleBatchFileSelect = async (event: Event) => { + const target = event.target as HTMLInputElement + const file = target.files?.[0] + if (!file) return + + if (!file.name.toLowerCase().endsWith('.zip')) { + ElMessage.error('请上传 .zip 格式的压缩包') + if (batchFileInputRef.value) { + batchFileInputRef.value.value = '' + } + return + } + + const maxSize = 100 * 1024 * 1024 + if (file.size > maxSize) { + ElMessage.error('压缩包大小不能超过 100MB') + if (batchFileInputRef.value) { + batchFileInputRef.value.value = '' + } + return + } + + try { + loading.value = true + const formData = new FormData() + formData.append('file', file) + formData.append('kb_id', props.kbId) + + const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api' + const apiKey = import.meta.env.VITE_APP_API_KEY || '' + const response = await fetch(`${baseUrl}/admin/kb/documents/batch-upload`, { + method: 'POST', + headers: { + 'X-Tenant-Id': tenantStore.currentTenantId, + 'X-API-Key': apiKey + }, + body: formData + }) + + const result = await response.json() + if (result.success) { + const { total, succeeded, failed, results } = result + ElMessage.success(`批量上传完成!成功: ${succeeded}, 失败: ${failed}, 总计: ${total}`) + + // 收集所有成功的任务ID,稍后串行轮询 + const jobIds: string[] = [] + for (const item of results) { + if (item.status === 'created' && item.jobId) { + jobIds.push(item.jobId) + } + } + + // 串行轮询所有任务,避免并发请求触发限流 + if (jobIds.length > 0) { + pollJobStatusSequential(jobIds) + } + + emit('upload-success') + loadDocuments() + } else { + ElMessage.error(result.message || '批量上传失败') + } + } catch (error) { + ElMessage.error('批量上传失败,请重试') + console.error('Batch upload error:', error) + } finally { + loading.value = false + if (batchFileInputRef.value) { + batchFileInputRef.value.value = '' + } + } +} + +const handleJsonFileSelect = async (event: Event) => { + const target = event.target as HTMLInputElement + const file = target.files?.[0] + if (!file) return + + const fileName = file.name.toLowerCase() + if (!fileName.endsWith('.json') && !fileName.endsWith('.jsonl') && !fileName.endsWith('.txt')) { + ElMessage.error('请上传 .json、.jsonl 或 .txt 格式的文件') + if (jsonFileInputRef.value) { + jsonFileInputRef.value.value = '' + } + return + } + + try { + loading.value = true + ElMessage.info('正在上传 JSON 文件...') + + const formData = new FormData() + formData.append('file', file) + + const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api' + const apiKey = import.meta.env.VITE_APP_API_KEY || '' + const response = await fetch(`${baseUrl}/admin/kb/${props.kbId}/documents/json-batch?tenant_id=${tenantStore.currentTenantId}`, { + method: 'POST', + headers: { + 'X-Tenant-Id': tenantStore.currentTenantId, + 'X-API-Key': apiKey + }, + body: formData + }) + + const result = await response.json() + if (result.success) { + ElMessage.success(`JSON 批量上传成功!成功: ${result.succeeded}, 失败: ${result.failed}`) + + if (result.valid_metadata_fields) { + console.log('有效的元数据字段:', result.valid_metadata_fields) + } + + if (result.failed > 0) { + const failedItems = result.results.filter((r: any) => !r.success) + console.warn('失败的项目:', failedItems) + } + + emit('upload-success') + loadDocuments() + } else { + ElMessage.error(result.message || 'JSON 批量上传失败') + } + } catch (error) { + ElMessage.error('JSON 批量上传失败,请重试') + console.error('JSON batch upload error:', error) + } finally { + loading.value = false + if (jsonFileInputRef.value) { + jsonFileInputRef.value.value = '' + } + } +} + const handleUpload = async () => { if (!selectedFile.value) { ElMessage.warning('请选择文件') @@ -310,11 +476,13 @@ const handleUpload = async () => { formData.append('kb_id', props.kbId) formData.append('metadata', JSON.stringify(uploadForm.value.metadata)) - const baseUrl = import.meta.env.VITE_API_BASE_URL || '' + const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api' + const apiKey = import.meta.env.VITE_APP_API_KEY || '' const response = await fetch(`${baseUrl}/admin/kb/documents`, { method: 'POST', headers: { - 'X-Tenant-Id': tenantStore.currentTenantId + 'X-Tenant-Id': tenantStore.currentTenantId, + 'X-API-Key': apiKey }, body: formData }) @@ -356,12 +524,14 @@ const handleSaveMetadata = async () => { saving.value = true try { - const baseUrl = import.meta.env.VITE_API_BASE_URL || '' + const baseUrl = import.meta.env.VITE_APP_BASE_API || '/api' + const apiKey = import.meta.env.VITE_APP_API_KEY || '' const response = await fetch(`${baseUrl}/admin/kb/documents/${currentEditDoc.value.docId}/metadata`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'X-Tenant-Id': tenantStore.currentTenantId + 'X-Tenant-Id': tenantStore.currentTenantId, + 'X-API-Key': apiKey }, body: JSON.stringify({ metadata: editForm.value.metadata }) }) @@ -398,14 +568,64 @@ const pollJobStatus = async (jobId: string) => { ElMessage.error(`文档处理失败: ${job.errorMsg || '未知错误'}`) loadDocuments() } else { - setTimeout(poll, 3000) + setTimeout(poll, 10000) // 10秒轮询一次,避免触发限流 + } + } catch (error: any) { + if (error.status === 429) { + console.warn('请求过于频繁,稍后重试') + setTimeout(poll, 15000) // 被限流后等待15秒 + } else { + console.error('轮询任务状态失败', error) } - } catch (error) { - console.error('轮询任务状态失败', error) } } - setTimeout(poll, 2000) + setTimeout(poll, 5000) // 首次轮询等待5秒 +} + +// 串行轮询多个任务,避免并发触发限流 +const pollJobStatusSequential = async (jobIds: string[]) => { + const pendingJobs = new Set(jobIds) + const maxPolls = 60 + let pollCount = 0 + + const pollAll = async () => { + if (pollCount >= maxPolls || pendingJobs.size === 0) return + pollCount++ + + const completedJobs: string[] = [] + + for (const jobId of pendingJobs) { + try { + const job: IndexJob = await getIndexJob(jobId) + if (job.status === 'completed') { + completedJobs.push(jobId) + } else if (job.status === 'failed') { + completedJobs.push(jobId) + console.error(`文档处理失败: ${job.errorMsg || '未知错误'}`) + } + } catch (error: any) { + if (error.status === 429) { + console.warn('请求过于频繁,稍后重试') + break // 被限流时停止本轮轮询,等待下次 + } else { + console.error('轮询任务状态失败', error) + } + } + } + + // 移除已完成的任务 + completedJobs.forEach(jobId => pendingJobs.delete(jobId)) + + if (pendingJobs.size > 0) { + setTimeout(pollAll, 10000) // 10秒后再次轮询 + } else { + ElMessage.success('所有文档处理完成') + loadDocuments() + } + } + + setTimeout(pollAll, 5000) // 首次轮询等待5秒 } const handleDelete = async (row: DocumentWithMetadata) => { @@ -438,6 +658,8 @@ onMounted(() => { .list-header { margin-bottom: 16px; + display: flex; + gap: 12px; } .pagination-wrapper { diff --git a/ai-service-admin/src/views/admin/metadata-schema/index.vue b/ai-service-admin/src/views/admin/metadata-schema/index.vue index ce623bc..d2a83da 100644 --- a/ai-service-admin/src/views/admin/metadata-schema/index.vue +++ b/ai-service-admin/src/views/admin/metadata-schema/index.vue @@ -211,6 +211,14 @@ + + + @@ -308,6 +316,7 @@ const formData = reactive({ scope: [] as MetadataScope[], is_filterable: true, is_rank_feature: false, + usage_description: '', field_roles: [] as FieldRole[], status: 'draft' as MetadataFieldStatus }) @@ -354,8 +363,11 @@ const getRoleLabel = (role: FieldRole) => { const fetchFields = async () => { loading.value = true try { - const res = await metadataSchemaApi.list(filterStatus.value || undefined, filterRole.value || undefined) - fields.value = res.items || [] + const res: any = await metadataSchemaApi.list(filterStatus.value || undefined, filterRole.value || undefined) + // 兼容多种后端返回格式:[] / {items: []} / {schemas: []} / {data: []} + fields.value = Array.isArray(res) + ? res + : (res?.items || res?.schemas || res?.data || []) } catch (error: any) { ElMessage.error(error.response?.data?.message || '获取元数据字段失败') } finally { @@ -376,6 +388,7 @@ const handleCreate = () => { scope: [], is_filterable: true, is_rank_feature: false, + usage_description: '', field_roles: [], status: 'draft' }) @@ -395,6 +408,7 @@ const handleEdit = (field: MetadataFieldDefinition) => { scope: [...field.scope], is_filterable: field.is_filterable, is_rank_feature: field.is_rank_feature, + usage_description: field.usage_description || '', field_roles: field.field_roles || [], status: field.status }) @@ -472,6 +486,7 @@ const handleSubmit = async () => { scope: formData.scope, is_filterable: formData.is_filterable, is_rank_feature: formData.is_rank_feature, + usage_description: formData.usage_description || undefined, field_roles: formData.field_roles, status: formData.status } diff --git a/ai-service-admin/src/views/admin/mid-platform-playground/index.vue b/ai-service-admin/src/views/admin/mid-platform-playground/index.vue index 34f4e6a..59bbb50 100644 --- a/ai-service-admin/src/views/admin/mid-platform-playground/index.vue +++ b/ai-service-admin/src/views/admin/mid-platform-playground/index.vue @@ -30,6 +30,12 @@ 应用模式 + + 取消流程 + + + 新建会话 + @@ -98,12 +104,34 @@
tool_calls
-
{{ JSON.stringify(lastTrace.tool_calls || [], null, 2) }}
+
+ + 复制 + +
{{ JSON.stringify(lastTrace.tool_calls || [], null, 2) }}
+
metrics_snapshot
-
{{ JSON.stringify(lastTrace.metrics_snapshot || {}, null, 2) }}
+
+ + 复制 + +
{{ JSON.stringify(lastTrace.metrics_snapshot || {}, null, 2) }}
+
@@ -180,6 +208,7 @@ import { createPublicShareToken, listShares, deleteShare, + cancelActiveFlow, type DialogueMessage, type DialogueResponse, type SessionMode, @@ -205,6 +234,7 @@ const rollbackToLegacy = ref(false) const sending = ref(false) const switchingMode = ref(false) const reporting = ref(false) +const cancellingFlow = ref(false) const userInput = ref('') const conversation = ref([]) @@ -221,11 +251,11 @@ const shareForm = ref({ const now = () => new Date().toLocaleTimeString() -const tagType = (role: ChatRole) => { +const tagType = (role: ChatRole): 'primary' | 'success' | 'info' | 'warning' | 'danger' => { if (role === 'user') return 'info' if (role === 'assistant') return 'success' if (role === 'human') return 'warning' - return '' + return 'primary' } const toHistory = (): DialogueMessage[] => { @@ -289,6 +319,32 @@ const handleSwitchMode = async () => { } } +const handleCancelFlow = async () => { + cancellingFlow.value = true + try { + const result = await cancelActiveFlow(sessionId.value) + if (result.success) { + ElMessage.success(result.message) + } else { + ElMessage.warning(result.message) + } + } catch { + ElMessage.error('取消流程失败') + } finally { + cancellingFlow.value = false + } +} + +const handleNewSession = () => { + const newSessionId = `sess_${Date.now()}` + const newUserId = `user_${Date.now()}` + sessionId.value = newSessionId + userId.value = newUserId + conversation.value = [] + lastTrace.value = null + ElMessage.success(`新会话已创建: ${newSessionId}`) +} + const handleReportMessages = async () => { if (!conversation.value.length) { ElMessage.warning('暂无可上报消息') @@ -370,6 +426,12 @@ const copyShareUrl = (url: string) => { ElMessage.success('链接已复制到剪贴板') } +const copyJson = (data: unknown, name: string) => { + const jsonStr = JSON.stringify(data, null, 2) + navigator.clipboard.writeText(jsonStr) + ElMessage.success(`${name} 已复制到剪贴板`) +} + const formatShareExpires = (expiresAt: string) => { const expires = new Date(expiresAt) return expires.toLocaleDateString() @@ -462,6 +524,23 @@ onMounted(() => { margin-bottom: 4px; } +.json-content { + position: relative; +} + +.json-content .copy-btn { + position: absolute; + top: 4px; + right: 4px; + z-index: 10; + background: var(--el-bg-color); + opacity: 0.8; +} + +.json-content .copy-btn:hover { + opacity: 1; +} + pre { background: var(--el-fill-color-lighter); padding: 8px; diff --git a/ai-service-admin/src/views/admin/scene-slot-bundle/index.vue b/ai-service-admin/src/views/admin/scene-slot-bundle/index.vue new file mode 100644 index 0000000..9f181cf --- /dev/null +++ b/ai-service-admin/src/views/admin/scene-slot-bundle/index.vue @@ -0,0 +1,660 @@ + + + + + diff --git a/ai-service-admin/src/views/admin/script-flow/index.vue b/ai-service-admin/src/views/admin/script-flow/index.vue index 33dbfe5..6b73912 100644 --- a/ai-service-admin/src/views/admin/script-flow/index.vue +++ b/ai-service-admin/src/views/admin/script-flow/index.vue @@ -185,11 +185,23 @@ v-model="element.expected_variables" multiple filterable - allow-create - default-first-option - placeholder="输入变量名后回车添加" + placeholder="选择期望提取的槽位" style="width: 100%" - /> + > + +
+ {{ slot.slot_key }} + {{ slot.type }} + 必填 +
+
+ +
期望变量必须引用已定义的槽位,步骤完成时会检查这些槽位是否已填充
@@ -218,14 +230,101 @@ v-model="element.expected_variables" multiple filterable - allow-create - default-first-option - placeholder="输入变量名后回车添加" + placeholder="选择期望提取的槽位" style="width: 100%" - /> + > + +
+ {{ slot.slot_key }} + {{ slot.type }} + 必填 +
+
+ +
期望变量必须引用已定义的槽位,步骤完成时会检查这些槽位是否已填充
+ 知识库范围 + +
+ + + +
+ {{ kb.name }} + {{ getKbTypeLabel(kb.kbType) }} +
+
+
+
限制该步骤只能从选定的知识库中检索信息,为空则使用默认检索策略
+
+ + + + +
+ {{ kb.name }} + {{ getKbTypeLabel(kb.kbType) }} +
+
+
+
检索时优先搜索这些知识库
+
+ + + + + + + + + + + + + +
+ @@ -362,17 +461,24 @@ import { createScriptFlow, updateScriptFlow, deleteScriptFlow, - getScriptFlow + getScriptFlow, } from '@/api/script-flow' +import { slotDefinitionApi } from '@/api/slot-definition' +import { listKnowledgeBases } from '@/api/knowledge-base' import { MetadataForm } from '@/components/metadata' import { TIMEOUT_ACTION_OPTIONS, SCRIPT_MODE_OPTIONS } from '@/types/script-flow' import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep, ScriptMode } from '@/types/script-flow' +import type { SlotDefinition } from '@/types/slot-definition' +import type { KnowledgeBase } from '@/types/knowledge-base' +import { KB_TYPE_MAP } from '@/types/knowledge-base' import FlowPreview from './components/FlowPreview.vue' import SimulateDialog from './components/SimulateDialog.vue' import ConstraintManager from './components/ConstraintManager.vue' const loading = ref(false) const flows = ref([]) +const availableSlots = ref([]) +const availableKnowledgeBases = ref([]) const dialogVisible = ref(false) const previewDrawer = ref(false) const simulateDialogVisible = ref(false) @@ -425,16 +531,55 @@ const loadFlows = async () => { } } +const loadAvailableSlots = async () => { + try { + const res = await slotDefinitionApi.list() + availableSlots.value = res || [] + } catch (error) { + console.error('加载槽位定义失败', error) + availableSlots.value = [] + } +} + +const loadAvailableKnowledgeBases = async () => { + try { + const res = await listKnowledgeBases({ is_enabled: true }) + availableKnowledgeBases.value = res.data || [] + } catch (error) { + console.error('加载知识库列表失败', error) + availableKnowledgeBases.value = [] + } +} + +const getKbTypeLabel = (kbType: string): string => { + return KB_TYPE_MAP[kbType]?.label || kbType +} + +const getKbTypeTagType = (kbType: string): string => { + const typeMap: Record = { + product: 'primary', + faq: 'success', + script: 'warning', + policy: 'danger', + general: 'info' + } + return typeMap[kbType] || 'info' +} + const handleCreate = () => { isEdit.value = false currentEditId.value = '' formData.value = defaultFormData() + loadAvailableSlots() + loadAvailableKnowledgeBases() dialogVisible.value = true } const handleEdit = async (row: ScriptFlow) => { isEdit.value = true currentEditId.value = row.id + await loadAvailableSlots() + await loadAvailableKnowledgeBases() try { const detail = await getScriptFlow(row.id) formData.value = { @@ -444,7 +589,11 @@ const handleEdit = async (row: ScriptFlow) => { ...step, script_mode: step.script_mode || 'fixed', script_constraints: step.script_constraints || [], - expected_variables: step.expected_variables || [] + expected_variables: step.expected_variables || [], + allowed_kb_ids: step.allowed_kb_ids || [], + preferred_kb_ids: step.preferred_kb_ids || [], + kb_query_hint: step.kb_query_hint || '', + max_kb_calls_per_step: step.max_kb_calls_per_step || null })), is_enabled: detail.is_enabled, metadata: detail.metadata || {} @@ -739,4 +888,35 @@ onMounted(() => { border-radius: 4px; margin-bottom: 8px; } + +.slot-option { + display: flex; + align-items: center; + gap: 8px; +} + +.slot-key { + font-weight: 500; + font-family: monospace; +} + +.field-hint { + margin-top: 4px; + font-size: 12px; + color: var(--el-text-color-secondary); +} + +.kb-binding-section { + padding: 12px; + background-color: var(--el-fill-color-lighter); + border-radius: 6px; + margin-bottom: 12px; +} + +.kb-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} diff --git a/ai-service-admin/src/views/admin/slot-definition/index.vue b/ai-service-admin/src/views/admin/slot-definition/index.vue index 9d679c7..a3fa2da 100644 --- a/ai-service-admin/src/views/admin/slot-definition/index.vue +++ b/ai-service-admin/src/views/admin/slot-definition/index.vue @@ -23,7 +23,16 @@ + + + @@ -38,11 +47,20 @@ - + + @@ -80,7 +98,7 @@ @@ -96,6 +114,25 @@
仅允许小写字母、数字、下划线
+ + + +
给运营/教研看的中文名
+
+
+
+ + + + @@ -108,21 +145,74 @@ - - - - - + + + + +
+
+ 按优先级排序,系统将依次尝试直到成功 + + 清空 + +
+ +
+ + + +
+ +
+ 暂无策略,请从下方添加 +
+ +
+
{{ opt.label }} @@ -130,9 +220,22 @@
- - - + + + 添加 + +
+ +
+ {{ strategyError }} +
+
+
+