feat: 完善多模型配置与运行切换

将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
Zhu Junhao
2026-09-03 10:46:25 +08:00
parent e968a8ddc1
commit 988c0fe555
36 changed files with 1715 additions and 190 deletions

View File

@@ -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>

View 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

View 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 })
})
})
})

View File

@@ -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>

View 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' })
})
})
})

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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'
}