feat: AI 助手模型管理(CRUD、会话绑定、自动化验收)

- Flyway V6:模型表、默认种子、ai_chat_session.model_id 回填
- 管理端 /api/admin/ai/models;用户端选模与新会话绑定
- 禁用模型后会话只读(5009);chat 按会话 modelCode 调 DashScope
- 前端 AiModels 页、助手模型选择器与 localStorage
- 单测、AiModelManagementApiIT、Playwright E2E;e2e Profile 关闭注解限流
- 技术实施文档与验收报告;移除过期冲突分析文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nojava
2026-05-24 03:36:31 +08:00
parent 9cf811d8c1
commit 6c8b31e38a
58 changed files with 2693 additions and 556 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import request from '@/utils/request'
export function createAiChatSession() {
return request.post('/api/app/ai/sessions')
export function createAiChatSession(data) {
return request.post('/api/app/ai/sessions', data ?? {})
}
export function listAiChatSessions(params) {
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function listAdminAiModels() {
return request.get('/api/admin/ai/models')
}
export function getAdminAiModel(id) {
return request.get(`/api/admin/ai/models/${id}`)
}
export function createAdminAiModel(data) {
return request.post('/api/admin/ai/models', data)
}
export function updateAdminAiModel(id, data) {
return request.put(`/api/admin/ai/models/${id}`, data)
}
export function deleteAdminAiModel(id) {
return request.delete(`/api/admin/ai/models/${id}`)
}
export function listAppAiModels() {
return request.get('/api/app/ai/models')
}
+49 -4
View File
@@ -2,7 +2,27 @@
<section class="chat-panel">
<div class="chat-header">
<span class="chat-title">{{ currentTitle }}</span>
<span v-if="isLoggedIn && currentSessionId" class="round-badge">{{ roundCount }}/{{ maxRounds }}</span>
<div class="chat-header-right">
<el-select
v-if="isLoggedIn"
:model-value="selectedModelId"
class="model-select"
size="small"
placeholder="选择模型"
@update:model-value="$emit('update:selected-model-id', $event)"
>
<el-option
v-for="m in availableModels"
:key="m.id"
:label="m.displayName"
:value="m.id"
>
<span>{{ m.displayName }}</span>
<span v-if="m.description" class="model-option-desc">{{ m.description }}</span>
</el-option>
</el-select>
<span v-if="isLoggedIn && currentSessionId" class="round-badge">{{ roundCount }}/{{ maxRounds }}</span>
</div>
</div>
<div ref="messagesRef" class="messages">
@@ -23,7 +43,10 @@
</div>
</div>
<div v-if="readOnly" class="readonly-banner">
<div v-if="readOnlyReason === 'MODEL_DISABLED'" class="readonly-banner model-disabled-banner">
该会话所用模型已停用额度不足仅可查看历史请切换模型后新建会话
</div>
<div v-else-if="readOnly" class="readonly-banner">
当前会话已满 {{ maxRounds }} 请点击左侧新会话开始新的对话
</div>
@@ -58,12 +81,15 @@ const props = defineProps({
roundCount: { type: Number, default: 0 },
maxRounds: { type: Number, default: 5 },
readOnly: { type: Boolean, default: false },
readOnlyReason: { type: String, default: null },
sending: { type: Boolean, default: false },
currentSessionId: { type: [Number, String], default: null },
isLoggedIn: { type: Boolean, default: false },
availableModels: { type: Array, default: () => [] },
selectedModelId: { type: [Number, String], default: null },
})
const emit = defineEmits(['send'])
const emit = defineEmits(['send', 'update:selected-model-id'])
const inputText = ref('')
const messagesRef = ref(null)
@@ -77,7 +103,6 @@ const inputDisabled = computed(() => {
const sendDisabled = computed(() => {
if (inputDisabled.value) return true
if (props.roundCount >= props.maxRounds) return true
return !inputText.value.trim()
})
@@ -122,11 +147,26 @@ watch(() => props.sending, () => { if (props.sending) scrollToBottom() })
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color-light);
gap: var(--spacing-md);
}
.chat-header-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-shrink: 0;
}
.chat-title {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
}
.model-select {
width: 200px;
}
.model-option-desc {
color: var(--text-secondary);
font-size: var(--font-size-xs);
margin-left: var(--spacing-xs);
}
.round-badge {
font-size: var(--font-size-sm);
color: var(--text-secondary);
@@ -143,6 +183,11 @@ watch(() => props.sending, () => { if (props.sending) scrollToBottom() })
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.model-disabled-banner {
background: #fef0f0;
border: 1px solid #fbc4c4;
color: #c45656;
}
.messages {
flex: 1;
overflow-y: auto;
+4
View File
@@ -24,6 +24,10 @@
<el-icon><Menu /></el-icon>
<span>分类管理</span>
</el-menu-item>
<el-menu-item index="/admin/ai-models">
<el-icon><Cpu /></el-icon>
<span>AI 模型</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
+6
View File
@@ -103,6 +103,12 @@ const routes = [
component: () => import('@/views/admin/Categories.vue'),
meta: { title: '分类管理', requiresAuth: true, requiresAdmin: true },
},
{
path: 'ai-models',
name: 'AdminAiModels',
component: () => import('@/views/admin/AiModels.vue'),
meta: { title: 'AI 模型', requiresAuth: true, requiresAdmin: true },
},
],
},
]
+72 -8
View File
@@ -17,10 +17,14 @@
:round-count="roundCount"
:max-rounds="maxRounds"
:read-only="readOnly"
:read-only-reason="readOnlyReason"
:sending="sending"
:current-session-id="currentSessionId"
:is-logged-in="userStore.isLoggedIn"
:available-models="availableModels"
:selected-model-id="selectedModelId"
@send="handleSend"
@update:selected-model-id="onModelChange"
/>
</div>
</template>
@@ -36,9 +40,12 @@ import {
listAiChatMessages,
sendAiChatMessage,
} from '@/api/aiChat'
import { listAppAiModels } from '@/api/aiModel'
import AiChatSidebar from '@/components/AiChatSidebar.vue'
import AiChatPanel from '@/components/AiChatPanel.vue'
const MODEL_STORAGE_KEY = 'jog_ai_assistant_model_id'
const userStore = useUserStore()
const router = useRouter()
@@ -49,10 +56,14 @@ const messages = ref([])
const roundCount = ref(0)
const sending = ref(false)
const readOnly = ref(false)
const readOnlyReason = ref(null)
const modelEnabled = ref(true)
const loadingSessions = ref(false)
const sessionPage = ref(1)
const sessionTotal = ref(0)
const pageSize = 50
const availableModels = ref([])
const selectedModelId = ref(null)
let localIdSeq = 0
const currentTitle = computed(() => {
@@ -65,12 +76,45 @@ const hasMore = computed(() => sessions.value.length < sessionTotal.value)
onMounted(async () => {
if (!userStore.isLoggedIn) return
await loadModels()
restoreModelId()
await loadSessions(1, false)
if (sessions.value.length > 0) {
await selectSession(sessions.value[0])
}
})
async function loadModels() {
try {
const res = await listAppAiModels()
availableModels.value = res.data || []
} catch {
availableModels.value = []
}
}
function restoreModelId() {
const stored = localStorage.getItem(MODEL_STORAGE_KEY)
if (stored) {
const id = Number(stored)
if (availableModels.value.some((m) => m.id === id)) {
selectedModelId.value = id
return
}
}
const def = availableModels.value.find((m) => m.isDefault)
selectedModelId.value = def ? def.id : (availableModels.value[0]?.id ?? null)
}
function onModelChange(id) {
selectedModelId.value = id
if (id) {
localStorage.setItem(MODEL_STORAGE_KEY, String(id))
} else {
localStorage.removeItem(MODEL_STORAGE_KEY)
}
}
async function loadSessions(page, append) {
loadingSessions.value = true
try {
@@ -106,19 +150,23 @@ async function handleNewSession() {
return
}
try {
const res = await createAiChatSession()
const res = await createAiChatSession({ modelId: selectedModelId.value })
const sessionId = res.data.sessionId
const item = {
id: sessionId,
title: '新会话',
roundCount: 0,
lastMessageAt: null,
modelDisplayName: availableModels.value.find((m) => m.id === selectedModelId.value)?.displayName || '',
modelEnabled: true,
}
sessions.value = [item, ...sessions.value.filter((s) => s.id !== sessionId)]
currentSessionId.value = sessionId
messages.value = []
roundCount.value = 0
readOnly.value = false
readOnlyReason.value = null
modelEnabled.value = true
} catch {
/* handled */
}
@@ -127,13 +175,20 @@ async function handleNewSession() {
async function selectSession(item) {
if (!userStore.isLoggedIn) return
currentSessionId.value = item.id
roundCount.value = item.roundCount ?? 0
readOnly.value = roundCount.value >= maxRounds
try {
const res = await listAiChatMessages(item.id)
messages.value = res.data || []
const payload = res.data
messages.value = payload.messages ?? []
modelEnabled.value = payload.modelEnabled !== false
readOnlyReason.value = payload.readOnlyReason ?? null
roundCount.value = payload.roundCount ?? item.roundCount ?? 0
readOnly.value = roundCount.value >= maxRounds || modelEnabled.value === false
} catch {
messages.value = []
modelEnabled.value = true
readOnlyReason.value = null
roundCount.value = item.roundCount ?? 0
readOnly.value = roundCount.value >= maxRounds
}
}
@@ -143,8 +198,12 @@ async function handleSend(text) {
router.push({ name: 'Login', query: { redirect: '/ai-assistant' } })
return
}
if (roundCount.value >= maxRounds) {
ElMessage.warning('当前会话已满 5 轮,请点击左侧「新会话」开始新的对话')
if (readOnly.value) {
if (readOnlyReason.value === 'MODEL_DISABLED') {
ElMessage.warning('该会话所用模型已停用,仅可查看历史,请切换模型后新建会话')
} else {
ElMessage.warning('当前会话已满 5 轮,请点击左侧「新会话」开始新的对话')
}
return
}
if (text.length > 2000) {
@@ -175,15 +234,20 @@ async function handleSend(text) {
const data = res.data
messages.value = messages.value.filter((m) => !m._localId)
const reload = await listAiChatMessages(currentSessionId.value)
messages.value = reload.data || []
const payload = reload.data
messages.value = payload.messages ?? []
roundCount.value = data.roundCount
readOnly.value = roundCount.value >= maxRounds
modelEnabled.value = payload.modelEnabled !== false
readOnlyReason.value = payload.readOnlyReason ?? null
readOnly.value = roundCount.value >= maxRounds || modelEnabled.value === false
const idx = sessions.value.findIndex((s) => s.id === currentSessionId.value)
if (idx >= 0) {
sessions.value[idx] = {
...sessions.value[idx],
roundCount: data.roundCount,
modelDisplayName: data.modelDisplayName ?? sessions.value[idx].modelDisplayName,
modelEnabled: modelEnabled.value,
title: sessions.value[idx].title === '新会话' && messages.value.length > 0
? truncateTitle(messages.value.find((m) => m.role === 'user')?.content)
: sessions.value[idx].title,
+187
View File
@@ -0,0 +1,187 @@
<template>
<div>
<h2 class="page-title">AI 模型管理</h2>
<div class="filter-bar">
<el-button type="primary" @click="showAddDialog">新增模型</el-button>
</div>
<el-card class="table-card">
<el-table :data="models" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="displayName" label="展示名称" min-width="140" />
<el-table-column prop="modelCode" label="模型编码" min-width="140" />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="图片" width="70">
<template #default="{ row }">
<span>{{ row.allowImageUpload ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column label="语音" width="70">
<template #default="{ row }">
<span>{{ row.allowVoiceUpload ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.isDefault" type="warning" size="small">默认</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button text type="primary" @click="showEditDialog(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑模型' : '新增模型'" width="520px">
<el-form :model="form" label-width="120px">
<el-form-item label="展示名称" required>
<el-input v-model="form.displayName" maxlength="32" placeholder="如:通义 Turbo · 快速" />
</el-form-item>
<el-form-item label="模型编码" required>
<el-input v-model="form.modelCode" maxlength="64" placeholder="如:qwen-turbo" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" maxlength="200" placeholder="可选,如:更强推理,适合复杂问题" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item label="允许上传图片">
<el-switch v-model="form.allowImageUpload" />
</el-form-item>
<el-form-item label="允许上传语音">
<el-switch v-model="form.allowVoiceUpload" />
</el-form-item>
<el-form-item label="默认模型">
<el-switch v-model="form.isDefault" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listAdminAiModels, createAdminAiModel, updateAdminAiModel, deleteAdminAiModel } from '@/api/aiModel'
import { ElMessage, ElMessageBox } from 'element-plus'
const models = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref(null)
const form = ref({
modelCode: '',
displayName: '',
description: '',
enabled: true,
allowImageUpload: false,
allowVoiceUpload: false,
isDefault: false,
sort: 0,
})
async function loadModels() {
loading.value = true
try {
const res = await listAdminAiModels()
models.value = res.data || []
} finally {
loading.value = false
}
}
function showAddDialog() {
isEdit.value = false
editingId.value = null
form.value = { modelCode: '', displayName: '', description: '', enabled: true, allowImageUpload: false, allowVoiceUpload: false, isDefault: false, sort: 0 }
dialogVisible.value = true
}
function showEditDialog(row) {
isEdit.value = true
editingId.value = row.id
form.value = {
modelCode: row.modelCode,
displayName: row.displayName,
description: row.description || '',
enabled: row.enabled,
allowImageUpload: row.allowImageUpload,
allowVoiceUpload: row.allowVoiceUpload,
isDefault: row.isDefault,
sort: row.sort,
}
dialogVisible.value = true
}
async function handleSubmit() {
if (!form.value.displayName.trim()) {
ElMessage.warning('请输入展示名称')
return
}
if (!form.value.modelCode.trim()) {
ElMessage.warning('请输入模型编码')
return
}
try {
if (isEdit.value) {
await updateAdminAiModel(editingId.value, form.value)
} else {
await createAdminAiModel(form.value)
}
ElMessage.success('操作成功')
dialogVisible.value = false
loadModels()
} catch {
// error handled by interceptor
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除模型"${row.displayName}"`, '提示')
try {
await deleteAdminAiModel(row.id)
ElMessage.success('已删除')
loadModels()
} catch {
// error handled by interceptor
}
}
onMounted(loadModels)
</script>
<style scoped>
.page-title {
color: var(--text-primary);
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
}
.filter-bar {
margin-bottom: var(--spacing-lg);
display: flex;
gap: var(--spacing-md);
align-items: center;
}
.table-card {
border-radius: var(--radius-md);
overflow: hidden;
}
.table-card :deep(.el-card__body) { padding: 0; }
</style>