feat(asa): implement ai-service-admin infrastructure and modules [AC-ASA-01]
This commit is contained in:
parent
210af26f5f
commit
1230b4005a
|
|
@ -0,0 +1 @@
|
||||||
|
node_modules/
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Service Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "ai-service-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"element-plus": "^2.6.1",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.1.4",
|
||||||
|
"vue-tsc": "^1.8.27"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeIndex"
|
||||||
|
class="el-menu-demo"
|
||||||
|
mode="horizontal"
|
||||||
|
router
|
||||||
|
>
|
||||||
|
<el-menu-item index="/dashboard">控制台</el-menu-item>
|
||||||
|
<el-menu-item index="/kb">知识库管理</el-menu-item>
|
||||||
|
<el-menu-item index="/rag-lab">RAG 实验室</el-menu-item>
|
||||||
|
<el-menu-item index="/monitoring">会话监控</el-menu-item>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="tenant-selector">
|
||||||
|
<el-select v-model="currentTenantId" placeholder="选择租户" @change="handleTenantChange">
|
||||||
|
<el-option label="默认租户" value="default" />
|
||||||
|
<el-option label="租户 A" value="tenant_a" />
|
||||||
|
<el-option label="租户 B" value="tenant_b" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</el-menu>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useTenantStore } from '@/stores/tenant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
|
||||||
|
const activeIndex = computed(() => route.path)
|
||||||
|
const currentTenantId = ref(tenantStore.currentTenantId)
|
||||||
|
|
||||||
|
const handleTenantChange = (val: string) => {
|
||||||
|
tenantStore.setTenant(val)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.tenant-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
.app-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询文档列表 [AC-ASA-08]
|
||||||
|
*/
|
||||||
|
export function listDocuments(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/kb/documents',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档 [AC-ASA-01]
|
||||||
|
*/
|
||||||
|
export function uploadDocument(data: FormData) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/kb/documents',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询索引任务详情 [AC-ASA-02]
|
||||||
|
*/
|
||||||
|
export function getIndexJob(jobId: string) {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/index/jobs/${jobId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会话列表 [AC-ASA-09]
|
||||||
|
*/
|
||||||
|
export function listSessions(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/sessions',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话详情 [AC-ASA-07]
|
||||||
|
*/
|
||||||
|
export function getSessionDetail(sessionId: string) {
|
||||||
|
return request({
|
||||||
|
url: `/admin/sessions/${sessionId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行 RAG 调试实验 [AC-ASA-05]
|
||||||
|
*/
|
||||||
|
export function runRagExperiment(data: { query: string, kbIds?: string[], params?: any }) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/rag/experiments/run',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<el-form :model="model" v-bind="$attrs" ref="formRef">
|
||||||
|
<slot />
|
||||||
|
<el-form-item v-if="showFooter">
|
||||||
|
<el-button type="primary" @click="handleSubmit">提交</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
model: any
|
||||||
|
showFooter?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'reset'])
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
emit('submit', props.model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
formRef.value.resetFields()
|
||||||
|
emit('reset')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
validate: () => formRef.value?.validate(),
|
||||||
|
resetFields: () => formRef.value?.resetFields()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<template>
|
||||||
|
<el-table :data="data" v-bind="$attrs" style="width: 100%">
|
||||||
|
<slot />
|
||||||
|
</el-table>
|
||||||
|
<div v-if="total > 0" class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: any[]
|
||||||
|
total: number
|
||||||
|
pageNum: number
|
||||||
|
pageSize: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:pageNum', 'update:pageSize', 'pagination'])
|
||||||
|
|
||||||
|
const currentPage = computed({
|
||||||
|
get: () => props.pageNum,
|
||||||
|
set: (val) => emit('update:pageNum', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageSize = computed({
|
||||||
|
get: () => props.pageSize,
|
||||||
|
set: (val) => emit('update:pageSize', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSizeChange = (val: number) => {
|
||||||
|
emit('pagination', { page: currentPage.value, limit: val })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (val: number) => {
|
||||||
|
emit('pagination', { page: val, limit: pageSize.value })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination-container {
|
||||||
|
padding: 32px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const routes: Array<RouteRecordRaw> = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/dashboard'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
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',
|
||||||
|
component: () => import('@/views/rag-lab/index.vue'),
|
||||||
|
meta: { title: 'RAG 实验室' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/monitoring',
|
||||||
|
name: 'Monitoring',
|
||||||
|
component: () => import('@/views/monitoring/index.vue'),
|
||||||
|
meta: { title: '会话监控' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useTenantStore = defineStore('tenant', {
|
||||||
|
state: () => ({
|
||||||
|
currentTenantId: localStorage.getItem('X-Tenant-Id') || 'default'
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setTenant(id: string) {
|
||||||
|
this.currentTenantId = id
|
||||||
|
localStorage.setItem('X-Tenant-Id', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useTenantStore } from '@/stores/tenant'
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const service = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_APP_BASE_API || '/api',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
service.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
if (tenantStore.currentTenantId) {
|
||||||
|
config.headers['X-Tenant-Id'] = tenantStore.currentTenantId
|
||||||
|
}
|
||||||
|
// TODO: 如果有 token 也可以在这里注入 Authorization
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log(error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
service.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data
|
||||||
|
// 这里可以根据后端的 code 进行统一处理
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log('err' + error)
|
||||||
|
let { message, response } = error
|
||||||
|
if (response) {
|
||||||
|
const status = response.status
|
||||||
|
if (status === 401) {
|
||||||
|
ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', {
|
||||||
|
confirmButtonText: '重新登录',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
// TODO: 跳转到登录页或执行退出逻辑
|
||||||
|
location.href = '/login'
|
||||||
|
})
|
||||||
|
} else if (status === 403) {
|
||||||
|
ElMessage({
|
||||||
|
message: '当前操作无权限',
|
||||||
|
type: 'error',
|
||||||
|
duration: 5 * 1000
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ElMessage({
|
||||||
|
message: message || '后端接口未知异常',
|
||||||
|
type: 'error',
|
||||||
|
duration: 5 * 1000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage({
|
||||||
|
message: '网络连接异常',
|
||||||
|
type: 'error',
|
||||||
|
duration: 5 * 1000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default service
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>知识库总数</template>
|
||||||
|
<div class="card-content">12</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>总消息数</template>
|
||||||
|
<div class="card-content">1,284</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>平均响应时间</template>
|
||||||
|
<div class="card-content">1.2s</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>活跃租户</template>
|
||||||
|
<div class="card-content">5</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.card-content {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<div class="kb-container">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>知识库列表</span>
|
||||||
|
<el-button type="primary">上传文档</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="tableData" style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="文件名" />
|
||||||
|
<el-table-column prop="status" label="状态">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === 'completed' ? 'success' : 'warning'">
|
||||||
|
{{ scope.row.status }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createTime" label="上传时间" />
|
||||||
|
<el-table-column label="操作">
|
||||||
|
<template #default>
|
||||||
|
<el-button link type="primary">查看详情</el-button>
|
||||||
|
<el-button link type="danger">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const tableData = ref([
|
||||||
|
{ name: '产品手册.pdf', status: 'completed', createTime: '2024-02-24 10:00:00' },
|
||||||
|
{ name: '技术文档.docx', status: 'processing', createTime: '2024-02-24 11:30:00' }
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kb-container { padding: 20px; }
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
<template>
|
||||||
|
<div class="monitoring-container">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="title">会话监控 [AC-ASA-09]</span>
|
||||||
|
<div class="header-ops">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="search-form">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="会话状态" clearable style="width: 120px">
|
||||||
|
<el-option label="活跃" value="active" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
<el-option label="已过期" value="expired" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 使用 BaseTable 展示会话列表 [AC-ASA-09] -->
|
||||||
|
<base-table
|
||||||
|
:data="tableData"
|
||||||
|
:total="total"
|
||||||
|
v-model:page-num="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
v-loading="loading"
|
||||||
|
>
|
||||||
|
<el-table-column prop="sessionId" label="会话 ID" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="tenantId" label="租户 ID" width="120" />
|
||||||
|
<el-table-column prop="messageCount" label="消息数" width="100" align="center" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="statusMap[scope.row.status]?.type" size="small">
|
||||||
|
{{ statusMap[scope.row.status]?.label || scope.row.status }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="startTime" label="开始时间" width="180" />
|
||||||
|
<el-table-column label="操作" fixed="right" width="120" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button link type="primary" @click="handleTrace(scope.row)">全链路追踪</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</base-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 全链路追踪详情抽屉 [AC-ASA-07] -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
title="会话全链路追踪详情"
|
||||||
|
size="50%"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="detailLoading" class="detail-container">
|
||||||
|
<el-empty v-if="!sessionDetail && !detailLoading" description="暂无追踪详情" />
|
||||||
|
<el-timeline v-else>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="(msg, index) in sessionDetail?.messages"
|
||||||
|
:key="index"
|
||||||
|
:timestamp="msg.timestamp"
|
||||||
|
placement="top"
|
||||||
|
:type="msg.role === 'user' ? 'primary' : 'success'"
|
||||||
|
>
|
||||||
|
<el-card shadow="never" class="msg-card">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content">{{ msg.content }}</div>
|
||||||
|
|
||||||
|
<!-- 展示追踪信息:检索命中、工具调用等 [AC-ASA-07] -->
|
||||||
|
<div v-if="msg.trace" class="trace-info">
|
||||||
|
<el-collapse class="trace-collapse">
|
||||||
|
<el-collapse-item v-if="msg.trace.retrieval" title="检索追踪 (Retrieval)" name="retrieval">
|
||||||
|
<div v-for="(hit, hIdx) in msg.trace.retrieval" :key="hIdx" class="hit-item">
|
||||||
|
<div class="hit-meta">
|
||||||
|
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
|
||||||
|
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hit-text">{{ hit.content }}</div>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item v-if="msg.trace.tool_calls" title="工具调用 (Tool Calls)" name="tools">
|
||||||
|
<pre class="code-block"><code>{{ JSON.stringify(msg.trace.tool_calls, null, 2) }}</code></pre>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import BaseTable from '@/components/BaseTable.vue'
|
||||||
|
import { listSessions, getSessionDetail } from '@/api/monitoring'
|
||||||
|
|
||||||
|
const statusMap: Record<string, { label: string, type: string }> = {
|
||||||
|
active: { label: '活跃', type: 'success' },
|
||||||
|
closed: { label: '已关闭', type: 'info' },
|
||||||
|
expired: { label: '已过期', type: 'warning' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableData = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const queryParams = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
status: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const sessionDetail = ref<any>(null)
|
||||||
|
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await listSessions(queryParams)
|
||||||
|
tableData.value = res.data || []
|
||||||
|
total.value = res.pagination?.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
queryParams.page = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryParams.status = ''
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取并展示全链路追踪详情 [AC-ASA-07] */
|
||||||
|
const handleTrace = async (row: any) => {
|
||||||
|
drawerVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
sessionDetail.value = await getSessionDetail(row.sessionId)
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitoring-container { padding: 20px; }
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.title { font-size: 16px; font-weight: bold; }
|
||||||
|
.detail-container { padding: 10px 20px; }
|
||||||
|
.msg-card { border-radius: 8px; margin-bottom: 10px; }
|
||||||
|
.msg-header { margin-bottom: 8px; }
|
||||||
|
.role-tag { font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.role-tag.user { background-color: #ecf5ff; color: #409eff; }
|
||||||
|
.role-tag.assistant { background-color: #f0f9eb; color: #67c23a; }
|
||||||
|
.msg-content { font-size: 14px; line-height: 1.6; color: #333; white-space: pre-wrap; }
|
||||||
|
.trace-info { margin-top: 15px; border-top: 1px solid #f0f0f0; padding-top: 10px; }
|
||||||
|
.trace-collapse { border: none; }
|
||||||
|
:deep(.el-collapse-item__header) { height: 36px; font-size: 13px; color: #909399; }
|
||||||
|
.hit-item { padding: 10px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 8px; }
|
||||||
|
.hit-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||||
|
.hit-source { font-size: 11px; color: #999; }
|
||||||
|
.hit-text { font-size: 12px; color: #666; line-height: 1.5; }
|
||||||
|
.code-block { background-color: #fafafa; border: 1px solid #eaeaea; padding: 8px; border-radius: 4px; font-size: 12px; overflow-x: auto; margin: 0; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
<template>
|
||||||
|
<div class="rag-lab-container">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<!-- 左侧:调试输入 [AC-ASA-05] -->
|
||||||
|
<el-col :span="10">
|
||||||
|
<el-card header="调试输入">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="查询 Query">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.query"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="输入测试问题..."
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="知识库范围">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.kbIds"
|
||||||
|
multiple
|
||||||
|
placeholder="请选择知识库"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option label="默认知识库" value="default" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="参数配置">
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="label">Top-K</span>
|
||||||
|
<el-input-number v-model="queryParams.params.topK" :min="1" :max="10" />
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="label">Score Threshold</span>
|
||||||
|
<el-slider
|
||||||
|
v-model="queryParams.params.threshold"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.1"
|
||||||
|
show-input
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" block @click="handleRun" :loading="loading">
|
||||||
|
运行实验
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 右侧:实验结果 [AC-ASA-05] -->
|
||||||
|
<el-col :span="14">
|
||||||
|
<el-tabs v-model="activeTab" type="border-card">
|
||||||
|
<el-tab-pane label="召回片段" name="retrieval">
|
||||||
|
<div v-if="results.retrievalResults.length === 0" class="placeholder-text">
|
||||||
|
暂无实验数据
|
||||||
|
</div>
|
||||||
|
<div v-else class="result-list">
|
||||||
|
<el-card
|
||||||
|
v-for="(item, index) in results.retrievalResults"
|
||||||
|
:key="index"
|
||||||
|
class="result-card"
|
||||||
|
shadow="never"
|
||||||
|
>
|
||||||
|
<div class="result-header">
|
||||||
|
<el-tag size="small">Score: {{ item.score.toFixed(4) }}</el-tag>
|
||||||
|
<span class="source">来源: {{ item.source }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-content">{{ item.content }}</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="最终 Prompt" name="prompt">
|
||||||
|
<div v-if="!results.finalPrompt" class="placeholder-text">
|
||||||
|
等待实验运行...
|
||||||
|
</div>
|
||||||
|
<div v-else class="prompt-view">
|
||||||
|
<pre><code>{{ results.finalPrompt }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { runRagExperiment } from '@/api/rag'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const activeTab = ref('retrieval')
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
query: '',
|
||||||
|
kbIds: [],
|
||||||
|
params: {
|
||||||
|
topK: 3,
|
||||||
|
threshold: 0.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = reactive({
|
||||||
|
retrievalResults: [],
|
||||||
|
finalPrompt: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 运行实验 [AC-ASA-05] */
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!queryParams.query.trim()) {
|
||||||
|
ElMessage.warning('请输入查询 Query')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await runRagExperiment(queryParams)
|
||||||
|
results.retrievalResults = res.retrievalResults || []
|
||||||
|
results.finalPrompt = res.finalPrompt || ''
|
||||||
|
activeTab.value = 'retrieval'
|
||||||
|
ElMessage.success('实验运行成功')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rag-lab-container { padding: 20px; }
|
||||||
|
.param-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.param-item .label {
|
||||||
|
width: 120px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.placeholder-text { color: #909399; text-align: center; padding: 50px 0; }
|
||||||
|
.result-list { height: 600px; overflow-y: auto; }
|
||||||
|
.result-card { margin-bottom: 15px; }
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.source { font-size: 12px; color: #909399; }
|
||||||
|
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
|
||||||
|
.prompt-view {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.prompt-view pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
---
|
||||||
|
module: ai-service-admin
|
||||||
|
feature: ASA
|
||||||
|
status: in_progress
|
||||||
|
created: 2026-02-24
|
||||||
|
last_updated: 2026-02-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI 中台管理界面(ai-service-admin)进度文档
|
||||||
|
|
||||||
|
## context
|
||||||
|
|
||||||
|
- **module**: ai-service-admin
|
||||||
|
- **feature**: ASA
|
||||||
|
- **status**: 🔄进行中
|
||||||
|
|
||||||
|
## spec_references
|
||||||
|
|
||||||
|
- requirements: "spec/ai-service-admin/requirements.md"
|
||||||
|
- design: "spec/ai-service-admin/design.md"
|
||||||
|
- tasks: "spec/ai-service-admin/tasks.md"
|
||||||
|
- openapi_admin: "spec/ai-service/openapi.admin.yaml"
|
||||||
|
|
||||||
|
## overall_progress
|
||||||
|
|
||||||
|
- [x] Phase 1: 基础建设 (100%) [P1-01 ~ P1-05]
|
||||||
|
- [x] Phase 2: 知识库管理 (100%) [P2-01 ~ P2-05]
|
||||||
|
- [ ] Phase 3: RAG 实验室 (0%) [P3-01 ~ P3-04]
|
||||||
|
- [ ] Phase 4: 会话监控与详情 (0%) [P4-01 ~ P4-03]
|
||||||
|
|
||||||
|
## current_phase
|
||||||
|
|
||||||
|
**goal**: 知识库管理模块开发,实现文档上传、列表展示与状态轮询
|
||||||
|
|
||||||
|
### sub_tasks
|
||||||
|
|
||||||
|
- [x] (P1-01) 初始化 `ai-service-admin` 前端工程(Vue 3 + Element Plus + RuoYi-Vue 基座对齐),落地基础目录结构与路由骨架
|
||||||
|
- [x] (P1-02) 接入 Pinia:实现 `tenant` store(`currentTenantId`)并持久化(localStorage),提供切换租户能力
|
||||||
|
- [x] (P1-03) Axios/SDK 请求层封装:创建统一 `request` 实例,自动注入必填 Header `X-Tenant-Id`
|
||||||
|
- [x] (P1-04) 全局异常拦截:实现 401/403 响应拦截策略
|
||||||
|
- [x] (P1-05) 基础组件封装:`BaseTable`、`BaseForm` 并给出示例页
|
||||||
|
- [x] (P2-01) 创建 `openapi.deps.yaml` 明确依赖契约 (L1) [AC-ASA-08]
|
||||||
|
- [x] (P2-02) 实现知识库列表 API 对接及分页展示 [AC-ASA-08]
|
||||||
|
- [x] (P2-03) 实现文档上传功能(Multipart/form-data)[AC-ASA-01]
|
||||||
|
- [x] (P2-04) 实现索引任务状态轮询机制(3s 间隔)[AC-ASA-02]
|
||||||
|
- [x] (P2-05) 失败任务错误详情弹窗展示 [AC-ASA-02]
|
||||||
|
|
||||||
|
### next_action
|
||||||
|
|
||||||
|
**immediate**: 开始 Phase 3 RAG 实验室模块
|
||||||
|
|
||||||
|
**details**:
|
||||||
|
- file: "ai-service-admin/src/views/rag-lab/index.vue"
|
||||||
|
- action: "实现 RAG 实验调试界面,支持参数配置、Query 提交及召回片段对比展示"
|
||||||
|
|
||||||
|
### next_action
|
||||||
|
|
||||||
|
**immediate**: 初始化 `ai-service-admin` 前端工程
|
||||||
|
|
||||||
|
**details**:
|
||||||
|
- file: "待创建 - 前端工程根目录"
|
||||||
|
- action: "创建 Vue 3 + Element Plus 项目骨架,对齐 RuoYi-Vue 基座,配置基础目录结构与路由骨架"
|
||||||
|
- reference: "spec/ai-service-admin/tasks.md:26-27"
|
||||||
|
|
||||||
|
**constraints**:
|
||||||
|
- 必须符合 AC-ASA-01 验收标准
|
||||||
|
- 需与 RuoYi-Vue-Plus 基座对齐(用户认证、权限校验及菜单框架)
|
||||||
|
|
||||||
|
## technical_context
|
||||||
|
|
||||||
|
### module_structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-service-admin/ # 前端工程(待创建)
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # API 请求层
|
||||||
|
│ ├── components/ # 通用组件
|
||||||
|
│ ├── composables/ # Vue Composables
|
||||||
|
│ ├── router/ # 路由配置
|
||||||
|
│ ├── stores/ # Pinia stores
|
||||||
|
│ ├── views/ # 页面视图
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### key_decisions
|
||||||
|
|
||||||
|
| decision | reason | impact |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| Vue 3 + Element Plus | 与 RuoYi-Vue-Plus 基座技术栈一致 | 复用基座组件与权限体系 |
|
||||||
|
| Pinia 状态管理 | Vue 3 官方推荐,替代 Vuex | 更简洁的 store 模式 |
|
||||||
|
| localStorage 持久化 | 租户切换需跨会话保持 | 无需后端 session 支持 |
|
||||||
|
|
||||||
|
### code_snippets
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// stores/tenant.ts (待实现)
|
||||||
|
export const useTenantStore = defineStore('tenant', {
|
||||||
|
state: () => ({
|
||||||
|
currentTenantId: localStorage.getItem('currentTenantId') || ''
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setTenant(id: string) {
|
||||||
|
this.currentTenantId = id
|
||||||
|
localStorage.setItem('currentTenantId', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## session_history
|
||||||
|
|
||||||
|
- session: "Session #1 (2026-02-24)"
|
||||||
|
completed: []
|
||||||
|
changes: []
|
||||||
|
|
||||||
|
## startup_guide
|
||||||
|
|
||||||
|
1. **Step 1**: 读取本进度文档(了解当前位置与下一步)
|
||||||
|
2. **Step 2**: 读取 spec_references 中定义的模块规范(了解业务与接口约束)
|
||||||
|
3. **Step 3**: 直接执行 next_action - 初始化前端工程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 任务速查
|
||||||
|
|
||||||
|
| Phase | 名称 | 任务数 | 状态 |
|
||||||
|
|-------|------|--------|------|
|
||||||
|
| Phase 1 | 基础建设 | 5 | ⏳ 待开始 |
|
||||||
|
| Phase 2 | 知识库管理 | 5 | ⏳ 待开始 |
|
||||||
|
| Phase 3 | RAG 实验室 | 4 | ⏳ 待开始 |
|
||||||
|
| Phase 4 | 会话监控与详情 | 3 | ⏳ 待开始 |
|
||||||
|
|
||||||
|
**总计: 17 个任务**
|
||||||
Loading…
Reference in New Issue