init
This commit is contained in:
13
web-ui/index.html
Normal file
13
web-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>智造申报 Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3608
web-ui/package-lock.json
generated
Normal file
3608
web-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
web-ui/package.json
Normal file
29
web-ui/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "smart-factory-approval-agent-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.58",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@lucide/vue": "^1.35.0",
|
||||
"element-plus": "2.14.5",
|
||||
"markstream-vue": "2.0.6",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.2.2"
|
||||
}
|
||||
}
|
||||
106
web-ui/src/App.vue
Normal file
106
web-ui/src/App.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box, Folder, MagicStick } from '@element-plus/icons-vue'
|
||||
import { api, type Project } from './api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projects = ref<Project[]>([])
|
||||
const createOpen = ref(false)
|
||||
const companyName = ref('')
|
||||
const level = ref<'ADVANCED' | 'EXCELLENT'>('ADVANCED')
|
||||
const creating = ref(false)
|
||||
|
||||
const isLogin = computed(() => route.path === '/login')
|
||||
const projectRoute = computed(() => route.path.startsWith('/projects'))
|
||||
|
||||
async function loadProjects() {
|
||||
if (isLogin.value) return
|
||||
try {
|
||||
projects.value = await api<Project[]>('/api/projects')
|
||||
if (route.path === '/projects' && projects.value.length) {
|
||||
await router.replace(`/projects/${projects.value[0].id}`)
|
||||
}
|
||||
} catch {
|
||||
// api() 已负责跳转登录。
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!companyName.value.trim() || creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
const project = await api<Project>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ companyName: companyName.value.trim(), applicationLevel: level.value })
|
||||
})
|
||||
projects.value.unshift(project)
|
||||
createOpen.value = false
|
||||
companyName.value = ''
|
||||
await router.push(`/projects/${project.id}`)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProjects)
|
||||
watch(isLogin, value => { if (!value) void loadProjects() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-if="isLogin" />
|
||||
<div v-else class="app-shell">
|
||||
<nav class="rail" aria-label="主导航">
|
||||
<div class="brand"><Folder /></div>
|
||||
<RouterLink to="/projects" :class="{ active: projectRoute }" aria-label="项目">
|
||||
<Folder /><span>项目</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/models" :class="{ active: route.path === '/models' }" aria-label="模型">
|
||||
<Box /><span>模型</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/skills" :class="{ active: route.path === '/skills' }" aria-label="Skills">
|
||||
<MagicStick /><span>Skills</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<aside v-if="projectRoute" class="project-list">
|
||||
<div class="aside-title">
|
||||
<h2>项目</h2>
|
||||
<button class="icon-button" aria-label="新建项目" @click="createOpen = true">+</button>
|
||||
</div>
|
||||
<p class="aside-label">当前会话</p>
|
||||
<RouterLink
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:to="`/projects/${project.id}`"
|
||||
class="project-item"
|
||||
:class="{ selected: route.params.id === project.id }"
|
||||
>
|
||||
<span>{{ project.companyName }}</span>
|
||||
<time>{{ new Date(project.updatedAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 15) }}</time>
|
||||
</RouterLink>
|
||||
<div v-if="!projects.length" class="aside-empty">暂无项目</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-view"><RouterView @projects-changed="loadProjects" /></main>
|
||||
|
||||
<el-dialog v-model="createOpen" title="新建项目" width="420px" align-center>
|
||||
<el-form label-position="top" @submit.prevent="createProject">
|
||||
<el-form-item label="企业名称" required>
|
||||
<el-input v-model="companyName" autofocus placeholder="输入企业全称" @keyup.enter="createProject" />
|
||||
</el-form-item>
|
||||
<el-form-item label="申报等级">
|
||||
<el-radio-group v-model="level">
|
||||
<el-radio-button value="ADVANCED">先进级</el-radio-button>
|
||||
<el-radio-button value="EXCELLENT">卓越级</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" :disabled="!companyName.trim()" @click="createProject">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
142
web-ui/src/api.ts
Normal file
142
web-ui/src/api.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export interface Project {
|
||||
id: string
|
||||
companyName: string
|
||||
projectName: string
|
||||
threadId: string
|
||||
applicationLevel: 'ADVANCED' | 'EXCELLENT'
|
||||
status: 'MATERIAL_CHECK' | 'PLANNING' | 'WRITING' | 'DELIVERED' | 'FAILED' | 'ARCHIVED'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ProjectFile {
|
||||
id: string
|
||||
name: string
|
||||
relativePath: string
|
||||
extension: string
|
||||
sizeBytes: number
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AgentEvent {
|
||||
id: number
|
||||
projectId: string
|
||||
runId: string | null
|
||||
type: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PlanView {
|
||||
id: string
|
||||
status: string
|
||||
version: number
|
||||
plan: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: string
|
||||
name: string
|
||||
kind: string
|
||||
sizeBytes: number
|
||||
metadataJson: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
let csrf: { token: string; headerName: string } | null = null
|
||||
|
||||
async function ensureCsrf() {
|
||||
if (!csrf) csrf = await raw('/api/auth/csrf')
|
||||
return csrf!
|
||||
}
|
||||
|
||||
async function raw(path: string, options: RequestInit = {}) {
|
||||
const response = await fetch(path, { credentials: 'include', ...options })
|
||||
if (response.status === 401 && path !== '/api/auth/login') {
|
||||
location.assign('/login')
|
||||
throw new Error('登录状态已失效')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ message: `请求失败(${response.status})` }))
|
||||
throw new Error(body.message || `请求失败(${response.status})`)
|
||||
}
|
||||
if (response.status === 204) return null
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const headers = new Headers(options.headers)
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const token = await ensureCsrf()
|
||||
headers.set(token.headerName, token.token)
|
||||
}
|
||||
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
|
||||
return raw(path, { ...options, headers })
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
csrf = null
|
||||
return raw('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
}
|
||||
|
||||
export function streamEvents(
|
||||
projectId: string,
|
||||
after: number,
|
||||
onEvents: (events: AgentEvent[]) => void,
|
||||
onError: (error: Error) => void
|
||||
) {
|
||||
const controller = new AbortController()
|
||||
let cursor = after
|
||||
let stopped = false
|
||||
|
||||
const connect = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/events/stream?after=${cursor}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/x-ndjson' }
|
||||
})
|
||||
if (response.status === 401) {
|
||||
location.assign('/login')
|
||||
return
|
||||
}
|
||||
if (!response.ok || !response.body) throw new Error(`事件流连接失败(${response.status})`)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
const batch = lines
|
||||
.filter(Boolean)
|
||||
.map(line => JSON.parse(line) as AgentEvent)
|
||||
.filter(event => event.type !== 'HEARTBEAT')
|
||||
if (batch.length) {
|
||||
cursor = batch.at(-1)!.id
|
||||
onEvents(batch)
|
||||
}
|
||||
}
|
||||
if (!stopped) await new Promise(resolve => setTimeout(resolve, 1200))
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
onError(error instanceof Error ? error : new Error('事件流异常'))
|
||||
await new Promise(resolve => setTimeout(resolve, 1200))
|
||||
}
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
return () => {
|
||||
stopped = true
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
131
web-ui/src/components/AgentTimeline.test.ts
Normal file
131
web-ui/src/components/AgentTimeline.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
|
||||
import { ElImageViewer } from 'element-plus'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AgentTimeline from './AgentTimeline.vue'
|
||||
|
||||
describe('AgentTimeline', () => {
|
||||
it('运行中但没有真实增量时不渲染占位消息', () => {
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { events: [], running: true, projectId: 'p' } })
|
||||
|
||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('Agent 正在继续执行')
|
||||
expect(wrapper.text()).not.toContain('思考中')
|
||||
})
|
||||
|
||||
it('终态不渲染暂停输出占位提示', () => {
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { events: [], running: false, projectId: 'p' } })
|
||||
|
||||
expect(wrapper.text()).not.toContain('Agent 已暂停输出')
|
||||
expect(wrapper.text()).not.toContain('Agent 正在执行')
|
||||
})
|
||||
|
||||
it('展示真实的模型重连和用户停止事件', () => {
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { running: false, projectId: 'p', events: [
|
||||
{ id: 1, projectId: 'p', runId: 'r', type: 'MODEL_RETRY', payload: { attempt: 2, maxAttempts: 5 }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'tool-1', toolCallName: 'execute' }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 3, projectId: 'p', runId: 'r', type: 'RUN_FINISHED', payload: { outcome: 'CANCELLED' }, createdAt: '2026-08-24T10:00:01Z' },
|
||||
{ id: 4, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'late-tool', toolCallName: 'write_file' }, createdAt: '2026-08-24T10:00:02Z' }
|
||||
] } })
|
||||
|
||||
expect(wrapper.text()).toContain('模型连接中断,正在重连(2/5)')
|
||||
expect(wrapper.text()).toContain('已停止')
|
||||
expect(wrapper.text()).toContain('Agent 已停止,当前上下文已保留')
|
||||
expect(wrapper.text()).not.toContain('运行中')
|
||||
})
|
||||
|
||||
it('完成后展示已调用的工具和 Skill 名称', () => {
|
||||
const events = [
|
||||
{ id: 1, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'a', toolCallName: 'read_file' }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'a', delta: '{"path":"inputs/report.md"}' }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', payload: { toolCallId: 'a' }, createdAt: '2026-08-24T10:00:01Z' },
|
||||
{ id: 4, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'b', toolCallName: 'load_skill_through_path' }, createdAt: '2026-08-24T10:00:02Z' },
|
||||
{ id: 5, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'b', delta: '{"skillId":"pdf_imported"}' }, createdAt: '2026-08-24T10:00:02Z' },
|
||||
{ id: 6, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', payload: { toolCallId: 'b' }, createdAt: '2026-08-24T10:00:03Z' },
|
||||
{ id: 7, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'c', toolCallName: 'write_file' }, createdAt: '2026-08-24T10:00:04Z' },
|
||||
{ id: 8, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'c', delta: '{"path":"work/material-check.json"}' }, createdAt: '2026-08-24T10:00:04Z' }
|
||||
]
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
|
||||
|
||||
expect(wrapper.text()).toContain('已读取 report.md 文件')
|
||||
expect(wrapper.text()).toContain('正在编辑 material-check.json 文件')
|
||||
expect(wrapper.text()).toContain('已调用 pdf Skill')
|
||||
})
|
||||
|
||||
it('每个 Run 只展示一次身份并安全渲染 Markdown 正文', async () => {
|
||||
const events = [
|
||||
{ id: 1, projectId: 'p', runId: 'r1', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm1', delta: '## 分析\n\n**关键结论**' }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 2, projectId: 'p', runId: 'r1', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm2', delta: '- 第一项\n- 第二项' }, createdAt: '2026-08-24T10:00:01Z' },
|
||||
{ id: 3, projectId: 'p', runId: 'r2', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm3', delta: '<script>alert(1)</script>' }, createdAt: '2026-08-24T10:00:02Z' }
|
||||
]
|
||||
const wrapper = mount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(wrapper.find('.agent-markdown h2').exists()).toBe(true))
|
||||
|
||||
expect(wrapper.findAll('.agent-avatar')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.flow-meta')).toHaveLength(2)
|
||||
expect(wrapper.find('.agent-markdown h2').text()).toBe('分析')
|
||||
expect(wrapper.find('.agent-markdown strong').text()).toBe('关键结论')
|
||||
expect(wrapper.findAll('.agent-markdown li')).toHaveLength(2)
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('完整展示正文并在浮层画廊中查看全部视觉结果', async () => {
|
||||
const body = '正文'.repeat(500)
|
||||
const result = 'document_view_result={"images":[{"index":1,"path":"work/tmp/document-view/a/render-1.png","sourcePath":"inputs/a.pdf","label":"第 1 页"},{"index":2,"path":"work/tmp/document-view/a/render-2.png","sourcePath":"inputs/a.pdf","label":"第 2 页"}],"errors":[]}'
|
||||
const events = [
|
||||
{ id: 1, projectId: 'p', runId: 'r', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm', delta: body }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'v', toolCallName: 'document_view' }, createdAt: '2026-08-24T10:00:01Z' },
|
||||
{ id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', payload: { toolCallId: 'v', content: result }, createdAt: '2026-08-24T10:00:02Z' }
|
||||
]
|
||||
const wrapper = mount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(wrapper.find('.agent-markdown').exists()).toBe(true))
|
||||
|
||||
expect(wrapper.text()).toContain(body)
|
||||
expect(wrapper.text()).not.toContain('展开完整输出')
|
||||
expect(wrapper.text()).toContain('已查看图片 · 2 张')
|
||||
expect(wrapper.findAll('.view-image-item')).toHaveLength(2)
|
||||
expect(wrapper.find('a[target="_blank"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.findAll<HTMLButtonElement>('.view-image-item')[1].trigger('click')
|
||||
expect(wrapper.findComponent(ElImageViewer).props('initialIndex')).toBe(1)
|
||||
expect(wrapper.findComponent(ElImageViewer).props('urlList')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('模型正文结束后延迟展示真实的结果整理状态', async () => {
|
||||
vi.useFakeTimers()
|
||||
const events = [
|
||||
{ id: 1, projectId: 'p', runId: 'r', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm', delta: '规划完成' }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 2, projectId: 'p', runId: 'r', type: 'TEXT_MESSAGE_END', payload: { messageId: 'm' }, createdAt: '2026-08-24T10:00:01Z' }
|
||||
]
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { events, running: true, projectId: 'p' } })
|
||||
|
||||
expect(wrapper.text()).not.toContain('正在整理结果')
|
||||
vi.advanceTimersByTime(500)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('正在整理结果')
|
||||
|
||||
await wrapper.setProps({ events: [...events, {
|
||||
id: 3, projectId: 'p', runId: 'r', type: 'ASK_REQUESTED', payload: { kind: 'planning' }, createdAt: '2026-08-24T10:00:02Z'
|
||||
}] })
|
||||
expect(wrapper.text()).not.toContain('正在整理结果')
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('材料确认后在时间线保留只读摘要', () => {
|
||||
const events = [
|
||||
{ id: 1, projectId: 'p', runId: 'r', type: 'ASK_REQUESTED', payload: { kind: 'material_check' }, createdAt: '2026-08-24T10:00:00Z' },
|
||||
{ id: 2, projectId: 'p', runId: 'r', type: 'ASK_RESPONDED', payload: { decisions: [
|
||||
{ id: 'a', action: 'ASSUMPTION' },
|
||||
{ id: 'b', action: 'PENDING_COMMENT' },
|
||||
{ id: 'c', action: 'UPLOADED' }
|
||||
] }, createdAt: '2026-08-24T10:00:01Z' }
|
||||
]
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
|
||||
|
||||
expect(wrapper.text()).toContain('已确认材料检验 · 1 项按规划假设继续 · 1 项待确认 · 1 项已补充')
|
||||
})
|
||||
})
|
||||
371
web-ui/src/components/AgentTimeline.vue
Normal file
371
web-ui/src/components/AgentTimeline.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElImageViewer } from 'element-plus'
|
||||
import { CircleCheck, Document, MagicStick } from '@element-plus/icons-vue'
|
||||
import { Bot, Brain, Images, Wrench } from '@lucide/vue'
|
||||
import type { AgentEvent } from '../api'
|
||||
|
||||
const MarkdownRender = defineAsyncComponent(() => import('markstream-vue'))
|
||||
|
||||
const props = defineProps<{ events: AgentEvent[]; running: boolean; projectId: string }>()
|
||||
const visibleLimit = ref(180)
|
||||
const previewIndex = ref<number | null>(null)
|
||||
const showFinalizing = ref(false)
|
||||
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
let finalizingTimer: number | undefined
|
||||
|
||||
type ActivityStatus = 'running' | 'done' | 'failed' | 'stopped'
|
||||
type ViewImage = { index: number; path: string; sourcePath: string; label: string }
|
||||
type FlowItem =
|
||||
| { key: string; runId: string | null; kind: 'message'; text: string; time: string; showIdentity: boolean; final: boolean }
|
||||
| { key: string; runId: string | null; kind: 'reasoning'; text: string; status: ActivityStatus; time: string }
|
||||
| { key: string; runId: string | null; kind: 'tool'; name: string; args: string; detail: string; images: ViewImage[]; status: ActivityStatus; time: string }
|
||||
| { key: string; kind: 'notice'; text: string; tone: 'success' | 'error' | 'info'; time: string }
|
||||
|
||||
const flow = computed<FlowItem[]>(() => {
|
||||
const items: FlowItem[] = []
|
||||
const messages = new Map<string, FlowItem & { kind: 'message' }>()
|
||||
const reasoning = new Map<string, FlowItem & { kind: 'reasoning' }>()
|
||||
const tools = new Map<string, FlowItem & { kind: 'tool' }>()
|
||||
const asks = new Map<string | null, Record<string, any>>()
|
||||
const terminalRuns = new Map<string | null, Exclude<ActivityStatus, 'running'>>()
|
||||
|
||||
for (const event of props.events) {
|
||||
const payload = event.payload || {}
|
||||
const eventKey = (id: unknown) => `${event.runId || 'none'}:${String(id)}`
|
||||
if (event.type === 'TEXT_MESSAGE_START') {
|
||||
const id = eventKey(payload.messageId || event.id)
|
||||
const item: FlowItem & { kind: 'message' } = {
|
||||
key: `m-${id}`, runId: event.runId, kind: 'message', text: '', time: formatTime(event.createdAt),
|
||||
showIdentity: false, final: false
|
||||
}
|
||||
messages.set(id, item)
|
||||
items.push(item)
|
||||
} else if (event.type === 'TEXT_MESSAGE_CONTENT' || event.type === 'TEXT_MESSAGE_CHUNK') {
|
||||
const id = eventKey(payload.messageId || 'current')
|
||||
let item = messages.get(id)
|
||||
if (!item) {
|
||||
item = {
|
||||
key: `m-${id}`, runId: event.runId, kind: 'message', text: '',
|
||||
time: formatTime(event.createdAt), showIdentity: false, final: false
|
||||
}
|
||||
messages.set(id, item)
|
||||
items.push(item)
|
||||
}
|
||||
item.text += String(payload.delta || payload.content || '')
|
||||
} else if (event.type === 'TEXT_MESSAGE_END') {
|
||||
const item = messages.get(eventKey(payload.messageId || 'current'))
|
||||
if (item) item.final = true
|
||||
} else if (event.type === 'REASONING_MESSAGE_START' || event.type === 'REASONING_START') {
|
||||
const id = eventKey(payload.messageId || payload.reasoningId || event.id)
|
||||
const item: FlowItem & { kind: 'reasoning' } = {
|
||||
key: `r-${id}`, runId: event.runId, kind: 'reasoning', text: '', status: 'running', time: formatTime(event.createdAt)
|
||||
}
|
||||
reasoning.set(id, item)
|
||||
items.push(item)
|
||||
} else if (event.type === 'REASONING_MESSAGE_CONTENT' || event.type === 'REASONING_MESSAGE_CHUNK') {
|
||||
const id = eventKey(payload.messageId || payload.reasoningId || event.id)
|
||||
let item = reasoning.get(id)
|
||||
if (!item) {
|
||||
item = {
|
||||
key: `r-${id}`, runId: event.runId, kind: 'reasoning', text: '', status: 'running', time: formatTime(event.createdAt)
|
||||
}
|
||||
reasoning.set(id, item)
|
||||
items.push(item)
|
||||
}
|
||||
item.text += String(payload.delta || payload.content || '')
|
||||
} else if (event.type === 'REASONING_MESSAGE_END' || event.type === 'REASONING_END') {
|
||||
const item = reasoning.get(eventKey(payload.messageId || payload.reasoningId || event.id))
|
||||
if (item) item.status = 'done'
|
||||
} else if (event.type === 'TOOL_CALL_START') {
|
||||
const id = eventKey(payload.toolCallId || event.id)
|
||||
const item: FlowItem & { kind: 'tool' } = {
|
||||
key: `t-${id}`, runId: event.runId, kind: 'tool',
|
||||
name: String(payload.toolCallName || payload.name || '工具'), args: '', detail: '', images: [],
|
||||
status: 'running', time: formatTime(event.createdAt)
|
||||
}
|
||||
tools.set(id, item)
|
||||
items.push(item)
|
||||
} else if (event.type === 'TOOL_CALL_ARGS' || event.type === 'TOOL_CALL_CHUNK') {
|
||||
const item = tools.get(eventKey(payload.toolCallId || payload.id || ''))
|
||||
if (item) item.args += String(payload.delta || payload.args || '')
|
||||
} else if (event.type === 'TOOL_CALL_RESULT' || event.type === 'TOOL_CALL_END') {
|
||||
const item = tools.get(eventKey(payload.toolCallId || payload.id || ''))
|
||||
if (item) {
|
||||
const result = String(payload.content || payload.result || '')
|
||||
item.status = /(?:执行失败|(?:^|\n)"?(?:error:|failed\b|exit code:\s*[1-9]))/i.test(result.trim()) ? 'failed' : 'done'
|
||||
if (result) {
|
||||
item.detail = summarize(result, 360)
|
||||
if (item.name === 'document_view') item.images = parseViewImages(result)
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'ASK_REQUESTED') {
|
||||
asks.set(event.runId, payload)
|
||||
} else if (event.type === 'ASK_RESPONDED') {
|
||||
const ask = asks.get(event.runId)
|
||||
items.push({
|
||||
key: `ask-${event.id}`, kind: 'notice', text: askSummary(ask, payload),
|
||||
tone: 'success', time: formatTime(event.createdAt)
|
||||
})
|
||||
} else if (event.type === 'RUN_ERROR') {
|
||||
terminalRuns.set(event.runId, 'failed')
|
||||
closeActive(event.runId, 'failed', reasoning, tools)
|
||||
items.push({
|
||||
key: `e-${event.id}`, kind: 'notice', text: '执行遇到问题,已保留现有进度',
|
||||
tone: 'error', time: formatTime(event.createdAt)
|
||||
})
|
||||
} else if (event.type === 'MODEL_RETRY') {
|
||||
items.push({
|
||||
key: `retry-${event.id}`, kind: 'notice',
|
||||
text: `模型连接中断,正在重连(${payload.attempt}/${payload.maxAttempts || 5})`,
|
||||
tone: 'info', time: formatTime(event.createdAt)
|
||||
})
|
||||
} else if (event.type === 'RUN_FINISHED' && payload.outcome === 'CANCELLED') {
|
||||
terminalRuns.set(event.runId, 'stopped')
|
||||
closeActive(event.runId, 'stopped', reasoning, tools)
|
||||
items.push({
|
||||
key: `cancel-${event.id}`, kind: 'notice', text: 'Agent 已停止,当前上下文已保留',
|
||||
tone: 'info', time: formatTime(event.createdAt)
|
||||
})
|
||||
} else if (event.type === 'ARTIFACT_PUBLISHED') {
|
||||
items.push({
|
||||
key: `a-${event.id}`, kind: 'notice', text: '申报书审阅稿已生成',
|
||||
tone: 'success', time: formatTime(event.createdAt)
|
||||
})
|
||||
} else if (event.type === 'RUN_FINISHED') {
|
||||
terminalRuns.set(event.runId, 'done')
|
||||
closeActive(event.runId, 'done', reasoning, tools)
|
||||
}
|
||||
}
|
||||
for (const [runId, status] of terminalRuns) closeActive(runId, status, reasoning, tools)
|
||||
for (const item of messages.values()) if (terminalRuns.has(item.runId)) item.final = true
|
||||
const filtered = items.filter(item => (item.kind !== 'message' && item.kind !== 'reasoning') || cleanText(item.text))
|
||||
const identifiedRuns = new Set<string | null>()
|
||||
for (const item of filtered) {
|
||||
if (item.kind === 'message' && !identifiedRuns.has(item.runId)) {
|
||||
item.showIdentity = true
|
||||
identifiedRuns.add(item.runId)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
|
||||
const visibleFlow = computed(() => flow.value.slice(-visibleLimit.value))
|
||||
const hiddenCount = computed(() => Math.max(0, flow.value.length - visibleFlow.value.length))
|
||||
const galleryImages = computed(() => flow.value.flatMap(item => item.kind === 'tool'
|
||||
? item.images.map(image => ({ key: `${item.key}:${image.path}`, image, url: previewUrl(image.path) }))
|
||||
: []))
|
||||
const galleryUrls = computed(() => galleryImages.value.map(item => item.url))
|
||||
|
||||
watch(
|
||||
() => [props.running, props.events.at(-1)?.id, props.events.at(-1)?.type] as const,
|
||||
([running, , type]) => {
|
||||
window.clearTimeout(finalizingTimer)
|
||||
showFinalizing.value = false
|
||||
if (running && type === 'TEXT_MESSAGE_END') {
|
||||
finalizingTimer = window.setTimeout(() => { showFinalizing.value = true }, 500)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => window.clearTimeout(finalizingTimer))
|
||||
|
||||
function closeActive(
|
||||
runId: string | null,
|
||||
status: Exclude<ActivityStatus, 'running'>,
|
||||
reasoning: Map<string, FlowItem & { kind: 'reasoning' }>,
|
||||
tools: Map<string, FlowItem & { kind: 'tool' }>
|
||||
) {
|
||||
for (const item of reasoning.values()) if (item.runId === runId && item.status === 'running') item.status = status
|
||||
for (const item of tools.values()) if (item.runId === runId && item.status === 'running') item.status = status
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return timeFormatter.format(new Date(value))
|
||||
}
|
||||
|
||||
function askSummary(ask: Record<string, any> | undefined, response: Record<string, any>) {
|
||||
if (ask?.kind !== 'material_check') return '已确认建设规划'
|
||||
const decisions = Array.isArray(response.decisions) ? response.decisions : []
|
||||
const assumptions = decisions.filter((item: any) => item?.action === 'ASSUMPTION').length
|
||||
const pending = decisions.filter((item: any) => item?.action === 'PENDING_COMMENT').length
|
||||
const uploaded = decisions.filter((item: any) => item?.action === 'UPLOADED').length
|
||||
const parts = ['已确认材料检验']
|
||||
if (assumptions) parts.push(`${assumptions} 项按规划假设继续`)
|
||||
if (pending) parts.push(`${pending} 项待确认`)
|
||||
if (uploaded) parts.push(`${uploaded} 项已补充`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function summarize(value: unknown, maxLength = 160) {
|
||||
const text = cleanText(typeof value === 'string' ? value : JSON.stringify(value))
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text
|
||||
}
|
||||
|
||||
function cleanText(value: string) {
|
||||
return redactText(value)
|
||||
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*|__|`{1,3}/g, '')
|
||||
.replace(/^\s*\|?\s*:?-{3,}.*$/gm, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function redactText(value: string) {
|
||||
return value
|
||||
.replace(/\/Users\/[^\s"')]+/g, '工作区路径')
|
||||
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, '内部任务')
|
||||
.replace(/sk-[A-Za-z0-9_-]{8,}/g, '已隐藏凭证')
|
||||
}
|
||||
|
||||
function parseArgs(item: FlowItem & { kind: 'tool' }) {
|
||||
try { return JSON.parse(item.args) as Record<string, any> } catch { return {} }
|
||||
}
|
||||
|
||||
function skillName(item: FlowItem & { kind: 'tool' }) {
|
||||
return String(parseArgs(item).skillId || '').replace(/_(?:builtin|imported)$/, '') || 'Skill'
|
||||
}
|
||||
|
||||
function baseToolName(item: FlowItem & { kind: 'tool' }) {
|
||||
const labels: Record<string, string> = {
|
||||
list_files: '查看文件', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件',
|
||||
glob_files: '查找文件', grep_files: '检索文件', execute: '工作区',
|
||||
memory_search: '项目记忆检索', memory_save: '项目记忆保存'
|
||||
}
|
||||
return labels[item.name] || item.name
|
||||
}
|
||||
|
||||
function activityLabel(item: FlowItem & { kind: 'tool' }) {
|
||||
if (item.name === 'document_view') {
|
||||
const count = item.images.length || requestedViewCount(item)
|
||||
if (item.status === 'running') return '正在查看图片'
|
||||
if (item.status === 'stopped') return '已停止查看图片'
|
||||
if (item.status === 'failed') return '查看图片失败'
|
||||
return `已查看图片${count ? ` · ${count} 张` : ''}`
|
||||
}
|
||||
if (item.name === 'read_file') return fileActivityLabel(item, '读取')
|
||||
if (item.name === 'write_file' || item.name === 'edit_file') return fileActivityLabel(item, '编辑')
|
||||
const skill = isSkill(item)
|
||||
const name = skill ? skillName(item) : baseToolName(item)
|
||||
if (item.status === 'running') return skill ? `正在调用 ${name} Skill` : `正在调用${name}工具`
|
||||
if (item.status === 'stopped') return skill ? `已停止调用 ${name} Skill` : `已停止调用${name}工具`
|
||||
if (item.status === 'failed') return skill ? `${name} Skill 调用失败` : `${name}工具调用失败`
|
||||
return skill ? `已调用 ${name} Skill` : `已调用${name}工具`
|
||||
}
|
||||
|
||||
function fileActivityLabel(item: FlowItem & { kind: 'tool' }, action: '读取' | '编辑') {
|
||||
const path = String(parseArgs(item).path || '').replace(/\\/g, '/')
|
||||
const name = path.split('/').filter(Boolean).pop()
|
||||
const target = name ? `${name} 文件` : '文件'
|
||||
if (item.status === 'running') return `正在${action} ${target}`
|
||||
if (item.status === 'stopped') return `已停止${action} ${target}`
|
||||
if (item.status === 'failed') return `${action} ${target}失败`
|
||||
return `已${action} ${target}`
|
||||
}
|
||||
|
||||
function isSkill(item: FlowItem & { kind: 'tool' }) {
|
||||
return item.name === 'load_skill_through_path' || item.name.toLowerCase().includes('skill')
|
||||
}
|
||||
|
||||
function requestedViewCount(item: FlowItem & { kind: 'tool' }) {
|
||||
const views = parseArgs(item).views
|
||||
return Array.isArray(views) ? views.length : 0
|
||||
}
|
||||
|
||||
function parseViewImages(content: string): ViewImage[] {
|
||||
const marker = 'document_view_result='
|
||||
const start = content.indexOf(marker)
|
||||
if (start < 0) return []
|
||||
try {
|
||||
const line = content.slice(start + marker.length).split('\n', 1)[0]
|
||||
const images = JSON.parse(line).images
|
||||
return Array.isArray(images) ? images : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function previewUrl(path: string) {
|
||||
return `/api/projects/${props.projectId}/view-images?path=${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
function openPreview(itemKey: string, path: string) {
|
||||
const index = galleryImages.value.findIndex(item => item.key === `${itemKey}:${path}`)
|
||||
if (index >= 0) previewIndex.value = index
|
||||
}
|
||||
|
||||
function reasoningLabel(item: FlowItem & { kind: 'reasoning' }) {
|
||||
if (item.status === 'running') return '正在思考'
|
||||
if (item.status === 'stopped') return '已停止思考'
|
||||
if (item.status === 'failed') return '思考中断'
|
||||
return '已思考'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<span v-if="running" class="sr-only" aria-live="polite">Agent 正在执行</span>
|
||||
<button v-if="hiddenCount" class="load-earlier" @click="visibleLimit += 180">查看更早记录({{ hiddenCount }})</button>
|
||||
<article v-for="item in visibleFlow" :key="item.key" class="flow-row" :class="`flow-${item.kind}`">
|
||||
<div v-if="item.kind === 'message' && item.showIdentity" class="agent-avatar" aria-label="Agent"><Bot /></div>
|
||||
<div class="flow-content">
|
||||
<div v-if="item.kind === 'message' && item.showIdentity" class="flow-meta"><strong>Agent</strong><time>{{ item.time }}</time></div>
|
||||
<MarkdownRender
|
||||
v-if="item.kind === 'message'"
|
||||
class="agent-markdown"
|
||||
mode="chat"
|
||||
:content="redactText(item.text).trim()"
|
||||
:final="item.final"
|
||||
smooth-streaming="auto"
|
||||
:fade="false"
|
||||
html-policy="escape"
|
||||
/>
|
||||
<details v-else-if="item.kind === 'reasoning'" class="activity-row reasoning-row" :class="{ active: item.status === 'running', failed: item.status === 'failed' }">
|
||||
<summary><Brain /><span>{{ reasoningLabel(item) }}</span></summary>
|
||||
<p>{{ cleanText(item.text) }}</p>
|
||||
</details>
|
||||
<details
|
||||
v-else-if="item.kind === 'tool'"
|
||||
class="activity-row tool-row"
|
||||
:class="{ active: item.status === 'running', failed: item.status === 'failed', visual: item.name === 'document_view' }"
|
||||
>
|
||||
<summary>
|
||||
<Images v-if="item.name === 'document_view'" />
|
||||
<MagicStick v-else-if="isSkill(item)" />
|
||||
<Wrench v-else />
|
||||
<span>{{ activityLabel(item) }}</span>
|
||||
</summary>
|
||||
<div v-if="item.images.length" class="view-image-strip">
|
||||
<button
|
||||
v-for="image in item.images"
|
||||
:key="image.path"
|
||||
type="button"
|
||||
class="view-image-item"
|
||||
:aria-label="`查看渲染图片 ${image.index}`"
|
||||
@click="openPreview(item.key, image.path)"
|
||||
>
|
||||
<img :src="previewUrl(image.path)" :alt="`渲染图片 ${image.index}`" loading="lazy" />
|
||||
<span>渲染图片 {{ image.index }} · {{ image.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="item.detail">{{ item.detail }}</p>
|
||||
</details>
|
||||
<div v-else class="notice-row" :class="item.tone">
|
||||
<CircleCheck v-if="item.tone === 'success'" /><Document v-else />
|
||||
<span>{{ item.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="showFinalizing" class="finalizing-row" aria-live="polite">
|
||||
<span>正在整理结果</span>
|
||||
</div>
|
||||
<ElImageViewer
|
||||
v-if="previewIndex !== null && galleryUrls.length"
|
||||
:url-list="galleryUrls"
|
||||
:initial-index="previewIndex"
|
||||
:infinite="true"
|
||||
:show-progress="true"
|
||||
:close-on-press-escape="true"
|
||||
:teleported="true"
|
||||
@close="previewIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
112
web-ui/src/components/MaterialAskCard.vue
Normal file
112
web-ui/src/components/MaterialAskCard.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
interface MissingItem {
|
||||
id: string
|
||||
label: string
|
||||
reason?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface MaterialAsk {
|
||||
interruptId: string
|
||||
report?: {
|
||||
summary?: string
|
||||
completeness?: number
|
||||
missingItems?: MissingItem[]
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
ask: MaterialAsk
|
||||
loading?: boolean
|
||||
uploadFile: (file: File) => Promise<unknown>
|
||||
}>()
|
||||
const emit = defineEmits<{ confirm: [response: Record<string, unknown>] }>()
|
||||
const form = reactive<{ decisions: Record<string, string>; note: string; uploading: string }>({
|
||||
decisions: {}, note: '', uploading: ''
|
||||
})
|
||||
const uploadErrors = reactive<Record<string, string>>({})
|
||||
const showAll = ref(false)
|
||||
|
||||
const items = computed(() => props.ask.report?.missingItems || [])
|
||||
const visibleItems = computed(() => showAll.value ? items.value : items.value.slice(0, 5))
|
||||
const summary = computed(() => {
|
||||
const value = props.ask.report?.summary || '请确认材料缺口的处理方式'
|
||||
return value.length > 150 ? `${value.slice(0, 150)}…` : value
|
||||
})
|
||||
const storageKey = computed(() => `material-ask:${props.ask.interruptId}`)
|
||||
|
||||
watch(
|
||||
() => props.ask.interruptId,
|
||||
() => {
|
||||
const saved = localStorage.getItem(storageKey.value)
|
||||
const draft = saved ? JSON.parse(saved) as { decisions?: Record<string, string>; note?: string } : {}
|
||||
form.decisions = Object.fromEntries(items.value.map(item => [item.id, draft.decisions?.[item.id] || 'PENDING_COMMENT']))
|
||||
form.note = draft.note || ''
|
||||
showAll.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => ({ decisions: form.decisions, note: form.note }),
|
||||
value => localStorage.setItem(storageKey.value, JSON.stringify(value)),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
async function upload(item: MissingItem, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
form.uploading = item.id
|
||||
uploadErrors[item.id] = ''
|
||||
try {
|
||||
await props.uploadFile(file)
|
||||
form.decisions[item.id] = 'UPLOADED'
|
||||
} catch {
|
||||
uploadErrors[item.id] = '上传失败,请重试'
|
||||
} finally {
|
||||
form.uploading = ''
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm', {
|
||||
interruptId: props.ask.interruptId,
|
||||
decisions: items.value.map(item => ({ id: item.id, action: form.decisions[item.id] })),
|
||||
note: form.note
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ask-card material-ask" aria-labelledby="material-title">
|
||||
<h3 id="material-title">确认材料检验</h3>
|
||||
<p>{{ summary }}</p>
|
||||
<div class="material-items">
|
||||
<div v-for="item in visibleItems" :key="item.id" class="material-item">
|
||||
<div><strong>{{ item.label }}</strong><small v-if="item.reason">{{ item.reason }}</small><small v-if="uploadErrors[item.id]" class="field-error">{{ uploadErrors[item.id] }}</small></div>
|
||||
<el-select v-model="form.decisions[item.id]" :aria-label="`${item.label}处理方式`">
|
||||
<el-option label="在审阅稿中待确认" value="PENDING_COMMENT" />
|
||||
<el-option label="暂无,按规划假设继续" value="ASSUMPTION" />
|
||||
<el-option label="已补充上传" value="UPLOADED" disabled />
|
||||
</el-select>
|
||||
<label class="material-upload" :class="{ busy: form.uploading === item.id }">
|
||||
{{ form.uploading === item.id ? '上传中' : '上传' }}
|
||||
<input type="file" :disabled="Boolean(form.uploading)" @change="upload(item, $event)" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="!items.length" class="material-clear">现有材料可进入规划</p>
|
||||
<button v-if="items.length > 5" class="material-more" @click="showAll = !showAll">
|
||||
{{ showAll ? '收起' : `展开其他 ${items.length - 5} 项` }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="plan-note">
|
||||
<span>补充说明(选填)</span>
|
||||
<el-input v-model="form.note" type="textarea" :rows="2" maxlength="300" />
|
||||
</label>
|
||||
<el-button type="primary" :loading="loading" :disabled="Boolean(form.uploading)" @click="confirm">确认并生成规划</el-button>
|
||||
</section>
|
||||
</template>
|
||||
91
web-ui/src/components/PlanCard.vue
Normal file
91
web-ui/src/components/PlanCard.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ planId: string; plan: Record<string, unknown>; loading?: boolean }>()
|
||||
const emit = defineEmits<{ confirm: [plan: Record<string, unknown>] }>()
|
||||
|
||||
const form = reactive({
|
||||
coreDirection: '',
|
||||
collaborationDirection: '',
|
||||
factoryName: '',
|
||||
planningYears: 3,
|
||||
investmentRange: '',
|
||||
scenarioCount: 1,
|
||||
aiScenarioCount: 0,
|
||||
note: ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.planId, props.plan] as const,
|
||||
([planId, plan]) => {
|
||||
const stored = localStorage.getItem(`plan-draft:${planId}`)
|
||||
const draft = stored ? JSON.parse(stored) as Partial<typeof form> : {}
|
||||
Object.assign(form, {
|
||||
coreDirection: String(plan.coreDirection || ''),
|
||||
collaborationDirection: String(plan.collaborationDirection || ''),
|
||||
factoryName: String(plan.factoryName || ''),
|
||||
planningYears: Number(plan.planningYears || 3),
|
||||
investmentRange: String(plan.investmentRange || ''),
|
||||
scenarioCount: Number(plan.scenarioCount || (Array.isArray(plan.scenarios) ? plan.scenarios.length : 1)),
|
||||
aiScenarioCount: Number(plan.aiScenarioCount || 0),
|
||||
note: String(plan.note || '')
|
||||
}, draft)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(form, value => localStorage.setItem(`plan-draft:${props.planId}`, JSON.stringify(value)), { deep: true })
|
||||
|
||||
const summary = computed(() => {
|
||||
return `${form.planningYears} 年 · ${form.investmentRange} · ${form.scenarioCount} 个场景 · ${form.aiScenarioCount} 个 AI 场景`
|
||||
})
|
||||
const valid = computed(() => Boolean(
|
||||
form.coreDirection.trim() && form.collaborationDirection.trim() && form.factoryName.trim()
|
||||
&& form.investmentRange.trim() && form.planningYears > 0 && form.scenarioCount > 0
|
||||
&& form.aiScenarioCount >= 0 && form.aiScenarioCount <= form.scenarioCount
|
||||
))
|
||||
|
||||
function confirm() {
|
||||
if (!valid.value) return
|
||||
emit('confirm', { ...props.plan, ...form })
|
||||
}
|
||||
|
||||
function restore() {
|
||||
localStorage.removeItem(`plan-draft:${props.planId}`)
|
||||
Object.assign(form, {
|
||||
coreDirection: String(props.plan.coreDirection || ''),
|
||||
collaborationDirection: String(props.plan.collaborationDirection || ''),
|
||||
factoryName: String(props.plan.factoryName || ''),
|
||||
planningYears: Number(props.plan.planningYears || 3),
|
||||
investmentRange: String(props.plan.investmentRange || ''),
|
||||
scenarioCount: Number(props.plan.scenarioCount || (Array.isArray(props.plan.scenarios) ? props.plan.scenarios.length : 1)),
|
||||
aiScenarioCount: Number(props.plan.aiScenarioCount || 0),
|
||||
note: String(props.plan.note || '')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ask-card" aria-labelledby="plan-title">
|
||||
<h3 id="plan-title">确认建设规划</h3>
|
||||
<p>确认后 Agent 将自主完成编写与评审</p>
|
||||
<div class="plan-fields">
|
||||
<label><span>核心建设方向</span><el-input v-model="form.coreDirection" /></label>
|
||||
<label><span>协同建设方向</span><el-input v-model="form.collaborationDirection" /></label>
|
||||
<label><span>智能工厂名称</span><el-input v-model="form.factoryName" /></label>
|
||||
<label><span>规划周期</span><el-input-number v-model="form.planningYears" :min="1" :max="10" controls-position="right" /></label>
|
||||
<label><span>投资范围</span><el-input v-model="form.investmentRange" /></label>
|
||||
<label><span>重点场景</span><el-input-number v-model="form.scenarioCount" :min="1" :max="60" controls-position="right" /></label>
|
||||
<label><span>AI 场景</span><el-input-number v-model="form.aiScenarioCount" :min="0" :max="form.scenarioCount" controls-position="right" /></label>
|
||||
</div>
|
||||
<div class="plan-summary">规划期 {{ summary }}</div>
|
||||
<label class="plan-note">
|
||||
<span>补充规划约束(选填)</span>
|
||||
<el-input v-model="form.note" type="textarea" :rows="3" maxlength="300" show-word-limit />
|
||||
</label>
|
||||
<div class="ask-actions">
|
||||
<el-button type="primary" :loading="loading" :disabled="!valid" @click="confirm">确认并开始编写</el-button>
|
||||
<el-button @click="restore">恢复 Agent 建议</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
57
web-ui/src/eventCache.ts
Normal file
57
web-ui/src/eventCache.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AgentEvent } from './api'
|
||||
|
||||
const DB_NAME = 'smart-factory-agent'
|
||||
const STORE_NAME = 'events'
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1)
|
||||
request.onupgradeneeded = () => {
|
||||
const store = request.result.createObjectStore(STORE_NAME, { keyPath: ['projectId', 'id'] })
|
||||
store.createIndex('projectId', 'projectId')
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function readCachedEvents(projectId: string): Promise<AgentEvent[]> {
|
||||
const database = await openDatabase()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly')
|
||||
const request = transaction.objectStore(STORE_NAME).index('projectId').getAll(projectId)
|
||||
request.onsuccess = () => resolve((request.result as AgentEvent[]).sort((a, b) => a.id - b.id))
|
||||
request.onerror = () => reject(request.error)
|
||||
transaction.oncomplete = () => database.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function cacheEvents(events: AgentEvent[]) {
|
||||
if (!events.length) return
|
||||
const database = await openDatabase()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
for (const event of events) store.put(event)
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
database.close()
|
||||
}
|
||||
|
||||
export async function deleteCachedEvents(projectId: string) {
|
||||
const database = await openDatabase()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite')
|
||||
const request = transaction.objectStore(STORE_NAME).index('projectId').openCursor(IDBKeyRange.only(projectId))
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result
|
||||
if (!cursor) return
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
}
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
database.close()
|
||||
}
|
||||
19
web-ui/src/eventUtils.test.ts
Normal file
19
web-ui/src/eventUtils.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentEvent } from './api'
|
||||
import { appendUniqueEvents } from './eventUtils'
|
||||
|
||||
const event = (id: number): AgentEvent => ({
|
||||
id,
|
||||
projectId: 'project',
|
||||
runId: 'run',
|
||||
type: 'TEXT_MESSAGE_CONTENT',
|
||||
payload: { delta: String(id) },
|
||||
createdAt: '2026-08-24T00:00:00Z'
|
||||
})
|
||||
|
||||
describe('appendUniqueEvents', () => {
|
||||
it('保留顺序并忽略重放事件', () => {
|
||||
expect(appendUniqueEvents([event(1), event(2)], [event(2), event(3)]).map(item => item.id))
|
||||
.toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
10
web-ui/src/eventUtils.ts
Normal file
10
web-ui/src/eventUtils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { AgentEvent } from './api'
|
||||
|
||||
/** 合并重连批次并按事件主键去重。 */
|
||||
export function appendUniqueEvents(current: AgentEvent[], incoming: AgentEvent[]) {
|
||||
if (!incoming.length) return current
|
||||
const lastId = current.at(-1)?.id
|
||||
if (lastId == null || incoming[0].id > lastId) return current.concat(incoming)
|
||||
const ids = new Set(current.map(event => event.id))
|
||||
return current.concat(incoming.filter(event => !ids.has(event.id)))
|
||||
}
|
||||
32
web-ui/src/main.ts
Normal file
32
web-ui/src/main.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElUpload
|
||||
} from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'markstream-vue/index.css'
|
||||
import './styles.css'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
for (const component of [
|
||||
ElButton, ElDialog, ElForm, ElFormItem, ElIcon, ElInput,
|
||||
ElInputNumber, ElOption, ElRadioButton, ElRadioGroup, ElSelect,
|
||||
ElSwitch, ElTabPane, ElTabs, ElUpload
|
||||
]) app.component(component.name!, component)
|
||||
app.use(router)
|
||||
void router.isReady().then(() => app.mount('#app'))
|
||||
45
web-ui/src/pages/LoginPage.vue
Normal file
45
web-ui/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('admin')
|
||||
const password = ref('admin123')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await login(username.value, password.value)
|
||||
await router.replace('/projects')
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<form class="login-panel" @submit.prevent="submit">
|
||||
<div class="brand large">智</div>
|
||||
<h1>智造申报 Agent</h1>
|
||||
<el-input v-model="username" autocomplete="username" aria-label="用户名" placeholder="用户名" />
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
aria-label="密码"
|
||||
placeholder="密码"
|
||||
/>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<el-button native-type="submit" type="primary" :loading="loading" class="full-button">登录</el-button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
124
web-ui/src/pages/ModelsPage.vue
Normal file
124
web-ui/src/pages/ModelsPage.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CircleCheck } from '@element-plus/icons-vue'
|
||||
import { ElMessage } 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 saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const tested = ref(false)
|
||||
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
|
||||
const selected = computed(() => models.value.find(model => model.id === selectedId.value))
|
||||
|
||||
async function load() {
|
||||
models.value = await api<ModelConfig[]>('/api/models')
|
||||
select(models.value.find(model => model.defaultModel)?.id || models.value[0]?.id || '')
|
||||
}
|
||||
|
||||
function select(id: string) {
|
||||
selectedId.value = id
|
||||
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
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api(`/api/models/${selectedId.value}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
modelId: form.modelId,
|
||||
apiKey: form.apiKey,
|
||||
config: { timeoutSeconds: 120, reasoningEffort: 'high' },
|
||||
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
|
||||
})
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
testing.value = true
|
||||
tested.value = false
|
||||
try {
|
||||
await api(`/api/models/${selectedId.value}/test`, { method: 'POST' })
|
||||
tested.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '连接失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault() {
|
||||
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' })
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-page">
|
||||
<header><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></header>
|
||||
<div class="settings-grid">
|
||||
<aside class="settings-list">
|
||||
<h2>已配置模型</h2>
|
||||
<button
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:class="{ selected: selectedId === model.id }"
|
||||
@click="select(model.id)"
|
||||
>
|
||||
<strong>{{ model.name }}</strong>
|
||||
<span>DeepSeek · {{ model.modelId }}</span>
|
||||
<small><i></i>可用</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>
|
||||
<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>
|
||||
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
426
web-ui/src/pages/ProjectPage.vue
Normal file
426
web-ui/src/pages/ProjectPage.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<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 { 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'
|
||||
|
||||
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 showBackToBottom = ref(false)
|
||||
let stopStream: (() => void) | null = null
|
||||
let loadVersion = 0
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
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 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']))
|
||||
|
||||
async function load(id: string) {
|
||||
const version = ++loadVersion
|
||||
historyLoading.value = true
|
||||
stopStream?.()
|
||||
stopStream = null
|
||||
project.value = null
|
||||
events.value = []
|
||||
try {
|
||||
const cached = await readCachedEvents(id).catch(() => [])
|
||||
const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest] = 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<{ status: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`)
|
||||
])
|
||||
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 || ''
|
||||
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<{ status: string }>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
|
||||
runStatus.value = run.status
|
||||
} 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 {
|
||||
await api(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
|
||||
runStatus.value = 'RUNNING'
|
||||
} 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<{ status: string }>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
|
||||
runStatus.value = run.status
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止失败')
|
||||
} finally {
|
||||
controlLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeRun() {
|
||||
if (!interrupted.value || controlLoading.value) return
|
||||
controlLoading.value = true
|
||||
try {
|
||||
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' })
|
||||
runStatus.value = run.status
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '继续失败')
|
||||
} 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: { status: string } }>(`/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
|
||||
runStatus.value = result.run.status
|
||||
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<{ status: string }>(`/api/projects/${projectId.value}/material/confirm`, {
|
||||
method: 'POST', body: JSON.stringify(value)
|
||||
})
|
||||
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
|
||||
pendingAsk.value = null
|
||||
runStatus.value = run.status
|
||||
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() {
|
||||
showBackToBottom.value = window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 240
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||
watch(() => events.value.length, async () => {
|
||||
const followsTail = !showBackToBottom.value
|
||||
await nextTick()
|
||||
if (followsTail) window.scrollTo({ top: document.documentElement.scrollHeight })
|
||||
})
|
||||
onMounted(() => window.addEventListener('scroll', updateScrollState, { passive: true }))
|
||||
onBeforeUnmount(() => {
|
||||
loadVersion++
|
||||
stopStream?.()
|
||||
window.removeEventListener('scroll', updateScrollState)
|
||||
})
|
||||
</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="controlLoading" @click="stopRun">停止</el-button>
|
||||
<el-button v-else-if="interrupted" type="primary" plain :loading="controlLoading" @click="resumeRun">继续</el-button>
|
||||
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<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 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>
|
||||
<button v-if="showBackToBottom" class="back-to-bottom" aria-label="回到底部" @click="scrollToBottom">↓</button>
|
||||
</section>
|
||||
<section v-else class="empty-main">新建或选择一个项目</section>
|
||||
</template>
|
||||
89
web-ui/src/pages/SkillsPage.vue
Normal file
89
web-ui/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>
|
||||
13
web-ui/src/router.ts
Normal file
13
web-ui/src/router.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
|
||||
{ path: '/', redirect: '/projects' },
|
||||
{ path: '/projects', component: () => import('./pages/ProjectPage.vue') },
|
||||
{ path: '/projects/:id', component: () => import('./pages/ProjectPage.vue') },
|
||||
{ path: '/models', component: () => import('./pages/ModelsPage.vue') },
|
||||
{ path: '/skills', component: () => import('./pages/SkillsPage.vue') }
|
||||
]
|
||||
})
|
||||
247
web-ui/src/styles.css
Normal file
247
web-ui/src/styles.css
Normal file
@@ -0,0 +1,247 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
font-synthesis: none;
|
||||
--blue: #0f5df5;
|
||||
--blue-soft: #f2f7ff;
|
||||
--muted: #667085;
|
||||
--line: #e8edf5;
|
||||
--green: #087d41;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 0; min-height: 100vh; }
|
||||
button, input, textarea { font: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: flex; background: #fff; }
|
||||
.rail { width: 84px; flex: 0 0 84px; height: 100vh; position: sticky; top: 0; border-right: 1px solid var(--line); display: flex; flex-direction: column; align-items: center; padding: 20px 10px; gap: 18px; }
|
||||
.brand { width: 46px; height: 46px; border-radius: 9px; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 700; }
|
||||
.brand svg { width: 24px; }
|
||||
.rail a { width: 64px; height: 66px; border-radius: 9px; color: #405170; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; font-size: 15px; }
|
||||
.rail a svg { width: 24px; height: 24px; }
|
||||
.rail a.active { background: var(--blue); color: #fff; }
|
||||
.rail .brand + a { margin-top: 6px; }
|
||||
|
||||
.project-list { width: 276px; flex: 0 0 276px; height: 100vh; position: sticky; top: 0; overflow-y: auto; border-right: 1px solid var(--line); padding: 24px 16px; background: #fff; }
|
||||
.aside-title { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
|
||||
.aside-title h2 { font-size: 20px; margin: 0; }
|
||||
.icon-button { width: 32px; height: 32px; border: 1px solid #9aa8bd; border-radius: 50%; background: #fff; color: #395277; font-size: 23px; line-height: 27px; cursor: pointer; }
|
||||
.aside-label { margin: 32px 12px 12px; color: #596883; font-size: 14px; }
|
||||
.project-item { display: flex; flex-direction: column; gap: 8px; padding: 16px 14px; border-radius: 8px; margin-bottom: 7px; }
|
||||
.project-item span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.project-item time { color: #697794; font-size: 13px; }
|
||||
.project-item.selected { background: #edf4ff; color: var(--blue); }
|
||||
.aside-empty { padding: 20px 12px; color: #98a2b3; }
|
||||
.main-view { min-width: 0; flex: 1; }
|
||||
|
||||
.project-page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.page-header { height: 80px; flex: 0 0 80px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 32px; background: rgba(255,255,255,.95); position: sticky; top: 0; z-index: 3; }
|
||||
.page-heading, .run-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.page-header h1 { margin: 0; font-size: 25px; letter-spacing: -.02em; }
|
||||
.tag { display: inline-flex; align-items: center; height: 30px; padding: 0 11px; border: 1px solid #cfd7e4; border-radius: 6px; color: #52617b; font-size: 14px; font-weight: 500; white-space: nowrap; }
|
||||
.tag.blue { color: var(--blue); border-color: #9dbdff; }
|
||||
.tag.green { color: var(--green); border-color: #9be0bd; }
|
||||
.work-scroll { width: min(920px, calc(100% - 64px)); margin: 0 auto; padding: 32px 0 72px; }
|
||||
.history-loading { margin-top: 24vh; text-align: center; color: var(--muted); }
|
||||
|
||||
.material-start { width: 640px; max-width: 100%; margin: 10vh auto 0; }
|
||||
.material-start h2 { font-size: 24px; margin: 0 0 24px; }
|
||||
.upload-box .el-upload-dragger { border: 1px dashed #a9b8cf; background: #fbfdff; padding: 44px; }
|
||||
.upload-box .el-icon { color: var(--blue); font-size: 34px; }
|
||||
.upload-box p { margin: 14px 0 4px; font-size: 16px; }
|
||||
.upload-box small { color: #8995a8; }
|
||||
.folder-upload { display: flex; justify-content: center; margin-top: 12px; }
|
||||
.folder-upload input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
.file-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
|
||||
.file-chips span { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 14px; }
|
||||
.file-chips svg { width: 16px; color: var(--blue); }
|
||||
.file-chips > small { align-self: center; color: var(--muted); }
|
||||
|
||||
.timeline { display: flex; flex-direction: column; gap: 12px; }
|
||||
.load-earlier { align-self: center; border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 8px 16px; }
|
||||
.load-earlier:hover, .load-earlier:focus-visible { color: var(--primary); }
|
||||
.flow-row { min-width: 0; content-visibility: auto; contain-intrinsic-size: 54px; }
|
||||
.flow-message { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; margin: 0; }
|
||||
.flow-message > .flow-content { grid-column: 2; }
|
||||
.flow-tool, .flow-reasoning, .flow-notice { position: relative; padding-left: 54px; }
|
||||
.agent-avatar { width: 38px; height: 38px; border-radius: 11px; background: #edf4ff; color: var(--blue); display: grid; place-items: center; }
|
||||
.agent-avatar svg { width: 21px; height: 21px; stroke-width: 1.8; }
|
||||
.flow-content { min-width: 0; }
|
||||
.flow-meta { display: flex; gap: 10px; align-items: baseline; margin: 1px 0 8px; }
|
||||
.flow-meta time { color: #6c7b95; font-size: 13px; }
|
||||
.agent-markdown { min-width: 0; color: #253247; font-size: 15px; line-height: 1.75; overflow-wrap: anywhere; }
|
||||
.agent-markdown > :first-child { margin-top: 0; }
|
||||
.agent-markdown > :last-child { margin-bottom: 0; }
|
||||
.agent-markdown p.paragraph-node { margin: 0; }
|
||||
.agent-markdown > .node-slot + .node-slot { margin-top: 12px; }
|
||||
.agent-markdown h1, .agent-markdown h2, .agent-markdown h3 { margin: 20px 0 10px; line-height: 1.35; color: #182235; }
|
||||
.agent-markdown h1 { font-size: 21px; }
|
||||
.agent-markdown h2 { font-size: 18px; }
|
||||
.agent-markdown h3 { font-size: 16px; }
|
||||
.agent-markdown ul, .agent-markdown ol { margin: 8px 0 12px; padding-left: 24px; }
|
||||
.agent-markdown li + li { margin-top: 4px; }
|
||||
.agent-markdown blockquote { margin: 12px 0; padding-left: 14px; border-left: 2px solid #d8e0eb; color: #5d687b; }
|
||||
.agent-markdown code { padding: 2px 5px; border-radius: 4px; background: #f2f4f7; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.agent-markdown pre { margin: 12px 0; padding: 14px 16px; overflow-x: auto; border-radius: 7px; background: #f5f7fa; }
|
||||
.agent-markdown pre code { padding: 0; background: transparent; }
|
||||
.agent-markdown a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.agent-markdown table { display: block; max-width: 100%; margin: 12px 0; overflow-x: auto; border-collapse: collapse; }
|
||||
.agent-markdown th, .agent-markdown td { padding: 7px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
|
||||
.activity-row { color: #778195; font-size: 13px; border: 0; }
|
||||
.activity-row summary { width: max-content; max-width: 100%; display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; list-style: none; }
|
||||
.activity-row summary::-webkit-details-marker { display: none; }
|
||||
.activity-row summary::after { content: "›"; margin-left: 2px; color: #a1a9b7; transition: transform .16s ease; }
|
||||
.activity-row[open] summary::after { transform: rotate(90deg); }
|
||||
.activity-row summary > svg { width: 15px; height: 15px; flex: 0 0 15px; color: currentColor; stroke-width: 1.8; }
|
||||
.activity-row p { margin: 6px 0 2px 23px; color: #647084; font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
|
||||
.activity-row.failed { color: #b54747; }
|
||||
.activity-row.active summary span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
|
||||
.view-image-strip { display: flex; flex-wrap: nowrap; gap: 10px; margin: 8px 0 4px 23px; overflow-x: auto; padding: 0 0 8px; }
|
||||
.view-image-item { flex: 0 0 168px; min-width: 0; padding: 0; border: 0; color: #657187; background: transparent; text-align: left; cursor: zoom-in; }
|
||||
.view-image-item:focus-visible { border-radius: 7px; outline: 2px solid var(--blue); outline-offset: 3px; }
|
||||
.view-image-strip img { display: block; width: 168px; height: 98px; object-fit: cover; border-radius: 7px; background: #f4f6f8; }
|
||||
.view-image-strip span { display: block; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.finalizing-row { margin-left: 54px; color: #778195; font-size: 13px; }
|
||||
.finalizing-row span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
|
||||
@keyframes activity-shimmer { to { background-position: -220% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .activity-row.active summary span, .finalizing-row span { color: inherit; background: none; animation: none; } }
|
||||
.notice-row { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-radius: 8px; background: #f6f9ff; }
|
||||
.notice-row svg { width: 18px; }
|
||||
.notice-row.success { color: #087d41; }
|
||||
.notice-row.error { color: #c53131; background: #fff7f7; }
|
||||
.notice-row.info { color: #45658f; background: #f7f9fc; }
|
||||
.pending { opacity: .9; }
|
||||
|
||||
.ask-card { width: 700px; max-width: calc(100% - 60px); margin: 28px 0 0 60px; padding: 22px; border-radius: 9px; background: #f6f9ff; box-shadow: inset 0 0 0 1px #d7e4fb; }
|
||||
.ask-card h3 { color: var(--blue); margin: 0 0 5px; font-size: 20px; }
|
||||
.ask-card > p { color: #697794; margin: 0 0 16px; font-size: 14px; }
|
||||
.plan-fields { overflow: hidden; border-radius: 6px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
|
||||
.plan-fields label { display: grid; grid-template-columns: 150px 1fr; align-items: center; min-height: 52px; border-bottom: 1px solid var(--line); }
|
||||
.plan-fields label:last-child { border: 0; }
|
||||
.plan-fields label > span { padding-left: 16px; }
|
||||
.plan-fields .el-input__wrapper { box-shadow: none; }
|
||||
.plan-fields .el-input-number { width: 100%; }
|
||||
.plan-fields .el-input-number .el-input__wrapper { box-shadow: none; }
|
||||
.plan-summary { margin: 12px 0 16px; padding: 11px 14px; border-radius: 6px; color: #4b6188; background: #eaf2ff; }
|
||||
.plan-note { display: block; margin-bottom: 16px; }
|
||||
.plan-note > span { display: block; margin-bottom: 8px; color: #53617b; font-size: 14px; }
|
||||
.ask-actions { display: flex; gap: 8px; }
|
||||
.material-items { margin-bottom: 16px; overflow: hidden; border-radius: 7px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
|
||||
.material-item { min-height: 72px; display: grid; grid-template-columns: minmax(180px, 1fr) 190px 58px; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
||||
.material-item:last-child { border-bottom: 0; }
|
||||
.material-item > div { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||
.material-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.material-item small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.material-item small.field-error { color: #b42318; }
|
||||
.material-upload { color: var(--blue); cursor: pointer; text-align: center; }
|
||||
.material-upload.busy { color: var(--muted); }
|
||||
.material-upload input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
.material-clear { margin: 0; padding: 16px; color: var(--green); }
|
||||
.material-more { width: 100%; padding: 11px; border: 0; border-top: 1px solid var(--line); background: #fff; color: var(--blue); cursor: pointer; }
|
||||
.material-upload:focus-within, .material-more:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
|
||||
.artifact-card { margin: 28px 0 min(28vh, 280px) 60px; border-radius: 8px; overflow: hidden; box-shadow: inset 0 0 0 1px #dce4ef; }
|
||||
.artifact-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); }
|
||||
.artifact-row:last-child { border: 0; }
|
||||
.artifact-row > svg { width: 25px; color: var(--blue); }
|
||||
.artifact-row div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.artifact-row small { color: #7b879b; }
|
||||
.artifact-row a { color: var(--blue); }
|
||||
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
|
||||
.retry-button { display: block; margin: 28px auto 0; }
|
||||
.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; }
|
||||
|
||||
.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-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 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 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 i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.connection-ok svg { width: 18px; }
|
||||
|
||||
.skills-header { display: flex; justify-content: space-between; align-items: start; }
|
||||
.import-skill-trigger { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 15px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: #fff; cursor: pointer; }
|
||||
.import-skill-trigger:hover, .skills-header .el-upload:focus-visible .import-skill-trigger { border-color: var(--primary); color: var(--primary); }
|
||||
.skill-grid { display: grid; grid-template-columns: 480px minmax(540px, 1fr); margin-top: 24px; min-height: 780px; }
|
||||
.skill-list { border-right: 1px solid var(--line); padding-right: 16px; overflow-y: auto; max-height: calc(100vh - 150px); }
|
||||
.skill-item { min-height: 84px; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-bottom: 1px solid var(--line); background: #fff; }
|
||||
.skill-item.selected { background: #edf4ff; }
|
||||
.skill-select { min-width: 0; min-height: 84px; display: grid; grid-template-columns: minmax(0, 1fr) 52px 54px; align-items: center; gap: 8px; border: 0; background: transparent; text-align: left; padding: 14px; cursor: pointer; }
|
||||
.skill-select > span { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.skill-select strong, .skill-select span > small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.skill-select strong { color: #1f2a3d; }
|
||||
.skill-select span > small { color: #667085; font-size: 13px; }
|
||||
.skill-select > i { color: var(--green); font-style: normal; white-space: nowrap; }
|
||||
.skill-select > i::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 5px; }
|
||||
.skill-detail { padding-left: 28px; }
|
||||
.skill-title { min-height: 72px; display: flex; justify-content: space-between; align-items: start; gap: 12px; }
|
||||
.skill-title > div { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||
.skill-title h2 { margin: 0 6px 0 0; font-size: 24px; }
|
||||
.skill-detail pre { min-height: 450px; max-height: calc(100vh - 280px); overflow: auto; white-space: pre-wrap; margin: 8px 0 0; padding: 22px; border-radius: 7px; background: #fafbfd; color: #293752; line-height: 1.7; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; box-shadow: inset 0 0 0 1px #e1e7f0; }
|
||||
.resource-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.resource-list span { padding: 12px; border-bottom: 1px solid var(--line); }
|
||||
.validation-ok { color: var(--green); }
|
||||
|
||||
.login-page { min-height: 100vh; display: grid; place-items: center; background: #f8faff; }
|
||||
.login-panel { width: 360px; padding: 36px; display: flex; flex-direction: column; gap: 16px; border-radius: 14px; background: #fff; box-shadow: 0 12px 44px rgba(34, 64, 120, .08); }
|
||||
.login-panel .brand.large { margin: 0 auto; }
|
||||
.login-panel h1 { margin: 4px 0 10px; text-align: center; font-size: 24px; }
|
||||
.full-button { width: 100%; }
|
||||
.form-error { color: #c53131; font-size: 13px; margin: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
.el-button--primary { --el-button-bg-color: var(--blue); --el-button-border-color: var(--blue); }
|
||||
.el-input__wrapper, .el-textarea__inner { box-shadow: inset 0 0 0 1px #d9e0ea; }
|
||||
.el-dialog { border-radius: 12px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.project-list { width: 240px; flex-basis: 240px; }
|
||||
.settings-page { padding-left: 28px; padding-right: 28px; }
|
||||
.settings-grid { grid-template-columns: 310px 1fr; }
|
||||
.skill-grid { grid-template-columns: 390px 1fr; }
|
||||
.skill-title { min-height: 100px; }
|
||||
.skill-title > div { align-items: flex-start; flex-wrap: wrap; }
|
||||
.skill-title h2 { flex-basis: 100%; font-size: 21px; overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.rail { width: 64px; flex-basis: 64px; padding-left: 0; padding-right: 0; }
|
||||
.rail a { width: 52px; font-size: 12px; }
|
||||
.project-list { width: 190px; flex-basis: 190px; padding: 20px 10px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.flow-tool::after, .flow-reasoning::after { left: 4px; }
|
||||
.material-item { grid-template-columns: 1fr 160px; }
|
||||
.material-upload { grid-column: 2; }
|
||||
.settings-page { padding: 24px 16px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
18
web-ui/tsconfig.app.json
Normal file
18
web-ui/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
7
web-ui/tsconfig.json
Normal file
7
web-ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
11
web-ui/tsconfig.node.json
Normal file
11
web-ui/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
web-ui/vite.config.ts
Normal file
21
web-ui/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8080'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: { vue: ['vue', 'vue-router'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user