feat: 完善多模型配置与运行切换
将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>智造申报 Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
4
web-ui/public/favicon.svg
Normal file
4
web-ui/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1769e8"/>
|
||||
<path d="M7 9h7l2 2h9v12H7z" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 223 B |
138
web-ui/src/pages/ModelsPage.test.ts
Normal file
138
web-ui/src/pages/ModelsPage.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ModelsPage from './ModelsPage.vue'
|
||||
|
||||
const apiMock = vi.fn()
|
||||
|
||||
vi.mock('element-plus', () => ({
|
||||
ElMessage: { success: vi.fn(), error: vi.fn() },
|
||||
ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) }
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args)
|
||||
}))
|
||||
|
||||
describe('ModelsPage', () => {
|
||||
beforeEach(() => {
|
||||
// 避免把 mockReset() 返回的 mock 函数误交给 Vitest 作为测试清理回调。
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
/** 统一注册页面使用的 Element Plus 浅层桩,测试输出不应包含组件解析警告。 */
|
||||
function mountModelsPage() {
|
||||
return shallowMount(ModelsPage, {
|
||||
global: {
|
||||
renderStubDefaultSlot: true,
|
||||
stubs: {
|
||||
'el-button': true,
|
||||
'el-input': true,
|
||||
'el-input-number': true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('数据库为空时提供新增模型入口', async () => {
|
||||
apiMock.mockResolvedValueOnce([])
|
||||
|
||||
const wrapper = mountModelsPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('新增模型')
|
||||
expect(wrapper.text()).toContain('暂无模型')
|
||||
})
|
||||
|
||||
it('展示多个 OpenAI 兼容模型及其状态', async () => {
|
||||
apiMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE',
|
||||
baseUrl: 'https://a.example.test', modelId: 'model-a', apiKeyHint: '••••1234',
|
||||
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: true
|
||||
},
|
||||
{
|
||||
id: 'model-b', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
|
||||
baseUrl: 'https://b.example.test', modelId: 'model-b', apiKeyHint: '••••5678',
|
||||
configJson: '{}', capabilitiesJson: '{"contextWindow":131072}', enabled: false, defaultModel: false
|
||||
}
|
||||
])
|
||||
|
||||
const wrapper = mountModelsPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('编排模型')
|
||||
expect(wrapper.text()).toContain('备用模型')
|
||||
expect(wrapper.text()).toContain('OpenAI 兼容')
|
||||
expect(wrapper.text()).toContain('已停用')
|
||||
expect(wrapper.text()).not.toContain('DeepSeek')
|
||||
})
|
||||
|
||||
it('测试连接时提交当前表单草稿而不是数据库旧配置', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE',
|
||||
baseUrl: 'https://old.example.test/v1', modelId: 'old-model', apiKeyHint: '••••1234',
|
||||
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false
|
||||
}])
|
||||
.mockResolvedValueOnce({ success: true, latencyMs: 12, message: '连接正常' })
|
||||
|
||||
const wrapper = mountModelsPage()
|
||||
await flushPromises()
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
|
||||
|
||||
// 依次修改 API 地址、API Key 和模型 ID,确保请求使用尚未保存的表单值。
|
||||
inputs[2].vm.$emit('update:modelValue', 'https://draft.example.test/v1')
|
||||
inputs[3].vm.$emit('update:modelValue', 'draft-secret')
|
||||
inputs[4].vm.$emit('update:modelValue', 'draft-model')
|
||||
await wrapper.vm.$nextTick()
|
||||
const testButton = wrapper.findAllComponents({ name: 'ElButton' })
|
||||
.find(button => button.text() === '测试连接')
|
||||
await testButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: 'model-a',
|
||||
baseUrl: 'https://draft.example.test/v1',
|
||||
modelId: 'draft-model',
|
||||
apiKey: 'draft-secret'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('连接正常')
|
||||
|
||||
// 成功标记只对应发起请求时的草稿,继续编辑后必须立即失效。
|
||||
inputs[4].vm.$emit('update:modelValue', 'changed-after-test')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).not.toContain('连接正常')
|
||||
})
|
||||
|
||||
it('停用模型时调用 PATCH 启用状态接口', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
|
||||
baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234',
|
||||
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false
|
||||
}])
|
||||
.mockResolvedValueOnce({
|
||||
id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
|
||||
baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234',
|
||||
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: false, defaultModel: false
|
||||
})
|
||||
.mockResolvedValueOnce([])
|
||||
|
||||
const wrapper = mountModelsPage()
|
||||
await flushPromises()
|
||||
const disableButton = wrapper.findAllComponents({ name: 'ElButton' })
|
||||
.find(button => button.text() === '停用')
|
||||
await disableButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/model-a/enabled', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ enabled: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CircleCheck } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CircleCheck, Delete, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
interface ModelConfig {
|
||||
@@ -19,19 +19,38 @@ interface ModelConfig {
|
||||
|
||||
const models = ref<ModelConfig[]>([])
|
||||
const selectedId = ref('')
|
||||
const creating = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const tested = ref(false)
|
||||
const stateChanging = ref(false)
|
||||
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
|
||||
const selected = computed(() => models.value.find(model => model.id === selectedId.value))
|
||||
const canSave = computed(() => Boolean(
|
||||
form.name.trim() && form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
|
||||
))
|
||||
const canTest = computed(() => Boolean(
|
||||
form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
|
||||
))
|
||||
|
||||
async function load() {
|
||||
// “连接正常”只证明发起请求时的草稿;任一字段变化后必须重新测试。
|
||||
watch(form, () => { tested.value = false })
|
||||
|
||||
/** 从服务端刷新模型列表,并尽量维持用户当前选中的模型。 */
|
||||
async function load(preferredId?: string) {
|
||||
models.value = await api<ModelConfig[]>('/api/models')
|
||||
select(models.value.find(model => model.defaultModel)?.id || models.value[0]?.id || '')
|
||||
const nextId = preferredId
|
||||
|| (models.value.some(model => model.id === selectedId.value) ? selectedId.value : '')
|
||||
|| models.value.find(model => model.defaultModel)?.id
|
||||
|| models.value[0]?.id
|
||||
|| ''
|
||||
if (nextId) select(nextId)
|
||||
}
|
||||
|
||||
/** 将数据库模型投影到编辑表单;API Key 始终保持为空,避免密钥回显。 */
|
||||
function select(id: string) {
|
||||
selectedId.value = id
|
||||
creating.value = false
|
||||
const model = models.value.find(item => item.id === id)
|
||||
if (!model) return
|
||||
let capabilities: { contextWindow?: number } = {}
|
||||
@@ -46,32 +65,57 @@ function select(id: string) {
|
||||
tested.value = false
|
||||
}
|
||||
|
||||
/** 进入新增模式并清空所有可能来自已有模型的可编辑字段。 */
|
||||
function beginCreate() {
|
||||
selectedId.value = ''
|
||||
creating.value = true
|
||||
tested.value = false
|
||||
Object.assign(form, { name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
|
||||
}
|
||||
|
||||
/** 创建或更新模型;新增模型的默认选择由后端事务保证。 */
|
||||
async function save() {
|
||||
if (!canSave.value || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api(`/api/models/${selectedId.value}`, {
|
||||
method: 'PUT',
|
||||
const path = creating.value ? '/api/models' : `/api/models/${selectedId.value}`
|
||||
const saved = await api<ModelConfig>(path, {
|
||||
method: creating.value ? 'POST' : 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
modelId: form.modelId,
|
||||
name: form.name.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
modelId: form.modelId.trim(),
|
||||
apiKey: form.apiKey,
|
||||
config: { timeoutSeconds: 120, reasoningEffort: 'high' },
|
||||
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
|
||||
})
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
await load()
|
||||
ElMessage.success(creating.value ? '模型已新增' : '配置已保存')
|
||||
creating.value = false
|
||||
await load(saved.id)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
/**
|
||||
* 使用当前表单草稿发送最小请求;已有模型留空 API Key 时由后端安全复用保存密钥。
|
||||
* 测试只验证草稿,不会隐式保存任何配置字段。
|
||||
*/
|
||||
async function testConnection() {
|
||||
if (!canTest.value || testing.value) return
|
||||
testing.value = true
|
||||
tested.value = false
|
||||
try {
|
||||
await api(`/api/models/${selectedId.value}/test`, { method: 'POST' })
|
||||
await api('/api/models/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: selected.value?.id || null,
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
modelId: form.modelId.trim(),
|
||||
apiKey: form.apiKey
|
||||
})
|
||||
})
|
||||
tested.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '连接失败')
|
||||
@@ -80,43 +124,139 @@ async function test() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将启用模型设为之后新建 Run 使用的全局默认模型。 */
|
||||
async function setDefault() {
|
||||
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' })
|
||||
await load()
|
||||
if (!selected.value || !selected.value.enabled || stateChanging.value) return
|
||||
stateChanging.value = true
|
||||
try {
|
||||
await api(`/api/models/${selected.value.id}/default`, { method: 'POST' })
|
||||
await load(selected.value.id)
|
||||
} finally {
|
||||
stateChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
/** 启停非默认模型;后端会阻止停用仍被运行中任务使用的模型。 */
|
||||
async function toggleEnabled() {
|
||||
if (!selected.value || stateChanging.value) return
|
||||
const enabled = !selected.value.enabled
|
||||
if (!enabled) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`停用“${selected.value.name}”?`, '停用模型', {
|
||||
confirmButtonText: '停用', cancelButtonText: '取消', type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
stateChanging.value = true
|
||||
try {
|
||||
const updated = await api<ModelConfig>(`/api/models/${selected.value.id}/enabled`, {
|
||||
method: 'PATCH', body: JSON.stringify({ enabled })
|
||||
})
|
||||
ElMessage.success(enabled ? '模型已启用' : '模型已停用')
|
||||
await load(updated.id)
|
||||
} finally {
|
||||
stateChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 真删除没有历史 Run 引用的非默认模型;常规模型下线优先使用停用。 */
|
||||
async function removeModel() {
|
||||
if (!selected.value || stateChanging.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`永久删除“${selected.value.name}”?`, '删除模型', {
|
||||
confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
stateChanging.value = true
|
||||
try {
|
||||
await api(`/api/models/${selected.value.id}`, { method: 'DELETE' })
|
||||
ElMessage.success('模型已删除')
|
||||
selectedId.value = ''
|
||||
await load()
|
||||
if (!models.value.length) beginCreate()
|
||||
} finally {
|
||||
stateChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
if (!models.value.length) beginCreate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-page">
|
||||
<header><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></header>
|
||||
<header class="settings-header">
|
||||
<div><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></div>
|
||||
<el-button :icon="Plus" type="primary" @click="beginCreate">新增模型</el-button>
|
||||
</header>
|
||||
<div class="settings-grid">
|
||||
<aside class="settings-list">
|
||||
<h2>已配置模型</h2>
|
||||
<h2>已配置模型 <small>{{ models.length }}</small></h2>
|
||||
<div v-if="!models.length" class="model-empty">暂无模型</div>
|
||||
<button
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:class="{ selected: selectedId === model.id }"
|
||||
class="model-list-item"
|
||||
:class="{ selected: selectedId === model.id, disabled: !model.enabled }"
|
||||
@click="select(model.id)"
|
||||
>
|
||||
<strong>{{ model.name }}</strong>
|
||||
<span>DeepSeek · {{ model.modelId }}</span>
|
||||
<small><i></i>可用</small>
|
||||
<span>OpenAI 兼容 · {{ model.modelId }}</span>
|
||||
<small :class="{ muted: !model.enabled }">
|
||||
<i></i>{{ model.defaultModel ? '默认' : model.enabled ? '已启用' : '已停用' }}
|
||||
</small>
|
||||
</button>
|
||||
</aside>
|
||||
<form v-if="selected" class="settings-form" @submit.prevent="save">
|
||||
<div class="form-title"><h2>{{ selected.name }}</h2><el-button v-if="!selected.defaultModel" @click="setDefault">设为默认</el-button><span v-else class="tag blue">默认</span></div>
|
||||
<label><span>服务商</span><el-input model-value="DeepSeek" disabled /></label>
|
||||
<label><span>API 地址</span><el-input v-model="form.baseUrl" /></label>
|
||||
<label><span>API Key</span><el-input v-model="form.apiKey" type="password" show-password :placeholder="selected.apiKeyHint" /></label>
|
||||
<label><span>模型 ID</span><el-input v-model="form.modelId" /></label>
|
||||
|
||||
<form v-if="creating || selected" class="settings-form" @submit.prevent="save">
|
||||
<div class="form-title">
|
||||
<h2>{{ creating ? '新增模型' : selected?.name }}</h2>
|
||||
<div v-if="selected" class="model-title-actions">
|
||||
<span v-if="selected.defaultModel" class="tag blue">默认</span>
|
||||
<el-button v-else :disabled="!selected.enabled" :loading="stateChanging" @click="setDefault">设为默认</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<label><span>配置名称</span><el-input v-model="form.name" maxlength="100" /></label>
|
||||
<label><span>服务商</span><el-input model-value="OpenAI 兼容" disabled /></label>
|
||||
<label><span>API 地址</span><el-input v-model="form.baseUrl" maxlength="500" /></label>
|
||||
<label>
|
||||
<span>API Key</span>
|
||||
<el-input
|
||||
v-model="form.apiKey"
|
||||
type="password"
|
||||
maxlength="4096"
|
||||
show-password
|
||||
:placeholder="creating ? '输入 API Key' : selected?.apiKeyHint || '留空保留现有密钥'"
|
||||
/>
|
||||
</label>
|
||||
<label><span>模型 ID</span><el-input v-model="form.modelId" maxlength="255" /></label>
|
||||
<label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label>
|
||||
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
|
||||
<div class="model-actions">
|
||||
<el-button native-type="submit" type="primary" :loading="saving">保存配置</el-button>
|
||||
<el-button :loading="testing" @click="test">测试连接</el-button>
|
||||
<el-button native-type="submit" type="primary" :loading="saving" :disabled="!canSave">{{ creating ? '创建模型' : '保存配置' }}</el-button>
|
||||
<el-button :loading="testing" :disabled="!canTest" @click="testConnection">测试连接</el-button>
|
||||
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
|
||||
<div v-if="selected" class="model-danger-actions">
|
||||
<el-button :loading="stateChanging" :disabled="selected.defaultModel" @click="toggleEnabled">
|
||||
{{ selected.enabled ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:icon="Delete"
|
||||
circle
|
||||
type="danger"
|
||||
plain
|
||||
title="删除模型"
|
||||
:loading="stateChanging"
|
||||
:disabled="selected.defaultModel"
|
||||
@click="removeModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
139
web-ui/src/pages/ProjectPage.test.ts
Normal file
139
web-ui/src/pages/ProjectPage.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ProjectPage from './ProjectPage.vue'
|
||||
|
||||
const apiMock = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { id: 'project-1' } }),
|
||||
useRouter: () => ({ replace: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args),
|
||||
streamEvents: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('../eventCache', () => ({
|
||||
cacheEvents: vi.fn(async () => undefined),
|
||||
deleteCachedEvents: vi.fn(async () => undefined),
|
||||
readCachedEvents: vi.fn(async () => [])
|
||||
}))
|
||||
|
||||
vi.mock('element-plus', () => ({
|
||||
ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||
ElMessageBox: { confirm: vi.fn() }
|
||||
}))
|
||||
|
||||
// 测试只关心项目页发送的模型选择请求,因此用原生控件模拟 Element Plus 的 v-model 契约。
|
||||
const ElButtonStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, emit, slots }) {
|
||||
return () => h('button', { ...attrs, onClick: () => emit('click') }, slots.default?.())
|
||||
}
|
||||
})
|
||||
const ElDialogStub = defineComponent({
|
||||
props: { modelValue: Boolean },
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue
|
||||
? h('div', { role: 'dialog' }, [slots.default?.(), h('footer', slots.footer?.())])
|
||||
: null
|
||||
}
|
||||
})
|
||||
const ElSelectStub = defineComponent({
|
||||
props: { modelValue: String },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () => h('select', {
|
||||
value: props.modelValue,
|
||||
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value)
|
||||
}, slots.default?.())
|
||||
}
|
||||
})
|
||||
const ElOptionStub = defineComponent({
|
||||
props: { label: String, value: String },
|
||||
setup(props) {
|
||||
return () => h('option', { value: props.value }, props.label)
|
||||
}
|
||||
})
|
||||
|
||||
const enabledModels = [
|
||||
{ id: 'model-a', name: '当前模型', modelId: 'model-a', enabled: true, defaultModel: false },
|
||||
{ id: 'model-b', name: '默认模型', modelId: 'model-b', enabled: true, defaultModel: true },
|
||||
{ id: 'model-c', name: '停用模型', modelId: 'model-c', enabled: false, defaultModel: false }
|
||||
]
|
||||
|
||||
function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED') {
|
||||
apiMock.mockImplementation(async (url: string, options?: RequestInit) => {
|
||||
if (url === '/api/projects/project-1') {
|
||||
return {
|
||||
id: 'project-1', companyName: '测试企业', projectName: '申报项目', threadId: 'thread-1',
|
||||
applicationLevel: 'ADVANCED', status: 'WRITING', createdAt: '', updatedAt: ''
|
||||
}
|
||||
}
|
||||
if (url === '/api/projects/project-1/files' || url === '/api/projects/project-1/artifacts'
|
||||
|| url.startsWith('/api/projects/project-1/events')) return []
|
||||
if (url === '/api/projects/project-1/plan') return null
|
||||
if (url === '/api/projects/project-1/runs/latest') {
|
||||
return { status: runStatus, modelConfigId: 'model-a' }
|
||||
}
|
||||
if (url === '/api/models') return enabledModels
|
||||
if (options?.method === 'POST') return { status: 'RUNNING' }
|
||||
throw new Error(`未处理的测试请求:${url}`)
|
||||
})
|
||||
|
||||
return shallowMount(ProjectPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
'el-button': ElButtonStub,
|
||||
'el-dialog': ElDialogStub,
|
||||
'el-select': ElSelectStub,
|
||||
'el-option': ElOptionStub,
|
||||
'el-icon': defineComponent({ setup: (_, { slots }) => () => h('span', slots.default?.()) }),
|
||||
'el-upload': defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProjectPage 模型切换', () => {
|
||||
beforeEach(() => {
|
||||
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
it('中断任务继续时允许选择启用模型并发送模型 ID', async () => {
|
||||
const wrapper = mountProject('INTERRUPTED')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text() === '继续')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('停用模型')
|
||||
|
||||
await wrapper.get('select').setValue('model-b')
|
||||
await wrapper.findAll('button').find(button => button.text() === '继续运行')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/resume', {
|
||||
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
|
||||
})
|
||||
})
|
||||
|
||||
it('运行中的任务可以选择替代模型并调用受控切换接口', async () => {
|
||||
const wrapper = mountProject('RUNNING')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text() === '切换模型')!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select').setValue('model-b')
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认切换')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/switch-model', {
|
||||
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,12 @@ const deleting = ref(false)
|
||||
const historyLoading = ref(true)
|
||||
const streamError = ref('')
|
||||
const showBackToBottom = ref(false)
|
||||
const currentModelConfigId = ref('')
|
||||
const modelPickerVisible = ref(false)
|
||||
const modelPickerLoading = ref(false)
|
||||
const modelPickerMode = ref<'resume' | 'switch'>('resume')
|
||||
const selectedModelConfigId = ref('')
|
||||
const selectableModels = ref<Array<{ id: string; name: string; modelId: string; enabled: boolean; defaultModel: boolean }>>([])
|
||||
let stopStream: (() => void) | null = null
|
||||
let loadVersion = 0
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -36,6 +42,8 @@ const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan
|
||||
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
|
||||
const running = computed(() => runStatus.value === 'RUNNING')
|
||||
const interrupted = computed(() => runStatus.value === 'INTERRUPTED')
|
||||
const modelPickerTitle = computed(() => modelPickerMode.value === 'switch' ? '切换运行模型' : '选择继续运行的模型')
|
||||
const modelPickerConfirmText = computed(() => modelPickerMode.value === 'switch' ? '确认切换' : '继续运行')
|
||||
const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级')
|
||||
const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
|
||||
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
|
||||
@@ -55,7 +63,7 @@ async function load(id: string) {
|
||||
api<ProjectFile[]>(`/api/projects/${id}/files`),
|
||||
api<Artifact[]>(`/api/projects/${id}/artifacts`),
|
||||
api<PlanView | null>(`/api/projects/${id}/plan`),
|
||||
api<{ status: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`)
|
||||
api<{ status: string; modelConfigId?: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`)
|
||||
])
|
||||
const loadedEvents = await fetchMissingEvents(id, cached)
|
||||
if (version !== loadVersion || id !== projectId.value) return
|
||||
@@ -65,6 +73,7 @@ async function load(id: string) {
|
||||
plan.value = loadedPlan
|
||||
events.value = loadedEvents
|
||||
runStatus.value = latest?.status || ''
|
||||
currentModelConfigId.value = latest?.modelConfigId || ''
|
||||
pendingAsk.value = parseAsk(latest?.pendingInterrupt)
|
||||
startStream(id, version)
|
||||
} finally {
|
||||
@@ -234,14 +243,51 @@ async function stopRun() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeRun() {
|
||||
if (!interrupted.value || controlLoading.value) return
|
||||
/**
|
||||
* 打开恢复或切换模型对话框,并从数据库重新读取当前启用的模型。
|
||||
* 优先保留 Run 已绑定的模型;如果该模型已停用,则退回当前默认模型或首个可用模型。
|
||||
*/
|
||||
async function openModelPicker(mode: 'resume' | 'switch') {
|
||||
if (controlLoading.value || modelPickerLoading.value) return
|
||||
modelPickerMode.value = mode
|
||||
modelPickerLoading.value = true
|
||||
try {
|
||||
const models = await api<Array<{ id: string; name: string; modelId: string; enabled: boolean; defaultModel: boolean }>>('/api/models')
|
||||
selectableModels.value = models.filter(model => model.enabled)
|
||||
if (!selectableModels.value.length) {
|
||||
ElMessage.error('没有可用模型,请先启用模型配置')
|
||||
return
|
||||
}
|
||||
selectedModelConfigId.value = selectableModels.value.find(model => model.id === currentModelConfigId.value)?.id
|
||||
|| selectableModels.value.find(model => model.defaultModel)?.id
|
||||
|| selectableModels.value[0]!.id
|
||||
modelPickerVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '模型列表加载失败')
|
||||
} finally {
|
||||
modelPickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用用户明确选择的模型恢复任务,或中断当前 Run 后创建新的 RESUME Run。
|
||||
* 请求成功后立即更新页面中的 Run 状态和绑定模型,事件流随后会补齐完整审计事件。
|
||||
*/
|
||||
async function confirmModelSelection() {
|
||||
if (!selectedModelConfigId.value || controlLoading.value) return
|
||||
controlLoading.value = true
|
||||
try {
|
||||
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' })
|
||||
const endpoint = modelPickerMode.value === 'switch' ? 'switch-model' : 'resume'
|
||||
const run = await api<{ status: string; modelConfigId?: string }>(`/api/projects/${projectId.value}/runs/${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ modelConfigId: selectedModelConfigId.value })
|
||||
})
|
||||
runStatus.value = run.status
|
||||
currentModelConfigId.value = run.modelConfigId || selectedModelConfigId.value
|
||||
modelPickerVisible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '继续失败')
|
||||
const fallback = modelPickerMode.value === 'switch' ? '切换失败' : '继续失败'
|
||||
ElMessage.error(error instanceof Error ? error.message : fallback)
|
||||
} finally {
|
||||
controlLoading.value = false
|
||||
}
|
||||
@@ -368,12 +414,33 @@ onBeforeUnmount(() => {
|
||||
<header class="page-header">
|
||||
<div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div>
|
||||
<div class="run-actions">
|
||||
<el-button v-if="running" text :loading="modelPickerLoading" @click="openModelPicker('switch')">切换模型</el-button>
|
||||
<el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button>
|
||||
<el-button v-else-if="interrupted" type="primary" plain :loading="controlLoading" @click="resumeRun">继续</el-button>
|
||||
<el-button v-else-if="interrupted" type="primary" plain :loading="modelPickerLoading" @click="openModelPicker('resume')">继续</el-button>
|
||||
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-dialog v-model="modelPickerVisible" :title="modelPickerTitle" width="420px" :close-on-click-modal="false">
|
||||
<label class="model-picker-field">
|
||||
<span>运行模型</span>
|
||||
<el-select v-model="selectedModelConfigId" placeholder="选择模型" style="width: 100%">
|
||||
<el-option
|
||||
v-for="model in selectableModels"
|
||||
:key="model.id"
|
||||
:label="`${model.name} · ${model.modelId}${model.defaultModel ? '(默认)' : ''}`"
|
||||
:value="model.id"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
<template #footer>
|
||||
<el-button :disabled="controlLoading" @click="modelPickerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="controlLoading" :disabled="!selectedModelConfigId" @click="confirmModelSelection">
|
||||
{{ modelPickerConfirmText }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div class="work-scroll">
|
||||
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
|
||||
<section v-else-if="!events.length" class="material-start">
|
||||
|
||||
@@ -153,27 +153,37 @@ a { color: inherit; text-decoration: none; }
|
||||
.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; }
|
||||
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||||
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
|
||||
.model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; }
|
||||
.model-picker-field > span { color: #53617b; font-size: 14px; }
|
||||
|
||||
.settings-page { min-height: 100vh; padding: 28px 42px; }
|
||||
.settings-page > header h1 { margin: 0; font-size: 28px; }
|
||||
.settings-page > header p { color: #5f6d86; margin: 10px 0 0; }
|
||||
.settings-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||
.settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; }
|
||||
.settings-list { padding-right: 28px; border-right: 1px solid var(--line); }
|
||||
.settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; }
|
||||
.settings-list h2 small { margin-left: 6px; color: #7b879b; font-size: 13px; font-weight: 500; }
|
||||
.settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; }
|
||||
.settings-list button.selected { background: #edf4ff; color: var(--blue); }
|
||||
.settings-list button.disabled strong, .settings-list button.disabled span { color: #8b96a8; }
|
||||
.settings-list button strong, .settings-list button span { grid-column: 1; }
|
||||
.settings-list button span { margin-top: 8px; color: #5f6d86; }
|
||||
.settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); }
|
||||
.settings-list button small.muted { color: #8b96a8; }
|
||||
.settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
|
||||
.settings-list button small.muted i { background: #9ca6b5; }
|
||||
.model-empty { min-height: 160px; display: grid; place-items: center; color: #8995a8; border: 1px dashed #d9e0ea; border-radius: 7px; }
|
||||
.settings-form { padding-left: 28px; }
|
||||
.form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||
.form-title h2 { margin: 0; font-size: 22px; }
|
||||
.model-title-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); }
|
||||
.capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); }
|
||||
.capability-row div { display: flex; gap: 8px; }
|
||||
.capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; }
|
||||
.model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; }
|
||||
.model-danger-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
||||
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.connection-ok svg { width: 18px; }
|
||||
|
||||
@@ -229,6 +239,7 @@ a { color: inherit; text-decoration: none; }
|
||||
.work-scroll { width: calc(100% - 32px); }
|
||||
.page-header { padding: 0 16px; }
|
||||
.page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
|
||||
.run-actions { gap: 2px; }
|
||||
.ask-card, .artifact-card { max-width: 100%; margin-left: 0; }
|
||||
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
|
||||
.flow-tool::before, .flow-reasoning::before { left: 7px; }
|
||||
@@ -236,10 +247,13 @@ a { color: inherit; text-decoration: none; }
|
||||
.material-item { grid-template-columns: 1fr 160px; }
|
||||
.material-upload { grid-column: 2; }
|
||||
.settings-page { padding: 24px 16px; }
|
||||
.settings-header { align-items: center; }
|
||||
.settings-grid, .skill-grid { grid-template-columns: 1fr; }
|
||||
.settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; }
|
||||
.settings-form, .skill-detail { padding: 24px 0 0; }
|
||||
.settings-form > label, .capability-row { grid-template-columns: 130px 1fr; }
|
||||
.model-actions { flex-wrap: wrap; }
|
||||
.model-danger-actions { width: 100%; margin-left: 0; padding-top: 6px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 15173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8080'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user