This commit is contained in:
2026-08-29 13:32:57 +08:00
commit c56aa6e752
81 changed files with 14319 additions and 0 deletions

13
web-ui/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

29
web-ui/package.json Normal file
View 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
View 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
View 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()
}
}

View 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 项已补充')
})
})

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

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

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

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

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

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

View 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>支持 PDFDOCXXLSXPPTX图片</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>

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

11
web-ui/tsconfig.node.json Normal file
View 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
View 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'] }
}
}
}
})