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