feat: update admin frontend with scene-slot-bundle, metadata schema, and mid-platform playground pages [AC-ADMIN-FE]
This commit is contained in:
parent
9769f7ccf0
commit
759eafb490
|
|
@ -13,6 +13,7 @@
|
|||
<span class="logo-text">AI Robot</span>
|
||||
</div>
|
||||
<nav class="main-nav">
|
||||
<div class="nav-row">
|
||||
<router-link to="/dashboard" class="nav-item" :class="{ active: isActive('/dashboard') }">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<span>控制台</span>
|
||||
|
|
@ -29,7 +30,8 @@
|
|||
<el-icon><Monitor /></el-icon>
|
||||
<span>会话监控</span>
|
||||
</router-link>
|
||||
<div class="nav-divider"></div>
|
||||
</div>
|
||||
<div class="nav-row">
|
||||
<router-link to="/admin/embedding" class="nav-item" :class="{ active: isActive('/admin/embedding') }">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>嵌入模型</span>
|
||||
|
|
@ -66,6 +68,11 @@
|
|||
<el-icon><Grid /></el-icon>
|
||||
<span>槽位定义</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/scene-slot-bundles" class="nav-item" :class="{ active: isActive('/admin/scene-slot-bundles') }">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>场景槽位包</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
|
||||
export const metadataSchemaApi = {
|
||||
list: (status?: 'draft' | 'active' | 'deprecated', fieldRole?: FieldRole) =>
|
||||
request<MetadataFieldListResponse>({
|
||||
request<MetadataFieldDefinition[]>({
|
||||
method: 'GET',
|
||||
url: '/admin/metadata-schemas',
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface ToolCallTrace {
|
|||
error_code?: string
|
||||
args_digest?: string
|
||||
result_digest?: string
|
||||
arguments?: Record<string, unknown>
|
||||
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<CancelFlowResponse> {
|
||||
return request({
|
||||
url: `/mid/sessions/${sessionId}/cancel-flow`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SceneSlotBundle[]>({
|
||||
method: 'GET',
|
||||
url: '/admin/scene-slot-bundles',
|
||||
params: status ? { status } : {},
|
||||
}),
|
||||
|
||||
get: (id: string) =>
|
||||
request<SceneSlotBundleWithDetails>({
|
||||
method: 'GET',
|
||||
url: `/admin/scene-slot-bundles/${id}`,
|
||||
}),
|
||||
|
||||
getBySceneKey: (sceneKey: string) =>
|
||||
request<SceneSlotBundle>({
|
||||
method: 'GET',
|
||||
url: `/admin/scene-slot-bundles/by-scene/${sceneKey}`,
|
||||
}),
|
||||
|
||||
create: (data: SceneSlotBundleCreateRequest) =>
|
||||
request<SceneSlotBundle>({
|
||||
method: 'POST',
|
||||
url: '/admin/scene-slot-bundles',
|
||||
data,
|
||||
}),
|
||||
|
||||
update: (id: string, data: SceneSlotBundleUpdateRequest) =>
|
||||
request<SceneSlotBundle>({
|
||||
method: 'PUT',
|
||||
url: `/admin/scene-slot-bundles/${id}`,
|
||||
data,
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<void>({
|
||||
method: 'DELETE',
|
||||
url: `/admin/scene-slot-bundles/${id}`,
|
||||
}),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -17,12 +17,6 @@ const routes: Array<RouteRecordRaw> = [
|
|||
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<RouteRecordRaw> = [
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@
|
|||
<el-icon><Upload /></el-icon>
|
||||
上传文档
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleBatchUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
批量上传
|
||||
</el-button>
|
||||
<el-button type="warning" @click="handleJsonUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
JSON上传
|
||||
</el-button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
|
|
@ -12,6 +20,20 @@
|
|||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<input
|
||||
ref="batchFileInputRef"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
style="display: none"
|
||||
@change="handleBatchFileSelect"
|
||||
/>
|
||||
<input
|
||||
ref="jsonFileInputRef"
|
||||
type="file"
|
||||
accept=".json,.jsonl,.txt"
|
||||
style="display: none"
|
||||
@change="handleJsonFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="documents" v-loading="loading" stripe>
|
||||
|
|
@ -165,6 +187,8 @@ const pagination = ref({
|
|||
})
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const batchFileInputRef = ref<HTMLInputElement>()
|
||||
const jsonFileInputRef = ref<HTMLInputElement>()
|
||||
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) {
|
||||
} catch (error: any) {
|
||||
if (error.status === 429) {
|
||||
console.warn('请求过于频繁,稍后重试')
|
||||
setTimeout(poll, 15000) // 被限流后等待15秒
|
||||
} else {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -211,6 +211,14 @@
|
|||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="用途说明">
|
||||
<el-input
|
||||
v-model="formData.usage_description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="描述该字段的业务用途和使用场景"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认值">
|
||||
<el-input v-model="formData.default_value" placeholder="可选默认值" />
|
||||
</el-form-item>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@
|
|||
<el-form-item>
|
||||
<el-button :loading="switchingMode" @click="handleSwitchMode">应用模式</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="warning" :loading="cancellingFlow" @click="handleCancelFlow">取消流程</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleNewSession">新建会话</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
|
|
@ -98,14 +104,36 @@
|
|||
|
||||
<div class="json-panel">
|
||||
<div class="json-title">tool_calls</div>
|
||||
<div class="json-content">
|
||||
<el-button
|
||||
class="copy-btn"
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
@click="copyJson(lastTrace.tool_calls || [], 'tool_calls')"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
<pre>{{ JSON.stringify(lastTrace.tool_calls || [], null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="json-panel">
|
||||
<div class="json-title">metrics_snapshot</div>
|
||||
<div class="json-content">
|
||||
<el-button
|
||||
class="copy-btn"
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
@click="copyJson(lastTrace.metrics_snapshot || {}, 'metrics_snapshot')"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
<pre>{{ JSON.stringify(lastTrace.metrics_snapshot || {}, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无 Trace" :image-size="90" />
|
||||
</el-card>
|
||||
|
||||
|
|
@ -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<ChatItem[]>([])
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
<template>
|
||||
<div class="scene-slot-bundle-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">场景槽位包管理</h1>
|
||||
<p class="page-desc">配置场景与槽位的映射关系,定义每个场景需要采集的槽位集合。[AC-SCENE-SLOT-01]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 140px;">
|
||||
<el-option
|
||||
v-for="opt in SCENE_SLOT_BUNDLE_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建场景槽位包
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="bundle-card" v-loading="loading">
|
||||
<el-table :data="bundles" stripe style="width: 100%">
|
||||
<el-table-column prop="scene_key" label="场景标识" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<code class="scene-key">{{ row.scene_key }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scene_name" label="场景名称" min-width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="required_slots" label="必填槽位" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="slot-tags">
|
||||
<el-tag
|
||||
v-for="slotKey in row.required_slots.slice(0, 3)"
|
||||
:key="slotKey"
|
||||
size="small"
|
||||
type="danger"
|
||||
class="slot-tag"
|
||||
>
|
||||
{{ slotKey }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.required_slots.length > 3" size="small" type="info">
|
||||
+{{ row.required_slots.length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="optional_slots" label="可选槽位" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="slot-tags" v-if="row.optional_slots.length > 0">
|
||||
<el-tag
|
||||
v-for="slotKey in row.optional_slots.slice(0, 3)"
|
||||
:key="slotKey"
|
||||
size="small"
|
||||
type="info"
|
||||
class="slot-tag"
|
||||
>
|
||||
{{ slotKey }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.optional_slots.length > 3" size="small" type="info">
|
||||
+{{ row.optional_slots.length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="no-value">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completion_threshold" label="完成阈值" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ (row.completion_threshold * 100).toFixed(0) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ask_back_order" label="追问策略" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ getAskBackOrderLabel(row.ask_back_order) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && bundles.length === 0" description="暂无场景槽位包" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑场景槽位包' : '新建场景槽位包'"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="场景标识" prop="scene_key">
|
||||
<el-input
|
||||
v-model="formData.scene_key"
|
||||
placeholder="如:open_consult, refund_apply"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
<div class="field-hint">唯一标识,创建后不可修改</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="场景名称" prop="scene_name">
|
||||
<el-input v-model="formData.scene_name" placeholder="如:开放咨询、退款申请" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="场景描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="描述该场景的业务背景和用途"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="opt in SCENE_SLOT_BUNDLE_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
<div class="status-option">
|
||||
<span>{{ opt.label }}</span>
|
||||
<span class="status-desc">{{ opt.description }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="完成阈值" prop="completion_threshold">
|
||||
<el-slider
|
||||
v-model="formData.completion_threshold"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
:format-tooltip="(val: number) => `${(val * 100).toFixed(0)}%`"
|
||||
/>
|
||||
<div class="field-hint">必填槽位填充比例达到此值视为完成</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="必填槽位" prop="required_slots">
|
||||
<el-select
|
||||
v-model="formData.required_slots"
|
||||
multiple
|
||||
filterable
|
||||
style="width: 100%;"
|
||||
placeholder="选择必填槽位"
|
||||
>
|
||||
<el-option
|
||||
v-for="slot in availableSlots"
|
||||
:key="slot.id"
|
||||
:label="`${slot.slot_key} (${slot.ask_back_prompt || '无追问提示'})`"
|
||||
:value="slot.slot_key"
|
||||
:disabled="formData.optional_slots.includes(slot.slot_key)"
|
||||
>
|
||||
<div class="slot-option">
|
||||
<span class="slot-key-label">{{ slot.slot_key }}</span>
|
||||
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="field-hint">缺失必填槽位时会触发追问,不会直接进行 KB 检索</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="可选槽位" prop="optional_slots">
|
||||
<el-select
|
||||
v-model="formData.optional_slots"
|
||||
multiple
|
||||
filterable
|
||||
style="width: 100%;"
|
||||
placeholder="选择可选槽位"
|
||||
>
|
||||
<el-option
|
||||
v-for="slot in availableSlots"
|
||||
:key="slot.id"
|
||||
:label="slot.slot_key"
|
||||
:value="slot.slot_key"
|
||||
:disabled="formData.required_slots.includes(slot.slot_key)"
|
||||
>
|
||||
<div class="slot-option">
|
||||
<span class="slot-key-label">{{ slot.slot_key }}</span>
|
||||
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="field-hint">可选槽位用于增强检索效果,缺失时不阻塞流程</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="槽位优先级" prop="slot_priority">
|
||||
<div class="priority-editor">
|
||||
<div class="priority-header">
|
||||
<span class="priority-hint">拖拽调整槽位采集和追问的优先级顺序</span>
|
||||
<el-button
|
||||
v-if="formData.slot_priority && formData.slot_priority.length > 0"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="formData.slot_priority = null"
|
||||
>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="allSlotsForPriority.length > 0" class="priority-list-wrapper">
|
||||
<draggable
|
||||
v-model="allSlotsForPriority"
|
||||
item-key="slot_key"
|
||||
handle=".drag-handle"
|
||||
class="priority-list"
|
||||
ghost-class="ghost"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="priority-item" :class="{ 'in-priority': isInPriority(element.slot_key) }">
|
||||
<el-icon class="drag-handle"><Rank /></el-icon>
|
||||
<span class="priority-order">{{ index + 1 }}</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="formData.required_slots.includes(element.slot_key) ? 'danger' : 'info'"
|
||||
>
|
||||
{{ element.slot_key }}
|
||||
</el-tag>
|
||||
<el-tag size="small" type="warning" v-if="formData.required_slots.includes(element.slot_key)">
|
||||
必填
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<div v-else class="priority-empty">
|
||||
<el-text type="info">请先添加必填或可选槽位</el-text>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="追问策略" prop="ask_back_order">
|
||||
<el-radio-group v-model="formData.ask_back_order">
|
||||
<el-radio-button
|
||||
v-for="opt in ASK_BACK_ORDER_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="field-hint">
|
||||
{{ ASK_BACK_ORDER_OPTIONS.find(o => o.value === formData.ask_back_order)?.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Rank } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import draggable from 'vuedraggable'
|
||||
import { sceneSlotBundleApi } from '@/api/scene-slot-bundle'
|
||||
import { slotDefinitionApi } from '@/api/slot-definition'
|
||||
import {
|
||||
SCENE_SLOT_BUNDLE_STATUS_OPTIONS,
|
||||
ASK_BACK_ORDER_OPTIONS,
|
||||
type SceneSlotBundle,
|
||||
type SceneSlotBundleCreateRequest,
|
||||
type SceneSlotBundleUpdateRequest,
|
||||
type SceneSlotBundleStatus,
|
||||
type AskBackOrder,
|
||||
getStatusLabel,
|
||||
getAskBackOrderLabel,
|
||||
} from '@/types/scene-slot-bundle'
|
||||
import type { SlotDefinition } from '@/types/slot-definition'
|
||||
|
||||
const loading = ref(false)
|
||||
const bundles = ref<SceneSlotBundle[]>([])
|
||||
const availableSlots = ref<SlotDefinition[]>([])
|
||||
const filterStatus = ref<SceneSlotBundleStatus | ''>('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
scene_key: '',
|
||||
scene_name: '',
|
||||
description: '',
|
||||
required_slots: [] as string[],
|
||||
optional_slots: [] as string[],
|
||||
slot_priority: null as string[] | null,
|
||||
completion_threshold: 1.0,
|
||||
ask_back_order: 'priority' as AskBackOrder,
|
||||
status: 'draft' as SceneSlotBundleStatus,
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
scene_key: [
|
||||
{ required: true, message: '请输入场景标识', trigger: 'blur' },
|
||||
{ pattern: /^[a-z][a-z0-9_]*$/, message: '以小写字母开头,仅允许小写字母、数字、下划线', trigger: 'blur' }
|
||||
],
|
||||
scene_name: [{ required: true, message: '请输入场景名称', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const getStatusTagType = (status: SceneSlotBundleStatus): 'success' | 'warning' | 'info' => {
|
||||
const typeMap: Record<SceneSlotBundleStatus, 'success' | 'warning' | 'info'> = {
|
||||
'active': 'success',
|
||||
'draft': 'warning',
|
||||
'deprecated': 'info',
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const allSlotsForPriority = computed({
|
||||
get: () => {
|
||||
const allKeys = [...formData.required_slots, ...formData.optional_slots]
|
||||
const priority = formData.slot_priority || allKeys
|
||||
|
||||
const orderedSlots = priority
|
||||
.filter(key => allKeys.includes(key))
|
||||
.map(key => availableSlots.value.find(s => s.slot_key === key) || { slot_key: key })
|
||||
|
||||
const remainingKeys = allKeys.filter(key => !priority.includes(key))
|
||||
const remainingSlots = remainingKeys
|
||||
.map(key => availableSlots.value.find(s => s.slot_key === key) || { slot_key: key })
|
||||
|
||||
return [...orderedSlots, ...remainingSlots]
|
||||
},
|
||||
set: (value: { slot_key: string }[]) => {
|
||||
formData.slot_priority = value.map(s => s.slot_key)
|
||||
}
|
||||
})
|
||||
|
||||
const isInPriority = (slotKey: string): boolean => {
|
||||
if (!formData.slot_priority) return true
|
||||
return formData.slot_priority.includes(slotKey)
|
||||
}
|
||||
|
||||
const fetchBundles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await sceneSlotBundleApi.list(filterStatus.value || undefined)
|
||||
bundles.value = res || []
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '获取场景槽位包列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAvailableSlots = async () => {
|
||||
try {
|
||||
const res = await slotDefinitionApi.list()
|
||||
availableSlots.value = res || []
|
||||
} catch (error: any) {
|
||||
console.error('获取槽位定义失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
id: '',
|
||||
scene_key: '',
|
||||
scene_name: '',
|
||||
description: '',
|
||||
required_slots: [],
|
||||
optional_slots: [],
|
||||
slot_priority: null,
|
||||
completion_threshold: 1.0,
|
||||
ask_back_order: 'priority',
|
||||
status: 'draft',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (bundle: SceneSlotBundle) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: bundle.id,
|
||||
scene_key: bundle.scene_key,
|
||||
scene_name: bundle.scene_name,
|
||||
description: bundle.description || '',
|
||||
required_slots: [...bundle.required_slots],
|
||||
optional_slots: [...bundle.optional_slots],
|
||||
slot_priority: bundle.slot_priority ? [...bundle.slot_priority] : null,
|
||||
completion_threshold: bundle.completion_threshold,
|
||||
ask_back_order: bundle.ask_back_order,
|
||||
status: bundle.status,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (bundle: SceneSlotBundle) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除场景槽位包「${bundle.scene_name}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await sceneSlotBundleApi.delete(bundle.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchBundles()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: SceneSlotBundleCreateRequest | SceneSlotBundleUpdateRequest = {
|
||||
scene_name: formData.scene_name,
|
||||
description: formData.description || undefined,
|
||||
required_slots: formData.required_slots.length > 0 ? formData.required_slots : undefined,
|
||||
optional_slots: formData.optional_slots.length > 0 ? formData.optional_slots : undefined,
|
||||
slot_priority: formData.slot_priority || undefined,
|
||||
completion_threshold: formData.completion_threshold,
|
||||
ask_back_order: formData.ask_back_order,
|
||||
status: formData.status,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await sceneSlotBundleApi.update(formData.id, data as SceneSlotBundleUpdateRequest)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
const createData = data as SceneSlotBundleCreateRequest
|
||||
createData.scene_key = formData.scene_key
|
||||
await sceneSlotBundleApi.create(createData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchBundles()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(filterStatus, () => {
|
||||
fetchBundles()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchBundles()
|
||||
fetchAvailableSlots()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scene-slot-bundle-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bundle-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scene-key {
|
||||
padding: 2px 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.slot-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slot-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.status-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.status-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.slot-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.slot-key-label {
|
||||
font-weight: 500;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.priority-editor {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.priority-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.priority-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.priority-list-wrapper {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.priority-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.priority-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
cursor: move;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
&.in-priority {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.priority-order {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.priority-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -185,11 +185,23 @@
|
|||
v-model="element.expected_variables"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入变量名后回车添加"
|
||||
placeholder="选择期望提取的槽位"
|
||||
style="width: 100%"
|
||||
/>
|
||||
>
|
||||
<el-option
|
||||
v-for="slot in availableSlots"
|
||||
:key="slot.id"
|
||||
:label="`${slot.slot_key} (${slot.type})`"
|
||||
:value="slot.slot_key"
|
||||
>
|
||||
<div class="slot-option">
|
||||
<span class="slot-key">{{ slot.slot_key }}</span>
|
||||
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
|
||||
<el-tag size="small" v-if="slot.required" type="danger">必填</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="field-hint">期望变量必须引用已定义的槽位,步骤完成时会检查这些槽位是否已填充</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
|
|
@ -218,13 +230,100 @@
|
|||
v-model="element.expected_variables"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入变量名后回车添加"
|
||||
placeholder="选择期望提取的槽位"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="slot in availableSlots"
|
||||
:key="slot.id"
|
||||
:label="`${slot.slot_key} (${slot.type})`"
|
||||
:value="slot.slot_key"
|
||||
>
|
||||
<div class="slot-option">
|
||||
<span class="slot-key">{{ slot.slot_key }}</span>
|
||||
<el-tag size="small" type="info">{{ slot.type }}</el-tag>
|
||||
<el-tag size="small" v-if="slot.required" type="danger">必填</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="field-hint">期望变量必须引用已定义的槽位,步骤完成时会检查这些槽位是否已填充</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-divider content-position="left">知识库范围</el-divider>
|
||||
|
||||
<div class="kb-binding-section">
|
||||
<el-form-item label="允许的知识库">
|
||||
<el-select
|
||||
v-model="element.allowed_kb_ids"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择允许检索的知识库(为空则使用默认策略)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="kb in availableKnowledgeBases"
|
||||
:key="kb.id"
|
||||
:label="kb.name"
|
||||
:value="kb.id"
|
||||
>
|
||||
<div class="kb-option">
|
||||
<span>{{ kb.name }}</span>
|
||||
<el-tag size="small" :type="getKbTypeTagType(kb.kbType)">{{ getKbTypeLabel(kb.kbType) }}</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="field-hint">限制该步骤只能从选定的知识库中检索信息,为空则使用默认检索策略</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先知识库">
|
||||
<el-select
|
||||
v-model="element.preferred_kb_ids"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择优先检索的知识库"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="kb in availableKnowledgeBases"
|
||||
:key="kb.id"
|
||||
:label="kb.name"
|
||||
:value="kb.id"
|
||||
:disabled="element.allowed_kb_ids && element.allowed_kb_ids.length > 0 && !element.allowed_kb_ids.includes(kb.id)"
|
||||
>
|
||||
<div class="kb-option">
|
||||
<span>{{ kb.name }}</span>
|
||||
<el-tag size="small" :type="getKbTypeTagType(kb.kbType)">{{ getKbTypeLabel(kb.kbType) }}</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="field-hint">检索时优先搜索这些知识库</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="检索提示">
|
||||
<el-input
|
||||
v-model="element.kb_query_hint"
|
||||
placeholder="可选:描述本步骤的检索意图,帮助提高检索准确性"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="最大检索次数">
|
||||
<el-input-number
|
||||
v-model="element.max_kb_calls_per_step"
|
||||
:min="1"
|
||||
:max="5"
|
||||
placeholder="默认1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
|
|
@ -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<ScriptFlow[]>([])
|
||||
const availableSlots = ref<SlotDefinition[]>([])
|
||||
const availableKnowledgeBases = ref<KnowledgeBase[]>([])
|
||||
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<string, string> = {
|
||||
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%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,16 @@
|
|||
<el-table :data="slots" stripe style="width: 100%">
|
||||
<el-table-column prop="slot_key" label="槽位标识" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="slot-key-cell">
|
||||
<code class="slot-key">{{ row.slot_key }}</code>
|
||||
<span v-if="row.display_name" class="display-name">{{ row.display_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.description" class="slot-description">{{ row.description }}</span>
|
||||
<span v-else class="no-value">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="100">
|
||||
|
|
@ -38,11 +47,20 @@
|
|||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="extract_strategy" label="提取策略" width="120">
|
||||
<!-- [AC-MRS-07-UPGRADE] 策略链展示 -->
|
||||
<el-table-column prop="extract_strategies" label="提取策略链" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.extract_strategy" size="small">
|
||||
{{ getExtractStrategyLabel(row.extract_strategy) }}
|
||||
<div v-if="getEffectiveStrategies(row).length > 0" class="strategy-chain">
|
||||
<el-tag
|
||||
v-for="(strategy, idx) in getEffectiveStrategies(row)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
:type="getStrategyTagType(strategy)"
|
||||
class="strategy-tag"
|
||||
>
|
||||
{{ idx + 1 }}. {{ getExtractStrategyLabel(strategy) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="no-value">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
|
@ -80,7 +98,7 @@
|
|||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑槽位定义' : '新建槽位定义'"
|
||||
width="650px"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
|
|
@ -96,6 +114,25 @@
|
|||
<div class="field-hint">仅允许小写字母、数字、下划线</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="槽位名称" prop="display_name">
|
||||
<el-input
|
||||
v-model="formData.display_name"
|
||||
placeholder="如:当前年级"
|
||||
/>
|
||||
<div class="field-hint">给运营/教研看的中文名</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="槽位说明" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="解释这个槽位采集什么、用于哪里,如:用于课程分层推荐和知识库过滤"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="槽位类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%;">
|
||||
|
|
@ -108,21 +145,74 @@
|
|||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否必填" prop="required">
|
||||
<el-switch v-model="formData.required" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="提取策略" prop="extract_strategy">
|
||||
<el-select v-model="formData.extract_strategy" style="width: 100%;" clearable placeholder="选择提取策略">
|
||||
</el-row>
|
||||
|
||||
<!-- [AC-MRS-07-UPGRADE] 提取策略链配置 -->
|
||||
<el-form-item label="提取策略链" prop="extract_strategies">
|
||||
<div class="strategy-chain-editor">
|
||||
<div class="strategy-chain-header">
|
||||
<span class="chain-hint">按优先级排序,系统将依次尝试直到成功</span>
|
||||
<el-button
|
||||
v-if="formData.extract_strategies.length > 0"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="clearStrategies"
|
||||
>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.extract_strategies.length > 0" class="strategy-chain-list">
|
||||
<draggable
|
||||
v-model="formData.extract_strategies"
|
||||
:item-key="(item: ExtractStrategy) => item"
|
||||
handle=".drag-handle"
|
||||
class="strategy-list"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="strategy-item">
|
||||
<el-icon class="drag-handle"><Rank /></el-icon>
|
||||
<span class="strategy-order">{{ index + 1 }}</span>
|
||||
<el-tag size="small" :type="getStrategyTagType(element)">
|
||||
{{ getExtractStrategyLabel(element) }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
class="remove-btn"
|
||||
@click="removeStrategy(index)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<div v-else class="strategy-empty">
|
||||
<el-text type="info">暂无策略,请从下方添加</el-text>
|
||||
</div>
|
||||
|
||||
<div class="strategy-add-section">
|
||||
<el-select
|
||||
v-model="selectedStrategy"
|
||||
placeholder="选择要添加的策略"
|
||||
style="width: 200px;"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in EXTRACT_STRATEGY_OPTIONS"
|
||||
v-for="opt in availableStrategies"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
:value="opt.value as ExtractStrategy"
|
||||
:disabled="isStrategySelected(opt.value as ExtractStrategy)"
|
||||
>
|
||||
<div class="extract-option">
|
||||
<span>{{ opt.label }}</span>
|
||||
|
|
@ -130,9 +220,22 @@
|
|||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!selectedStrategy"
|
||||
@click="addStrategy"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="strategyError" class="strategy-error">
|
||||
<el-text type="danger">{{ strategyError }}</el-text>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="关联字段" prop="linked_field_id">
|
||||
<el-select
|
||||
v-model="formData.linked_field_id"
|
||||
|
|
@ -186,10 +289,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { Plus, Edit, Delete, Rank, Close } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import draggable from 'vuedraggable'
|
||||
import { slotDefinitionApi } from '@/api/slot-definition'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import {
|
||||
|
|
@ -199,7 +303,9 @@ import {
|
|||
type SlotDefinitionCreateRequest,
|
||||
type SlotDefinitionUpdateRequest,
|
||||
type SlotType,
|
||||
type ExtractStrategy
|
||||
type ExtractStrategy,
|
||||
validateExtractStrategies,
|
||||
getEffectiveStrategies
|
||||
} from '@/types/slot-definition'
|
||||
import type { MetadataFieldDefinition } from '@/types/metadata'
|
||||
|
||||
|
|
@ -212,12 +318,19 @@ const isEdit = ref(false)
|
|||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 策略链相关
|
||||
const selectedStrategy = ref<ExtractStrategy | ''>('')
|
||||
const strategyError = ref('')
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
slot_key: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
type: 'string' as SlotType,
|
||||
required: false,
|
||||
extract_strategy: '' as ExtractStrategy | '',
|
||||
// [AC-MRS-07-UPGRADE] 使用策略链数组
|
||||
extract_strategies: [] as ExtractStrategy[],
|
||||
validation_rule: '',
|
||||
ask_back_prompt: '',
|
||||
default_value: '',
|
||||
|
|
@ -233,6 +346,42 @@ const formRules: FormRules = {
|
|||
required: [{ required: true, message: '请选择是否必填', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 计算可用策略(未选择的)
|
||||
const availableStrategies = computed(() => {
|
||||
return EXTRACT_STRATEGY_OPTIONS
|
||||
})
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 检查策略是否已选择
|
||||
const isStrategySelected = (strategy: ExtractStrategy) => {
|
||||
return formData.extract_strategies.includes(strategy)
|
||||
}
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 添加策略到链
|
||||
const addStrategy = () => {
|
||||
if (!selectedStrategy.value) return
|
||||
|
||||
if (isStrategySelected(selectedStrategy.value)) {
|
||||
strategyError.value = '该策略已存在于链中'
|
||||
return
|
||||
}
|
||||
|
||||
formData.extract_strategies.push(selectedStrategy.value)
|
||||
selectedStrategy.value = ''
|
||||
strategyError.value = ''
|
||||
}
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 从链中移除策略
|
||||
const removeStrategy = (index: number) => {
|
||||
formData.extract_strategies.splice(index, 1)
|
||||
strategyError.value = ''
|
||||
}
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 清空策略链
|
||||
const clearStrategies = () => {
|
||||
formData.extract_strategies = []
|
||||
strategyError.value = ''
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: SlotType) => {
|
||||
return SLOT_TYPE_OPTIONS.find(o => o.value === type)?.label || type
|
||||
}
|
||||
|
|
@ -241,6 +390,16 @@ const getExtractStrategyLabel = (strategy: ExtractStrategy) => {
|
|||
return EXTRACT_STRATEGY_OPTIONS.find(o => o.value === strategy)?.label || strategy
|
||||
}
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 获取策略标签类型
|
||||
const getStrategyTagType = (strategy: ExtractStrategy): any => {
|
||||
const typeMap: Record<ExtractStrategy, any> = {
|
||||
'rule': 'success',
|
||||
'llm': 'warning',
|
||||
'user_input': 'info'
|
||||
}
|
||||
return typeMap[strategy] || 'info'
|
||||
}
|
||||
|
||||
const fetchSlots = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -256,7 +415,8 @@ const fetchSlots = async () => {
|
|||
const fetchSlotFields = async () => {
|
||||
try {
|
||||
const res = await metadataSchemaApi.getByRole('slot', true)
|
||||
slotFields.value = res.items || []
|
||||
// 后端返回数组格式,兼容处理
|
||||
slotFields.value = Array.isArray(res) ? res : (res.items || [])
|
||||
} catch (error: any) {
|
||||
console.error('获取槽位角色字段失败', error)
|
||||
}
|
||||
|
|
@ -267,14 +427,19 @@ const handleCreate = () => {
|
|||
Object.assign(formData, {
|
||||
id: '',
|
||||
slot_key: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
type: 'string',
|
||||
required: false,
|
||||
extract_strategy: '',
|
||||
// [AC-MRS-07-UPGRADE] 初始化为空数组
|
||||
extract_strategies: [],
|
||||
validation_rule: '',
|
||||
ask_back_prompt: '',
|
||||
default_value: '',
|
||||
linked_field_id: ''
|
||||
})
|
||||
selectedStrategy.value = ''
|
||||
strategyError.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
|
@ -283,14 +448,19 @@ const handleEdit = (slot: SlotDefinition) => {
|
|||
Object.assign(formData, {
|
||||
id: slot.id,
|
||||
slot_key: slot.slot_key,
|
||||
display_name: slot.display_name || '',
|
||||
description: slot.description || '',
|
||||
type: slot.type,
|
||||
required: slot.required,
|
||||
extract_strategy: slot.extract_strategy || '',
|
||||
// [AC-MRS-07-UPGRADE] 使用有效的策略链
|
||||
extract_strategies: [...getEffectiveStrategies(slot)],
|
||||
validation_rule: slot.validation_rule || '',
|
||||
ask_back_prompt: slot.ask_back_prompt || '',
|
||||
default_value: slot.default_value ?? '',
|
||||
linked_field_id: slot.linked_field_id || ''
|
||||
})
|
||||
selectedStrategy.value = ''
|
||||
strategyError.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
|
@ -317,13 +487,25 @@ const handleSubmit = async () => {
|
|||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 校验策略链
|
||||
if (formData.extract_strategies.length > 0) {
|
||||
const validation = validateExtractStrategies(formData.extract_strategies)
|
||||
if (!validation.valid) {
|
||||
strategyError.value = validation.error || '策略链校验失败'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: SlotDefinitionCreateRequest | SlotDefinitionUpdateRequest = {
|
||||
slot_key: formData.slot_key,
|
||||
display_name: formData.display_name || undefined,
|
||||
description: formData.description || undefined,
|
||||
type: formData.type,
|
||||
required: formData.required,
|
||||
extract_strategy: formData.extract_strategy || undefined,
|
||||
// [AC-MRS-07-UPGRADE] 提交策略链
|
||||
extract_strategies: formData.extract_strategies.length > 0 ? formData.extract_strategies : undefined,
|
||||
validation_rule: formData.validation_rule || undefined,
|
||||
ask_back_prompt: formData.ask_back_prompt || undefined,
|
||||
linked_field_id: formData.linked_field_id || undefined
|
||||
|
|
@ -409,6 +591,27 @@ onMounted(() => {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.slot-key-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.slot-description {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
|
@ -439,6 +642,91 @@ onMounted(() => {
|
|||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
// [AC-MRS-07-UPGRADE] 策略链样式
|
||||
.strategy-chain {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.strategy-tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.strategy-chain-editor {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.strategy-chain-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chain-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.strategy-chain-list {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.strategy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.strategy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.strategy-order {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.strategy-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.strategy-add-section {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.strategy-error {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.extract-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -410,6 +410,24 @@
|
|||
<p>配置知识库、意图规则等的动态元数据字段定义。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/slot-definitions')">
|
||||
<div class="help-icon success">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>槽位定义</h4>
|
||||
<p>定义槽位类型、提取策略、校验规则和追问提示。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/scene-slot-bundles')">
|
||||
<div class="help-icon warning">
|
||||
<el-icon><Collection /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>场景槽位包</h4>
|
||||
<p>配置场景与槽位的映射关系,定义每个场景需要采集的槽位。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
|
@ -420,7 +438,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine, Aim, DocumentCopy, Share, Warning, Setting } from '@element-plus/icons-vue'
|
||||
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine, Aim, DocumentCopy, Share, Warning, Setting, Grid, Collection } from '@element-plus/icons-vue'
|
||||
import { getDashboardStats, type DashboardStats } from '@/api/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@
|
|||
<el-icon><Upload /></el-icon>
|
||||
上传文档
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleBatchUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
批量上传
|
||||
</el-button>
|
||||
<el-button type="warning" @click="handleJsonUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
JSON上传
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -85,6 +93,8 @@
|
|||
</el-dialog>
|
||||
|
||||
<input ref="fileInput" type="file" style="display: none" @change="handleFileChange" />
|
||||
<input ref="batchFileInput" type="file" accept=".zip" style="display: none" @change="handleBatchFileChange" />
|
||||
<input ref="jsonFileInput" type="file" accept=".json,.jsonl,.txt" style="display: none" @change="handleJsonFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -92,7 +102,7 @@
|
|||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload, Document, View, Delete } from '@element-plus/icons-vue'
|
||||
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
|
||||
import { uploadDocument, listDocuments, getIndexJob, deleteDocument, batchUploadDocuments, jsonBatchUpload } from '@/api/kb'
|
||||
|
||||
interface DocumentItem {
|
||||
docId: string
|
||||
|
|
@ -242,11 +252,21 @@ onUnmounted(() => {
|
|||
})
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const batchFileInput = ref<HTMLInputElement | null>(null)
|
||||
const jsonFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleBatchUploadClick = () => {
|
||||
batchFileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleJsonUploadClick = () => {
|
||||
jsonFileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
|
@ -281,6 +301,111 @@ const handleFileChange = async (event: Event) => {
|
|||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchFileChange = 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 格式的压缩包')
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('kb_id', 'kb_default')
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res: any = await batchUploadDocuments(formData)
|
||||
|
||||
const { total, succeeded, failed, results } = res
|
||||
|
||||
if (succeeded > 0) {
|
||||
ElMessage.success(`批量上传成功!成功: ${succeeded}, 失败: ${failed}, 总计: ${total}`)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'created') {
|
||||
const newDoc: DocumentItem = {
|
||||
docId: result.docId,
|
||||
name: result.fileName,
|
||||
status: 'pending',
|
||||
jobId: result.jobId,
|
||||
createTime: new Date().toLocaleString('zh-CN')
|
||||
}
|
||||
tableData.value.unshift(newDoc)
|
||||
startPolling(result.jobId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('批量上传失败,请检查压缩包格式')
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
const failedItems = results.filter((r: any) => r.status === 'failed')
|
||||
console.error('Failed uploads:', failedItems)
|
||||
}
|
||||
|
||||
fetchDocuments()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '批量上传失败')
|
||||
console.error('Batch upload error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleJsonFileChange = 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 格式的文件')
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const kbId = '75c465fe-277d-455d-a30b-4b168adcc03b'
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('tenant_id', 'szmp@ash@2026')
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
ElMessage.info('正在上传 JSON 文件...')
|
||||
|
||||
const res: any = await jsonBatchUpload(kbId, formData)
|
||||
|
||||
if (res.success) {
|
||||
ElMessage.success(`JSON 批量上传成功!成功: ${res.succeeded}, 失败: ${res.failed}`)
|
||||
|
||||
if (res.valid_metadata_fields) {
|
||||
console.log('有效的元数据字段:', res.valid_metadata_fields)
|
||||
}
|
||||
|
||||
if (res.failed > 0) {
|
||||
const failedItems = res.results.filter((r: any) => !r.success)
|
||||
console.warn('失败的项目:', failedItems)
|
||||
}
|
||||
|
||||
fetchDocuments()
|
||||
} else {
|
||||
ElMessage.error('JSON 批量上传失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || 'JSON 批量上传失败')
|
||||
console.error('JSON batch upload error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -324,6 +449,7 @@ const handleFileChange = async (event: Event) => {
|
|||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
|
|
|
|||
Loading…
Reference in New Issue