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:
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user