chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块
This commit is contained in:
138
web-ui/packages/agent/src/pages/ModelsPage.test.ts
Normal file
138
web-ui/packages/agent/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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
264
web-ui/packages/agent/src/pages/ModelsPage.vue
Normal file
264
web-ui/packages/agent/src/pages/ModelsPage.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
baseUrl: string
|
||||
modelId: string
|
||||
apiKeyHint: string
|
||||
configJson: string
|
||||
capabilitiesJson: string
|
||||
enabled: boolean
|
||||
defaultModel: boolean
|
||||
}
|
||||
|
||||
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())
|
||||
))
|
||||
|
||||
// “连接正常”只证明发起请求时的草稿;任一字段变化后必须重新测试。
|
||||
watch(form, () => { tested.value = false })
|
||||
|
||||
/** 从服务端刷新模型列表,并尽量维持用户当前选中的模型。 */
|
||||
async function load(preferredId?: string) {
|
||||
models.value = await api<ModelConfig[]>('/api/models')
|
||||
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 } = {}
|
||||
try { capabilities = JSON.parse(model.capabilitiesJson) } catch { capabilities = {} }
|
||||
Object.assign(form, {
|
||||
name: model.name,
|
||||
baseUrl: model.baseUrl,
|
||||
modelId: model.modelId,
|
||||
apiKey: '',
|
||||
contextWindow: capabilities.contextWindow || 131072
|
||||
})
|
||||
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 {
|
||||
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.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(creating.value ? '模型已新增' : '配置已保存')
|
||||
creating.value = false
|
||||
await load(saved.id)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前表单草稿发送最小请求;已有模型留空 API Key 时由后端安全复用保存密钥。
|
||||
* 测试只验证草稿,不会隐式保存任何配置字段。
|
||||
*/
|
||||
async function testConnection() {
|
||||
if (!canTest.value || testing.value) return
|
||||
testing.value = true
|
||||
tested.value = false
|
||||
try {
|
||||
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 : '连接失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将启用模型设为之后新建 Run 使用的全局默认模型。 */
|
||||
async function setDefault() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 启停非默认模型;后端会阻止停用仍被运行中任务使用的模型。 */
|
||||
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 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>已配置模型 <small>{{ models.length }}</small></h2>
|
||||
<div v-if="!models.length" class="model-empty">暂无模型</div>
|
||||
<button
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
class="model-list-item"
|
||||
:class="{ selected: selectedId === model.id, disabled: !model.enabled }"
|
||||
@click="select(model.id)"
|
||||
>
|
||||
<strong>{{ model.name }}</strong>
|
||||
<span>OpenAI 兼容 · {{ model.modelId }}</span>
|
||||
<small :class="{ muted: !model.enabled }">
|
||||
<i></i>{{ model.defaultModel ? '默认' : model.enabled ? '已启用' : '已停用' }}
|
||||
</small>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<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" :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>
|
||||
</section>
|
||||
</template>
|
||||
432
web-ui/packages/agent/src/pages/ProjectPage.test.ts
Normal file
432
web-ui/packages/agent/src/pages/ProjectPage.test.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentEvent } from '../api'
|
||||
import ProjectPage from './ProjectPage.vue'
|
||||
|
||||
type StreamEventsCallback = (batch: AgentEvent[]) => void
|
||||
|
||||
const apiMock = vi.fn()
|
||||
let outputResizeCallback: ResizeObserverCallback | null = null
|
||||
const observeOutputMock = vi.fn()
|
||||
const unobserveOutputMock = vi.fn()
|
||||
const disconnectOutputObserverMock = vi.fn()
|
||||
const mountedWrappers: Array<{ unmount: () => void }> = []
|
||||
|
||||
/**
|
||||
* JSDOM 不提供 ResizeObserver,这里保留组件注册的回调,模拟流式 Markdown
|
||||
* 在事件已经写入后仍逐帧增高的真实浏览器行为。
|
||||
*/
|
||||
class ResizeObserverMock {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
outputResizeCallback = callback
|
||||
}
|
||||
|
||||
observe = observeOutputMock
|
||||
unobserve = unobserveOutputMock
|
||||
disconnect = disconnectOutputObserverMock
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
|
||||
const streamEventsMock = vi.fn((
|
||||
_projectId: string,
|
||||
_after: number,
|
||||
_onEvents: StreamEventsCallback,
|
||||
_onError: (error: Error) => void
|
||||
) => vi.fn())
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { id: 'project-1' } }),
|
||||
useRouter: () => ({ replace: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args),
|
||||
streamEvents: (
|
||||
projectId: string,
|
||||
after: number,
|
||||
onEvents: StreamEventsCallback,
|
||||
onError: (error: Error) => void
|
||||
) => streamEventsMock(projectId, after, onEvents, onError)
|
||||
}))
|
||||
|
||||
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 }
|
||||
]
|
||||
|
||||
interface ProjectFixtureOptions {
|
||||
projectStatus?: 'MATERIAL_CHECK' | 'PLANNING' | 'WRITING' | 'DELIVERED' | 'FAILED' | 'ARCHIVED'
|
||||
files?: Array<Record<string, unknown>>
|
||||
artifacts?: Array<Record<string, unknown>>
|
||||
latestRun?: { status: string; modelConfigId?: string } | null
|
||||
startedRun?: { status: string; modelConfigId?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造项目页的最小服务端数据集,允许每个测试只覆盖自己关心的业务状态。
|
||||
* 默认仍保持原有“申报书编写中”场景,避免模型切换测试因视觉改造改变测试语义。
|
||||
*/
|
||||
function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixtureOptions = {}) {
|
||||
apiMock.mockImplementation(async (url: string, requestOptions?: RequestInit) => {
|
||||
if (url === '/api/projects/project-1') {
|
||||
return {
|
||||
id: 'project-1', companyName: '测试企业', projectName: '申报项目', threadId: 'thread-1',
|
||||
applicationLevel: 'ADVANCED', status: options.projectStatus || 'WRITING',
|
||||
createdAt: '2026-08-31T09:00:00Z', updatedAt: '2026-08-31T10:00:00Z'
|
||||
}
|
||||
}
|
||||
if (url === '/api/projects/project-1/files') return options.files || []
|
||||
if (url === '/api/projects/project-1/artifacts') return options.artifacts || []
|
||||
if (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 options.latestRun === undefined
|
||||
? { status: runStatus, modelConfigId: 'model-a' }
|
||||
: options.latestRun
|
||||
}
|
||||
if (url === '/api/models') return enabledModels
|
||||
if (url === '/api/projects/project-1/runs/material-check' && requestOptions?.method === 'POST') {
|
||||
return options.startedRun || { status: 'RUNNING', modelConfigId: 'model-a' }
|
||||
}
|
||||
if (requestOptions?.method === 'POST') return { status: 'RUNNING' }
|
||||
throw new Error(`未处理的测试请求:${url}`)
|
||||
})
|
||||
|
||||
const wrapper = 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?.()) })
|
||||
}
|
||||
}
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例
|
||||
// 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。
|
||||
while (mountedWrappers.length) mountedWrappers.pop()!.unmount()
|
||||
})
|
||||
|
||||
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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectPage 项目工作区', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset()
|
||||
streamEventsMock.mockReset()
|
||||
streamEventsMock.mockReturnValue(vi.fn())
|
||||
outputResizeCallback = null
|
||||
observeOutputMock.mockReset()
|
||||
unobserveOutputMock.mockReset()
|
||||
disconnectOutputObserverMock.mockReset()
|
||||
})
|
||||
|
||||
it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => {
|
||||
const wrapper = mountProject('RUNNING', {
|
||||
projectStatus: 'WRITING',
|
||||
files: [
|
||||
{ id: 'file-1', name: '企业营业执照.pdf', relativePath: '企业营业执照.pdf', extension: 'pdf', sizeBytes: 1024, status: 'READY', createdAt: '' },
|
||||
{ id: 'file-2', name: '财务报表.xlsx', relativePath: '财务报表.xlsx', extension: 'xlsx', sizeBytes: 2048, status: 'READY', createdAt: '' }
|
||||
],
|
||||
artifacts: [
|
||||
{ id: 'artifact-1', name: '先进级申报书.docx', kind: 'DOCX', sizeBytes: 4096, metadataJson: '{}', publishedAt: '' }
|
||||
]
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const stages = wrapper.findAll('.project-stage')
|
||||
expect(stages).toHaveLength(4)
|
||||
expect(stages.slice(0, 2).every(stage => stage.classes().includes('is-complete'))).toBe(true)
|
||||
expect(stages[2]!.text()).toContain('申报书编写')
|
||||
expect(stages[2]!.attributes('aria-current')).toBe('step')
|
||||
|
||||
const context = wrapper.get('.project-context-panel')
|
||||
expect(context.text()).toContain('当前模型')
|
||||
expect(context.text()).toContain('当前模型 · model-a')
|
||||
expect(context.text()).toContain('2 个文件')
|
||||
expect(context.text()).toContain('企业营业执照.pdf')
|
||||
expect(context.text()).toContain('1 个文件')
|
||||
expect(context.text()).toContain('先进级申报书.docx')
|
||||
expect(context.get('a').attributes('href')).toBe('/api/artifacts/artifact-1/download')
|
||||
})
|
||||
|
||||
it('首次启动材料检查后立即显示本次 Run 实际绑定的模型', async () => {
|
||||
const wrapper = mountProject('RUNNING', {
|
||||
projectStatus: 'MATERIAL_CHECK',
|
||||
latestRun: null,
|
||||
startedRun: { status: 'RUNNING', modelConfigId: 'model-a' }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.current-model-name').text()).toBe('尚未选择')
|
||||
|
||||
await wrapper.findAll('button').find(button => button.text() === '开始材料检验')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.current-model-name').text()).toBe('当前模型 · model-a')
|
||||
})
|
||||
|
||||
it('用户向上滚动后暂停跟随新输出,并可通过箭头回到最新位置', async () => {
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1800 })
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1200 })
|
||||
const wrapper = mountProject('RUNNING')
|
||||
await flushPromises()
|
||||
scrollTo.mockClear()
|
||||
|
||||
// 即使只向上移动 20px,也已代表用户主动回看历史,下一批输出不能把页面拉回底部。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
const latestButton = wrapper.get('.back-to-bottom')
|
||||
expect(latestButton.attributes('aria-label')).toBe('转到最新输出')
|
||||
|
||||
const streamCallback = streamEventsMock.mock.calls[0]?.[2]
|
||||
expect(streamCallback).toBeTypeOf('function')
|
||||
streamCallback!([{
|
||||
id: 1,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
type: 'TEXT_MESSAGE_CONTENT',
|
||||
payload: { delta: '新输出' },
|
||||
createdAt: '2026-09-03T10:00:00Z'
|
||||
}])
|
||||
await flushPromises()
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
await latestButton.trigger('click')
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 箭头点击后如果输出容器先发生尺寸变化,必须保留程序滚动目标,
|
||||
// 不能因 ResizeObserver 的再次滚动把“持续跟随”状态提前结束。
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1900 })
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
|
||||
// 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 点击后浏览器可能先派发仍接近旧位置的事件;即使距离底部小于通用阈值,
|
||||
// 也不能提前结束程序滚动跟踪,否则紧接着的布局滚动会再次显示箭头。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1170 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 用户上滚事件先于 scroll 到达时,必须立即取消跟随,不能被尺寸观察器抢回底部。
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
scrollTo.mockClear()
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2100 })
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
|
||||
// 已取消的程序滚动可能仍会派发到达底部的延迟事件,不能借此错误恢复跟随。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
|
||||
// 用户明确向下滚动并抵达底部时,仍应恢复持续跟随。
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 32 }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 在输入控件内使用方向键不应改变页面的自动跟随状态。
|
||||
const modelInput = document.createElement('input')
|
||||
document.body.appendChild(modelInput)
|
||||
modelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
modelInput.remove()
|
||||
|
||||
// 控件内部的图标/子节点事件也不应触发页面滚动状态切换。
|
||||
const control = document.createElement('button')
|
||||
const icon = document.createElement('span')
|
||||
control.appendChild(icon)
|
||||
document.body.appendChild(control)
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
icon.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
control.remove()
|
||||
|
||||
// 空格键同样会向下翻页,回到底部后应恢复持续跟随。
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 滚动条拖动期间,旧程序滚动到达底部不能提前恢复跟随;释放后向下拖到底部才恢复。
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1000 })
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 300 }))
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 100 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000 }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
|
||||
// 向下拖动滚动条并释放到底部时才恢复跟随。
|
||||
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 100 }))
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 300 }))
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000, clientY: 300 }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 触摸滚动/滚动条拖动没有 wheel 事件,也必须能取消跟随。
|
||||
window.dispatchEvent(new TouchEvent('touchmove'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
await wrapper.get('.back-to-bottom').trigger('click')
|
||||
|
||||
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
|
||||
scrollTo.mockClear()
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
|
||||
expect(outputResizeCallback).not.toBeNull()
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
|
||||
// 即使新内容只让底部前移 20px,也必须记录程序目标,防止中间 scroll 事件误停跟随。
|
||||
scrollTo.mockClear()
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1380 })
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1370 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。
|
||||
scrollTo.mockClear()
|
||||
streamCallback!([{
|
||||
id: 2,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
type: 'TEXT_MESSAGE_CONTENT',
|
||||
payload: { delta: '后续输出' },
|
||||
createdAt: '2026-09-03T10:00:01Z'
|
||||
}])
|
||||
await flushPromises()
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
868
web-ui/packages/agent/src/pages/ProjectPage.vue
Normal file
868
web-ui/packages/agent/src/pages/ProjectPage.vue
Normal file
@@ -0,0 +1,868 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Document, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ArrowDown } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AgentTimeline from '../components/AgentTimeline.vue'
|
||||
import MaterialAskCard from '../components/MaterialAskCard.vue'
|
||||
import PlanCard from '../components/PlanCard.vue'
|
||||
import { api, streamEvents, type AgentEvent, type Artifact, type PlanView, type Project, type ProjectFile } from '../api'
|
||||
import { cacheEvents, deleteCachedEvents, readCachedEvents } from '../eventCache'
|
||||
import { appendUniqueEvents } from '../eventUtils'
|
||||
|
||||
interface ModelOption {
|
||||
id: string
|
||||
name: string
|
||||
modelId: string
|
||||
enabled: boolean
|
||||
defaultModel: boolean
|
||||
}
|
||||
|
||||
interface RunSummary {
|
||||
status: string
|
||||
modelConfigId?: string
|
||||
}
|
||||
|
||||
const projectStageDefinitions = [
|
||||
{ key: 'materials', label: '材料检查', description: '核验企业材料' },
|
||||
{ key: 'planning', label: '规划确认', description: '确认建设规划' },
|
||||
{ key: 'writing', label: '申报书编写', description: '生成并校验内容' },
|
||||
{ key: 'delivery', label: '交付', description: '下载最终文件' }
|
||||
] as const
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const emit = defineEmits<{ 'projects-changed': [] }>()
|
||||
const project = ref<Project | null>(null)
|
||||
const files = ref<ProjectFile[]>([])
|
||||
const events = shallowRef<AgentEvent[]>([])
|
||||
const plan = ref<PlanView | null>(null)
|
||||
const pendingAsk = ref<Record<string, any> | null>(null)
|
||||
const artifacts = ref<Artifact[]>([])
|
||||
const runStatus = ref('')
|
||||
const loading = ref(false)
|
||||
const folderUploading = ref(false)
|
||||
const controlLoading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const historyLoading = ref(true)
|
||||
const streamError = ref('')
|
||||
const followsLatestOutput = ref(true)
|
||||
const currentModelConfigId = ref('')
|
||||
const modelPickerVisible = ref(false)
|
||||
const modelPickerLoading = ref(false)
|
||||
const modelPickerMode = ref<'resume' | 'switch'>('resume')
|
||||
const selectedModelConfigId = ref('')
|
||||
const modelCatalog = ref<ModelOption[]>([])
|
||||
const selectableModels = ref<ModelOption[]>([])
|
||||
const outputContainer = ref<HTMLElement | null>(null)
|
||||
let stopStream: (() => void) | null = null
|
||||
let outputResizeObserver: ResizeObserver | null = null
|
||||
let loadVersion = 0
|
||||
let previousScrollY = 0
|
||||
let hasObservedScrollPosition = false
|
||||
let pendingFollowScrollTarget: number | null = null
|
||||
type UserScrollIntent = 'none' | 'up' | 'down'
|
||||
let userScrollIntent: UserScrollIntent = 'none'
|
||||
let touchStartY: number | null = null
|
||||
let scrollbarDragLastY: number | null = null
|
||||
let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none'
|
||||
let scrollbarDragActive = false
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
const LATEST_OUTPUT_THRESHOLD = 48
|
||||
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
|
||||
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
|
||||
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
|
||||
|
||||
const projectId = computed(() => String(route.params.id || ''))
|
||||
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
||||
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: '已归档'
|
||||
}[project.value?.status || 'MATERIAL_CHECK']))
|
||||
|
||||
/**
|
||||
* 将后端业务状态投影为稳定的四阶段索引。失败状态通常发生在内容生成阶段,
|
||||
* 因而仍停留在“申报书编写”,方便用户理解失败位置并继续使用原有重试入口。
|
||||
*/
|
||||
const currentStageIndex = computed(() => ({
|
||||
MATERIAL_CHECK: 0,
|
||||
PLANNING: 1,
|
||||
WRITING: 2,
|
||||
FAILED: 2,
|
||||
DELIVERED: 3,
|
||||
ARCHIVED: 3
|
||||
}[project.value?.status || 'MATERIAL_CHECK']))
|
||||
|
||||
/** 为阶段条补充完成、当前和待开始状态;这里只改变展示,不参与后端流程判断。 */
|
||||
const projectStages = computed(() => projectStageDefinitions.map((stage, index) => ({
|
||||
...stage,
|
||||
state: project.value?.status === 'DELIVERED' || project.value?.status === 'ARCHIVED'
|
||||
? 'complete'
|
||||
: index < currentStageIndex.value
|
||||
? 'complete'
|
||||
: index === currentStageIndex.value ? 'current' : 'upcoming'
|
||||
})))
|
||||
|
||||
const currentStageLabel = computed(() => projectStageDefinitions[currentStageIndex.value]?.label || '材料检查')
|
||||
const currentModelLabel = computed(() => {
|
||||
const model = modelCatalog.value.find(item => item.id === currentModelConfigId.value)
|
||||
if (model) return `${model.name} · ${model.modelId}`
|
||||
return currentModelConfigId.value ? '已绑定运行模型' : '尚未选择'
|
||||
})
|
||||
const updatedAtLabel = computed(() => {
|
||||
const updatedAt = project.value?.updatedAt
|
||||
if (!updatedAt) return '--'
|
||||
const value = new Date(updatedAt)
|
||||
return Number.isNaN(value.getTime()) ? '--' : value.toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
|
||||
/**
|
||||
* 把创建、恢复或切换 Run 的响应同步到页面状态。
|
||||
* 后端返回的 modelConfigId 代表本次 Run 真正绑定的模型,不能继续沿用上一个 Run
|
||||
* 或页面初次加载时的空值;仅在兼容旧响应时才使用调用方提供的兜底模型 ID。
|
||||
*/
|
||||
function syncRunState(run: RunSummary, fallbackModelConfigId = '') {
|
||||
runStatus.value = run.status
|
||||
const modelConfigId = run.modelConfigId || fallbackModelConfigId
|
||||
if (modelConfigId) currentModelConfigId.value = modelConfigId
|
||||
}
|
||||
|
||||
async function load(id: string) {
|
||||
const version = ++loadVersion
|
||||
historyLoading.value = true
|
||||
followsLatestOutput.value = true
|
||||
previousScrollY = window.scrollY
|
||||
hasObservedScrollPosition = false
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'none'
|
||||
touchStartY = null
|
||||
scrollbarDragLastY = null
|
||||
scrollbarDragDirection = 'none'
|
||||
scrollbarDragActive = false
|
||||
stopStream?.()
|
||||
stopStream = null
|
||||
project.value = null
|
||||
events.value = []
|
||||
try {
|
||||
const cached = await readCachedEvents(id).catch(() => [])
|
||||
const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest, loadedModels] = await Promise.all([
|
||||
api<Project>(`/api/projects/${id}`),
|
||||
api<ProjectFile[]>(`/api/projects/${id}/files`),
|
||||
api<Artifact[]>(`/api/projects/${id}/artifacts`),
|
||||
api<PlanView | null>(`/api/projects/${id}/plan`),
|
||||
api<(RunSummary & { pendingInterrupt?: string }) | null>(`/api/projects/${id}/runs/latest`),
|
||||
// 模型摘要只服务于右侧信息栏;加载失败不能阻断项目主体和历史事件恢复。
|
||||
api<ModelOption[]>('/api/models').catch(() => [])
|
||||
])
|
||||
const loadedEvents = await fetchMissingEvents(id, cached)
|
||||
if (version !== loadVersion || id !== projectId.value) return
|
||||
project.value = loadedProject
|
||||
files.value = loadedFiles
|
||||
artifacts.value = loadedArtifacts
|
||||
plan.value = loadedPlan
|
||||
events.value = loadedEvents
|
||||
runStatus.value = latest?.status || ''
|
||||
currentModelConfigId.value = latest?.modelConfigId || ''
|
||||
modelCatalog.value = loadedModels
|
||||
pendingAsk.value = parseAsk(latest?.pendingInterrupt)
|
||||
startStream(id, version)
|
||||
} finally {
|
||||
if (version === loadVersion) historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingEvents(id: string, initial: AgentEvent[]) {
|
||||
let result = initial
|
||||
let cursor = result.at(-1)?.id || 0
|
||||
const pending: AgentEvent[] = []
|
||||
while (true) {
|
||||
const batch = await api<AgentEvent[]>(`/api/projects/${id}/events?after=${cursor}`)
|
||||
if (!batch.length) break
|
||||
pending.push(...batch)
|
||||
await cacheEvents(batch).catch(() => undefined)
|
||||
cursor = batch.at(-1)!.id
|
||||
if (batch.length < 1000) break
|
||||
}
|
||||
if (pending.length) result = appendUniqueEvents(result, pending)
|
||||
return result
|
||||
}
|
||||
|
||||
function mergeEvents(batch: AgentEvent[]) {
|
||||
events.value = appendUniqueEvents(events.value, batch)
|
||||
}
|
||||
|
||||
function applyEvents(batch: AgentEvent[], id = projectId.value, version = loadVersion) {
|
||||
if (id !== projectId.value || version !== loadVersion) return
|
||||
mergeEvents(batch)
|
||||
let projectChanged = false
|
||||
let planChanged = false
|
||||
let artifactsChanged = false
|
||||
for (const event of batch) {
|
||||
if (event.type === 'RUN_STARTED') {
|
||||
runStatus.value = 'RUNNING'
|
||||
pendingAsk.value = null
|
||||
}
|
||||
if (event.type === 'ASK_REQUESTED') {
|
||||
pendingAsk.value = event.payload
|
||||
runStatus.value = 'WAITING_INPUT'
|
||||
projectChanged = true
|
||||
if (event.payload.kind === 'planning') planChanged = true
|
||||
}
|
||||
if (event.type === 'ASK_RESPONDED') pendingAsk.value = null
|
||||
if (event.type === 'RUN_FINISHED') {
|
||||
if (event.payload.outcome === 'CANCELLED') runStatus.value = 'INTERRUPTED'
|
||||
else if (event.payload.outcome !== 'INTERRUPT') runStatus.value = 'COMPLETED'
|
||||
if (event.payload.outcome !== 'INTERRUPT') projectChanged = true
|
||||
}
|
||||
if (event.type === 'RUN_ERROR') {
|
||||
runStatus.value = 'FAILED'
|
||||
pendingAsk.value = null
|
||||
projectChanged = true
|
||||
}
|
||||
if (event.type === 'ARTIFACT_PUBLISHED') artifactsChanged = true
|
||||
}
|
||||
if (projectChanged) void refreshProject(id, version)
|
||||
if (planChanged) void refreshPlan(id, version)
|
||||
if (artifactsChanged) void refreshArtifacts(id, version)
|
||||
}
|
||||
|
||||
function startStream(id: string, version: number) {
|
||||
const cursor = events.value.at(-1)?.id || 0
|
||||
stopStream = streamEvents(id, cursor, batch => {
|
||||
applyEvents(batch, id, version)
|
||||
void cacheEvents(batch)
|
||||
if (version === loadVersion) streamError.value = ''
|
||||
}, error => {
|
||||
if (version === loadVersion && id === projectId.value) streamError.value = error.message
|
||||
})
|
||||
}
|
||||
|
||||
async function upload(options: { file: File; onSuccess: (value: unknown) => void; onError: (error: Error) => void }) {
|
||||
try {
|
||||
const file = await uploadFile(options.file)
|
||||
options.onSuccess(file)
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error('上传失败')
|
||||
options.onError(failure)
|
||||
ElMessage.error(failure.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(source: File, relativePath?: string) {
|
||||
const form = new FormData()
|
||||
form.append('file', source)
|
||||
if (relativePath) form.append('relativePath', relativePath)
|
||||
const file = await api<ProjectFile>(`/api/projects/${projectId.value}/files`, { method: 'POST', body: form })
|
||||
files.value = [...files.value, file].sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'zh-CN'))
|
||||
return file
|
||||
}
|
||||
|
||||
async function uploadFolder(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = Array.from(input.files || []).filter(file => {
|
||||
const path = file.webkitRelativePath || file.name
|
||||
const name = path.split('/').at(-1) || ''
|
||||
return name !== '.DS_Store' && !name.startsWith('._')
|
||||
})
|
||||
input.value = ''
|
||||
if (!selected.length || folderUploading.value) return
|
||||
|
||||
folderUploading.value = true
|
||||
let cursor = 0
|
||||
let uploaded = 0
|
||||
const failures: string[] = []
|
||||
const worker = async () => {
|
||||
while (cursor < selected.length) {
|
||||
const file = selected[cursor++]!
|
||||
try {
|
||||
await uploadFile(file, file.webkitRelativePath || file.name)
|
||||
uploaded++
|
||||
} catch (error) {
|
||||
failures.push(error instanceof Error ? error.message : '上传失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.all(Array.from({ length: Math.min(4, selected.length) }, worker))
|
||||
if (uploaded) ElMessage.success(`已上传 ${uploaded} 个文件`)
|
||||
if (failures.length) ElMessage.error(`${failures.length} 个文件上传失败:${failures[0]}`)
|
||||
} finally {
|
||||
folderUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startCheck() {
|
||||
if (loading.value || folderUploading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
|
||||
syncRunState(run)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '启动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
if (plan.value?.status === 'CONFIRMED') {
|
||||
loading.value = true
|
||||
try {
|
||||
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
|
||||
syncRunState(run)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '重试失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await startCheck()
|
||||
}
|
||||
|
||||
async function stopRun() {
|
||||
if (!running.value || controlLoading.value) return
|
||||
controlLoading.value = true
|
||||
try {
|
||||
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
|
||||
syncRunState(run)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止失败')
|
||||
} finally {
|
||||
controlLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开恢复或切换模型对话框,并从数据库重新读取当前启用的模型。
|
||||
* 优先保留 Run 已绑定的模型;如果该模型已停用,则退回当前默认模型或首个可用模型。
|
||||
*/
|
||||
async function openModelPicker(mode: 'resume' | 'switch') {
|
||||
if (controlLoading.value || modelPickerLoading.value) return
|
||||
modelPickerMode.value = mode
|
||||
modelPickerLoading.value = true
|
||||
try {
|
||||
const models = await api<ModelOption[]>('/api/models')
|
||||
modelCatalog.value = 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 endpoint = modelPickerMode.value === 'switch' ? 'switch-model' : 'resume'
|
||||
const run = await api<RunSummary>(`/api/projects/${projectId.value}/runs/${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ modelConfigId: selectedModelConfigId.value })
|
||||
})
|
||||
syncRunState(run, selectedModelConfigId.value)
|
||||
modelPickerVisible.value = false
|
||||
} catch (error) {
|
||||
const fallback = modelPickerMode.value === 'switch' ? '切换失败' : '继续失败'
|
||||
ElMessage.error(error instanceof Error ? error.message : fallback)
|
||||
} finally {
|
||||
controlLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (!project.value || running.value || deleting.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除“${project.value.companyName}”及全部材料和执行记录?`,
|
||||
'删除项目',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const id = projectId.value
|
||||
deleting.value = true
|
||||
try {
|
||||
await api(`/api/projects/${id}`, { method: 'DELETE' })
|
||||
loadVersion++
|
||||
stopStream?.()
|
||||
stopStream = null
|
||||
localStorage.removeItem(`plan-draft:${plan.value?.id || ''}`)
|
||||
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
|
||||
try {
|
||||
await deleteCachedEvents(id)
|
||||
} catch {
|
||||
ElMessage.warning('项目已删除,本地记录清理失败')
|
||||
}
|
||||
project.value = null
|
||||
events.value = []
|
||||
await router.replace('/projects')
|
||||
emit('projects-changed')
|
||||
ElMessage.success('项目已删除')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPlan(value: Record<string, unknown>) {
|
||||
if (!plan.value || loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api<{ plan: PlanView; run: RunSummary }>(`/api/projects/${projectId.value}/plan/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: plan.value.id, plan: value })
|
||||
})
|
||||
localStorage.removeItem(`plan-draft:${plan.value.id}`)
|
||||
plan.value = result.plan
|
||||
pendingAsk.value = null
|
||||
syncRunState(result.run)
|
||||
await refreshProject()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '规划确认失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMaterials(value: Record<string, unknown>) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const run = await api<RunSummary>(`/api/projects/${projectId.value}/material/confirm`, {
|
||||
method: 'POST', body: JSON.stringify(value)
|
||||
})
|
||||
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
|
||||
pendingAsk.value = null
|
||||
syncRunState(run)
|
||||
await refreshProject()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '材料确认失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function parseAsk(value?: string) {
|
||||
if (!value) return null
|
||||
try { return JSON.parse(value) as Record<string, any> } catch { return null }
|
||||
}
|
||||
|
||||
async function refreshPlan(id = projectId.value, version = loadVersion) {
|
||||
const value = await api<PlanView | null>(`/api/projects/${id}/plan`)
|
||||
if (version === loadVersion && id === projectId.value) plan.value = value
|
||||
}
|
||||
async function refreshProject(id = projectId.value, version = loadVersion) {
|
||||
const value = await api<Project>(`/api/projects/${id}`)
|
||||
if (version === loadVersion && id === projectId.value) project.value = value
|
||||
}
|
||||
async function refreshArtifacts(id = projectId.value, version = loadVersion) {
|
||||
const value = await api<Artifact[]>(`/api/projects/${id}/artifacts`)
|
||||
if (version === loadVersion && id === projectId.value) artifacts.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。
|
||||
* 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。
|
||||
*/
|
||||
function updateScrollState() {
|
||||
const currentScrollY = window.scrollY
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
const scrollingUp = hasObservedScrollPosition && currentScrollY < previousScrollY
|
||||
const completingFollowScroll = pendingFollowScrollTarget !== null
|
||||
|
||||
if (completingFollowScroll) {
|
||||
// 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差)
|
||||
// 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。
|
||||
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||
if (reachedRequestedTarget) {
|
||||
pendingFollowScrollTarget = null
|
||||
followsLatestOutput.value = true
|
||||
}
|
||||
} else if (scrollingUp && userScrollIntent !== 'down') {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
|
||||
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
}
|
||||
previousScrollY = currentScrollY
|
||||
hasObservedScrollPosition = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 将窗口移动到当前文档底部,并记录本次程序滚动的目标位置。
|
||||
*
|
||||
* <p>浏览器派发 scroll 事件晚于 scrollTo 调用,大文档还可能先派发一个或多个
|
||||
* 尚未到达底部的中间事件。目标位置必须单独保存,不能写入 previousScrollY,
|
||||
* 否则中间坐标会因为小于目标值而被误判为用户向上滚动。</p>
|
||||
*/
|
||||
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
|
||||
// 所有程序滚动(箭头点击、事件更新、ResizeObserver)统一只在真正抵达目标时
|
||||
// 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。
|
||||
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||
? null
|
||||
: targetScrollY
|
||||
hasObservedScrollPosition = true
|
||||
const options: ScrollToOptions = { top: scrollHeight }
|
||||
if (behavior) options.behavior = behavior
|
||||
window.scrollTo(options)
|
||||
}
|
||||
|
||||
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
|
||||
function scrollToBottom() {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
touchStartY = null
|
||||
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
|
||||
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
|
||||
moveViewportToLatestOutput('auto')
|
||||
}
|
||||
|
||||
/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null
|
||||
return Boolean(element && (
|
||||
(element instanceof HTMLElement && element.isContentEditable)
|
||||
|| element.closest('input, textarea, select, option, button, [contenteditable="true"]')
|
||||
))
|
||||
}
|
||||
|
||||
/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */
|
||||
function restoreFollowIfAtLatest() {
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 在浏览器派发 scroll 之前捕获用户的真实上滚意图。
|
||||
*
|
||||
* <p>输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll
|
||||
* 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。</p>
|
||||
*/
|
||||
function handleUserWheel(event: WheelEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
if (event.deltaY !== 0) {
|
||||
// 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll
|
||||
// 事件可能在用户上滚后再次把页面状态恢复到底部。
|
||||
pendingFollowScrollTarget = null
|
||||
}
|
||||
if (event.deltaY < 0) {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (event.deltaY > 0) {
|
||||
userScrollIntent = 'down'
|
||||
// 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */
|
||||
function handleUserScrollbarDrag(event: MouseEvent) {
|
||||
const scrollbarStart = document.documentElement.clientWidth
|
||||
if (event.clientX >= scrollbarStart) {
|
||||
followsLatestOutput.value = false
|
||||
pendingFollowScrollTarget = null
|
||||
scrollbarDragLastY = event.clientY
|
||||
scrollbarDragDirection = 'none'
|
||||
scrollbarDragActive = true
|
||||
// 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。
|
||||
userScrollIntent = 'up'
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */
|
||||
function handleUserScrollbarMove(event: MouseEvent) {
|
||||
if (!scrollbarDragActive || scrollbarDragLastY === null) return
|
||||
if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up'
|
||||
else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down'
|
||||
scrollbarDragLastY = event.clientY
|
||||
}
|
||||
|
||||
/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */
|
||||
function handleUserMouseUp() {
|
||||
if (!scrollbarDragActive) return
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
scrollbarDragActive = false
|
||||
scrollbarDragLastY = null
|
||||
const draggedUp = scrollbarDragDirection === 'up'
|
||||
scrollbarDragDirection = 'none'
|
||||
if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */
|
||||
function handleUserTouchStart(event: TouchEvent) {
|
||||
touchStartY = event.touches[0]?.clientY ?? null
|
||||
}
|
||||
|
||||
/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */
|
||||
function handleUserTouchMove(event: TouchEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
pendingFollowScrollTarget = null
|
||||
const currentY = event.touches[0]?.clientY
|
||||
if (touchStartY === null || currentY === undefined) {
|
||||
// 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
return
|
||||
}
|
||||
const pageScrollDelta = touchStartY - currentY
|
||||
if (pageScrollDelta < 0) {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (pageScrollDelta > 0) {
|
||||
userScrollIntent = 'down'
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */
|
||||
function handleUserTouchEnd() {
|
||||
touchStartY = null
|
||||
}
|
||||
|
||||
/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */
|
||||
function handleUserKeydown(event: KeyboardEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') {
|
||||
followsLatestOutput.value = false
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'up'
|
||||
} else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') {
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'down'
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||
watch(() => events.value.length, async () => {
|
||||
await nextTick()
|
||||
// nextTick 后重新读取实时状态,确保用户在本轮渲染期间向上滚动也能立即中止跟随。
|
||||
if (followsLatestOutput.value) moveViewportToLatestOutput()
|
||||
})
|
||||
watch(outputContainer, (current, previous) => {
|
||||
if (!outputResizeObserver) return
|
||||
if (previous) outputResizeObserver.unobserve(previous)
|
||||
if (current) outputResizeObserver.observe(current)
|
||||
}, { flush: 'post' })
|
||||
onMounted(() => {
|
||||
/**
|
||||
* Markstream 的平滑流式渲染会在 Vue nextTick 结束后继续逐帧增加内容高度。
|
||||
* 监听真实输出容器的尺寸变化,才能在同一批事件的渲染动画期间持续贴住底部。
|
||||
*/
|
||||
outputResizeObserver = new ResizeObserver(() => {
|
||||
if (followsLatestOutput.value) moveViewportToLatestOutput()
|
||||
})
|
||||
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
|
||||
|
||||
window.addEventListener('scroll', updateScrollState, { passive: true })
|
||||
window.addEventListener('wheel', handleUserWheel, { passive: true })
|
||||
window.addEventListener('mousedown', handleUserScrollbarDrag)
|
||||
window.addEventListener('mousemove', handleUserScrollbarMove)
|
||||
window.addEventListener('mouseup', handleUserMouseUp)
|
||||
window.addEventListener('touchstart', handleUserTouchStart, { passive: true })
|
||||
window.addEventListener('touchmove', handleUserTouchMove, { passive: true })
|
||||
window.addEventListener('touchend', handleUserTouchEnd, { passive: true })
|
||||
window.addEventListener('touchcancel', handleUserTouchEnd, { passive: true })
|
||||
window.addEventListener('keydown', handleUserKeydown)
|
||||
updateScrollState()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
loadVersion++
|
||||
stopStream?.()
|
||||
outputResizeObserver?.disconnect()
|
||||
outputResizeObserver = null
|
||||
window.removeEventListener('scroll', updateScrollState)
|
||||
window.removeEventListener('wheel', handleUserWheel)
|
||||
window.removeEventListener('mousedown', handleUserScrollbarDrag)
|
||||
window.removeEventListener('mousemove', handleUserScrollbarMove)
|
||||
window.removeEventListener('mouseup', handleUserMouseUp)
|
||||
window.removeEventListener('touchstart', handleUserTouchStart)
|
||||
window.removeEventListener('touchmove', handleUserTouchMove)
|
||||
window.removeEventListener('touchend', handleUserTouchEnd)
|
||||
window.removeEventListener('touchcancel', handleUserTouchEnd)
|
||||
window.removeEventListener('keydown', handleUserKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="project" class="project-page">
|
||||
<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="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="project-workspace">
|
||||
<main class="workspace-main">
|
||||
<ol class="project-stage-bar" aria-label="项目执行阶段">
|
||||
<li
|
||||
v-for="(stage, index) in projectStages"
|
||||
:key="stage.key"
|
||||
class="project-stage"
|
||||
:class="`is-${stage.state}`"
|
||||
:aria-current="stage.state === 'current' ? 'step' : undefined"
|
||||
>
|
||||
<span class="stage-marker" aria-hidden="true">{{ stage.state === 'complete' ? '✓' : index + 1 }}</span>
|
||||
<span class="stage-copy"><strong>{{ stage.label }}</strong><small>{{ stage.description }}</small></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div ref="outputContainer" 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">
|
||||
<h2>企业材料</h2>
|
||||
<el-upload drag multiple :show-file-list="false" :http-request="upload" class="upload-box">
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
<p>拖入企业材料,或点击上传</p>
|
||||
<small>支持 PDF、DOCX、XLSX、PPTX、图片</small>
|
||||
</el-upload>
|
||||
<div class="folder-upload">
|
||||
<el-button :loading="folderUploading" @click="folderInput?.click()">上传文件夹</el-button>
|
||||
<input ref="folderInput" type="file" multiple webkitdirectory aria-label="上传文件夹" @change="uploadFolder" />
|
||||
</div>
|
||||
<div v-if="files.length" class="file-chips">
|
||||
<span v-for="file in files.slice(0, 12)" :key="file.id" :title="file.relativePath"><Document />{{ file.name }}</span>
|
||||
<small v-if="files.length > 12">共 {{ files.length }} 个文件</small>
|
||||
</div>
|
||||
<el-button type="primary" size="large" :loading="loading" :disabled="folderUploading" @click="startCheck">开始材料检验</el-button>
|
||||
</section>
|
||||
|
||||
<!-- AgentTimeline 继续消费原始流式事件,视觉布局不会替换或截断事件内容。 -->
|
||||
<AgentTimeline v-if="events.length" :events="events" :running="running" :project-id="projectId" />
|
||||
<MaterialAskCard
|
||||
v-if="waitingMaterials"
|
||||
:ask="pendingAsk as any"
|
||||
:loading="loading"
|
||||
:upload-file="uploadFile"
|
||||
@confirm="confirmMaterials"
|
||||
/>
|
||||
<PlanCard
|
||||
v-if="waitingPlan"
|
||||
:plan-id="plan!.id"
|
||||
:plan="plan!.plan"
|
||||
:loading="loading"
|
||||
@confirm="confirmPlan"
|
||||
/>
|
||||
|
||||
<section v-if="artifacts.length" class="artifact-card">
|
||||
<div v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
|
||||
<Document />
|
||||
<div><strong>{{ artifact.name }}</strong><small>{{ Math.ceil(artifact.sizeBytes / 1024) }} KB</small></div>
|
||||
<a :href="`/api/artifacts/${artifact.id}/download`">下载</a>
|
||||
</div>
|
||||
</section>
|
||||
<el-button v-if="project.status === 'FAILED' && !running" class="retry-button" :loading="loading" @click="retry">重新运行</el-button>
|
||||
<button v-if="streamError" class="stream-error" @click="load(projectId)">连接中断,点击重连</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="project-context-panel" aria-label="项目上下文">
|
||||
<section class="context-card stage-context-card">
|
||||
<h2>当前阶段</h2>
|
||||
<div class="context-stage-summary">
|
||||
<span class="context-stage-index">{{ currentStageIndex + 1 }}</span>
|
||||
<div><strong>{{ currentStageLabel }}</strong><small>{{ statusLabel }}</small></div>
|
||||
</div>
|
||||
<dl class="context-definition-list">
|
||||
<div><dt>最近更新</dt><dd>{{ updatedAtLabel }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-card-heading"><h2>当前模型</h2><span v-if="running" class="context-status">使用中</span></div>
|
||||
<strong class="current-model-name">{{ currentModelLabel }}</strong>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-card-heading"><h2>企业材料</h2><span>{{ files.length }} 个文件</span></div>
|
||||
<ul v-if="files.length" class="context-file-list">
|
||||
<li v-for="file in files.slice(0, 4)" :key="file.id"><Document /><span :title="file.relativePath">{{ file.name }}</span></li>
|
||||
</ul>
|
||||
<p v-else class="context-empty">尚未上传材料</p>
|
||||
<small v-if="files.length > 4" class="context-more">另有 {{ files.length - 4 }} 个文件</small>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-card-heading"><h2>生成文件</h2><span>{{ artifacts.length }} 个文件</span></div>
|
||||
<ul v-if="artifacts.length" class="context-file-list artifact-context-list">
|
||||
<li v-for="artifact in artifacts.slice(0, 3)" :key="artifact.id">
|
||||
<Document />
|
||||
<a :href="`/api/artifacts/${artifact.id}/download`" :title="artifact.name">{{ artifact.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="context-empty">完成后将在这里显示交付物</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<button
|
||||
v-if="!followsLatestOutput"
|
||||
class="back-to-bottom"
|
||||
type="button"
|
||||
aria-label="转到最新输出"
|
||||
title="转到最新输出"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<ArrowDown aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
<section v-else class="empty-main">新建或选择一个项目</section>
|
||||
</template>
|
||||
89
web-ui/packages/agent/src/pages/SkillsPage.vue
Normal file
89
web-ui/packages/agent/src/pages/SkillsPage.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
interface SkillView {
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
sourceType: 'BUILTIN' | 'IMPORTED'
|
||||
enabled: boolean
|
||||
readOnly: boolean
|
||||
validationStatus: string
|
||||
}
|
||||
interface SkillDetail { view: SkillView; content: string; resources: string[] }
|
||||
|
||||
const skills = ref<SkillView[]>([])
|
||||
const selectedName = ref('')
|
||||
const detail = ref<SkillDetail | null>(null)
|
||||
const tab = ref('content')
|
||||
const selected = computed(() => skills.value.find(skill => skill.name === selectedName.value))
|
||||
|
||||
async function load() {
|
||||
skills.value = await api<SkillView[]>('/api/skills')
|
||||
await select(selectedName.value || skills.value[0]?.name || '')
|
||||
}
|
||||
async function select(name: string) {
|
||||
selectedName.value = name
|
||||
detail.value = name ? await api<SkillDetail>(`/api/skills/${encodeURIComponent(name)}`) : null
|
||||
}
|
||||
async function toggle(skill: SkillView) {
|
||||
const enabled = !skill.enabled
|
||||
skill.enabled = enabled
|
||||
try {
|
||||
await api(`/api/skills/${encodeURIComponent(skill.name)}/${enabled ? 'enable' : 'disable'}`, { method: 'POST' })
|
||||
} catch (error) {
|
||||
skill.enabled = !enabled
|
||||
ElMessage.error(error instanceof Error ? error.message : '设置失败')
|
||||
}
|
||||
}
|
||||
async function importSkill(options: { file: File; onSuccess: (value: unknown) => void; onError: (error: Error) => void }) {
|
||||
const data = new FormData()
|
||||
data.append('file', options.file)
|
||||
try {
|
||||
const value = await api('/api/skills/import', { method: 'POST', body: data })
|
||||
options.onSuccess(value)
|
||||
ElMessage.success('已导入')
|
||||
await load()
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error('导入失败')
|
||||
options.onError(failure)
|
||||
ElMessage.error(failure.message)
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-page skills-page">
|
||||
<header class="skills-header">
|
||||
<div><h1>Skills</h1><p>导入、查看并控制 Agent 可用的技能</p></div>
|
||||
<el-upload :show-file-list="false" accept=".zip" :http-request="importSkill">
|
||||
<span class="import-skill-trigger"><el-icon><Upload /></el-icon>导入 Skill</span>
|
||||
</el-upload>
|
||||
</header>
|
||||
<div class="skill-grid">
|
||||
<aside class="skill-list">
|
||||
<h2>已导入 {{ skills.length }} 个</h2>
|
||||
<div v-for="skill in skills" :key="skill.name" class="skill-item" :class="{ selected: selectedName === skill.name }">
|
||||
<button class="skill-select" @click="select(skill.name)">
|
||||
<span><strong>{{ skill.name }}</strong><small>{{ skill.description }}</small></span>
|
||||
<small>{{ skill.version || '—' }}</small>
|
||||
<i>有效</i>
|
||||
</button>
|
||||
<el-switch :model-value="skill.enabled" :aria-label="`启用 ${skill.name}`" @click.stop="toggle(skill)" />
|
||||
</div>
|
||||
</aside>
|
||||
<article v-if="detail && selected" class="skill-detail">
|
||||
<div class="skill-title"><div><h2>{{ selected.name }}</h2><span class="tag">{{ selected.version || '未标版本' }}</span><span class="tag">只读</span><span class="tag green">{{ selected.enabled ? '已启用' : '已停用' }}</span></div><el-button @click="toggle(selected)">{{ selected.enabled ? '停用' : '启用' }}</el-button></div>
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="SKILL.md" name="content"><pre>{{ detail.content }}</pre></el-tab-pane>
|
||||
<el-tab-pane label="资源" name="resources"><div class="resource-list"><span v-for="resource in detail.resources" :key="resource">{{ resource }}</span><p v-if="!detail.resources.length">无资源文件</p></div></el-tab-pane>
|
||||
<el-tab-pane label="校验" name="validation"><p class="validation-ok">结构有效 · 未发现错误</p></el-tab-pane>
|
||||
</el-tabs>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user