chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
**/node_modules
|
||||
**/dist
|
||||
client
|
||||
*.tsbuildinfo
|
||||
npm-debug.log
|
||||
|
||||
@@ -7,6 +7,10 @@ WORKDIR /workspace
|
||||
|
||||
# 先复制依赖清单以复用 Docker 构建缓存;只有依赖变化时才重新执行 npm ci。
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/common/package.json packages/common/package.json
|
||||
COPY packages/admin/package.json packages/admin/package.json
|
||||
COPY packages/agent/package.json packages/agent/package.json
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
@@ -16,6 +20,6 @@ RUN npm run build
|
||||
FROM nginx:1.29.8-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /workspace/dist /usr/share/nginx/html
|
||||
COPY --from=builder /workspace/apps/web/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
21
web-ui/apps/web/package.json
Normal file
21
web-ui/apps/web/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@manuagent/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "^4.6.3",
|
||||
"element-plus": "2.14.5",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"markstream-vue": "2.0.6",
|
||||
"@manuagent/common": "0.1.0",
|
||||
"@manuagent/admin": "0.1.0",
|
||||
"@manuagent/agent": "0.1.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 223 B |
35
web-ui/apps/web/src/App.vue
Normal file
35
web-ui/apps/web/src/App.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Box, Folder, MagicStick } from '@element-plus/icons-vue'
|
||||
import { ProjectSidebar } from '@manuagent/agent'
|
||||
|
||||
const route = useRoute()
|
||||
const sidebar = ref<InstanceType<typeof ProjectSidebar> | null>(null)
|
||||
const isLogin = computed(() => route.path === '/login')
|
||||
const projectRoute = computed(() => route.path.startsWith('/projects'))
|
||||
</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>
|
||||
|
||||
<ProjectSidebar v-if="projectRoute" ref="sidebar" />
|
||||
|
||||
<main class="main-view"><RouterView @projects-changed="sidebar?.loadProjects()" /></main>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
} from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'markstream-vue/index.css'
|
||||
import './styles.css'
|
||||
import '@manuagent/common/styles.css'
|
||||
import { setUnauthorizedHandler } from '@manuagent/common'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
|
||||
setUnauthorizedHandler(() => location.assign('/login'))
|
||||
const app = createApp(App)
|
||||
for (const component of [
|
||||
ElButton, ElDialog, ElForm, ElFormItem, ElIcon, ElInput,
|
||||
11
web-ui/apps/web/src/router.ts
Normal file
11
web-ui/apps/web/src/router.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { agentRoutes } from '@manuagent/agent'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: () => import('@manuagent/admin').then(module => module.LoginPage), props: { destination: '/projects' }, meta: { public: true } },
|
||||
{ path: '/', redirect: '/projects' },
|
||||
...agentRoutes
|
||||
]
|
||||
})
|
||||
7
web-ui/apps/web/tsconfig.json
Normal file
7
web-ui/apps/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: { dedupe: ['vue', 'vue-router'] },
|
||||
server: {
|
||||
port: 15173,
|
||||
proxy: {
|
||||
71
web-ui/package-lock.json
generated
71
web-ui/package-lock.json
generated
@@ -7,15 +7,10 @@
|
||||
"": {
|
||||
"name": "smart-factory-approval-agent-client",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
@@ -26,6 +21,20 @@
|
||||
"vue-tsc": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@manuagent/web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@manuagent/admin": "0.1.0",
|
||||
"@manuagent/agent": "0.1.0",
|
||||
"@manuagent/common": "0.1.0",
|
||||
"element-plus": "2.14.5",
|
||||
"markstream-vue": "2.0.6",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@ag-ui/client": {
|
||||
"version": "0.0.58",
|
||||
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.58.tgz",
|
||||
@@ -779,6 +788,22 @@
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@manuagent/admin": {
|
||||
"resolved": "packages/admin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@manuagent/agent": {
|
||||
"resolved": "packages/agent",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@manuagent/common": {
|
||||
"resolved": "packages/common",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@manuagent/web": {
|
||||
"resolved": "apps/web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -3603,6 +3628,34 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"packages/admin": {
|
||||
"name": "@manuagent/admin",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@manuagent/common": "0.1.0",
|
||||
"element-plus": "2.14.5",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@manuagent/agent",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.58",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@lucide/vue": "^1.35.0",
|
||||
"@manuagent/common": "0.1.0",
|
||||
"element-plus": "2.14.5",
|
||||
"markstream-vue": "2.0.6",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
},
|
||||
"packages/common": {
|
||||
"name": "@manuagent/common",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,15 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"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"
|
||||
"dev": "npm run dev --workspace @manuagent/web",
|
||||
"typecheck": "npm run typecheck --workspaces",
|
||||
"test": "vitest run --config apps/web/vite.config.ts",
|
||||
"build": "npm run typecheck && npm run build --workspace @manuagent/web"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
||||
18
web-ui/packages/admin/package.json
Normal file
18
web-ui/packages/admin/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@manuagent/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manuagent/common": "0.1.0",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "^4.6.3",
|
||||
"element-plus": "2.14.5"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
9
web-ui/packages/admin/src/api.ts
Normal file
9
web-ui/packages/admin/src/api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { api, resetCsrf } from '@manuagent/common'
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
resetCsrf()
|
||||
return api('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
}
|
||||
2
web-ui/packages/admin/src/index.ts
Normal file
2
web-ui/packages/admin/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoginPage } from './pages/LoginPage.vue'
|
||||
export { login } from './api'
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login } from '../api'
|
||||
|
||||
const props = defineProps<{ destination: string }>()
|
||||
const router = useRouter()
|
||||
const username = ref('admin')
|
||||
const password = ref('admin123')
|
||||
@@ -15,7 +16,7 @@ async function submit() {
|
||||
error.value = ''
|
||||
try {
|
||||
await login(username.value, password.value)
|
||||
await router.replace('/projects')
|
||||
await router.replace(props.destination)
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '登录失败'
|
||||
} finally {
|
||||
7
web-ui/packages/admin/tsconfig.json
Normal file
7
web-ui/packages/admin/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
22
web-ui/packages/agent/package.json
Normal file
22
web-ui/packages/agent/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@manuagent/agent",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manuagent/common": "0.1.0",
|
||||
"@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"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { request } from '@manuagent/common'
|
||||
export { api } from '@manuagent/common'
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
companyName: string
|
||||
@@ -44,47 +47,6 @@ export interface Artifact {
|
||||
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,
|
||||
@@ -98,13 +60,11 @@ export function streamEvents(
|
||||
const connect = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/events/stream?after=${cursor}`, {
|
||||
credentials: 'include',
|
||||
const response = await request(`/api/projects/${projectId}/events/stream?after=${cursor}`, {
|
||||
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})`)
|
||||
@@ -39,10 +39,10 @@ describe('AgentTimeline', () => {
|
||||
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: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', payload: { toolCallId: 'a', content: '材料正文' }, 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: 6, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', payload: { toolCallId: 'b', content: 'Skill 已加载' }, 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' }
|
||||
]
|
||||
@@ -128,4 +128,24 @@ describe('AgentTimeline', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('已确认材料检验 · 1 项按规划假设继续 · 1 项待确认 · 1 项已补充')
|
||||
})
|
||||
it('增量更新不重读历史事件,结束事件不会覆盖失败状态', async () => {
|
||||
let historyReads = 0
|
||||
const started = { id: 1, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START',
|
||||
get payload() { historyReads++; return { toolCallId: 't', toolCallName: 'execute' } }, createdAt: '2026-08-24T10:00:00Z' }
|
||||
const wrapper = shallowMount(AgentTimeline, { props: { events: [started], running: true, projectId: 'p' } })
|
||||
expect(wrapper.text()).toContain('正在调用')
|
||||
const reads = historyReads
|
||||
const failure = { id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT',
|
||||
payload: { toolCallId: 't', content: JSON.stringify({ success: false, exitCode: 1, output: '失败' }) }, createdAt: '2026-08-24T10:00:01Z' }
|
||||
await wrapper.setProps({ events: [started, failure] })
|
||||
expect(historyReads).toBe(reads)
|
||||
expect(wrapper.findAll('.tool-row.failed')).toHaveLength(1)
|
||||
await wrapper.setProps({ events: [started, failure, { id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END',
|
||||
payload: { toolCallId: 't' }, createdAt: '2026-08-24T10:00:02Z' }] })
|
||||
expect(wrapper.findAll('.tool-row.failed')).toHaveLength(1)
|
||||
expect(historyReads).toBe(reads)
|
||||
await wrapper.setProps({ projectId: 'other', events: [] })
|
||||
expect(wrapper.findAll('.flow-row')).toHaveLength(0)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
import { parseToolResult } from '../eventUtils'
|
||||
|
||||
const MarkdownRender = defineAsyncComponent(() => import('markstream-vue'))
|
||||
|
||||
@@ -22,15 +23,30 @@ type FlowItem =
|
||||
| { 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'>>()
|
||||
let processed = 0
|
||||
let previousEvents: AgentEvent[] = []
|
||||
let previousProject = ''
|
||||
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 nonempty = new Set<FlowItem>()
|
||||
|
||||
const flow = computed<FlowItem[]>(() => {
|
||||
if (previousProject !== props.projectId || props.events.length < processed
|
||||
|| (processed > 0 && (props.events[0] !== previousEvents[0]
|
||||
|| props.events[processed - 1] !== previousEvents[processed - 1]))) {
|
||||
processed = 0
|
||||
items.length = 0
|
||||
messages.clear(); reasoning.clear(); tools.clear(); asks.clear(); terminalRuns.clear(); nonempty.clear()
|
||||
}
|
||||
previousProject = props.projectId
|
||||
// 只归并新事件;历史加载或切换项目时重建,保留重连后工具与正文的关联。
|
||||
for (let index = processed; index < props.events.length; index++) {
|
||||
const event = props.events[index]!
|
||||
const payload = event.payload || {}
|
||||
const eventKey = (id: unknown) => `${event.runId || 'none'}:${String(id)}`
|
||||
if (event.type === 'TEXT_MESSAGE_START') {
|
||||
@@ -53,6 +69,8 @@ const flow = computed<FlowItem[]>(() => {
|
||||
items.push(item)
|
||||
}
|
||||
item.text += String(payload.delta || payload.content || '')
|
||||
if (cleanText(item.text)) nonempty.add(item)
|
||||
else nonempty.delete(item)
|
||||
} else if (event.type === 'TEXT_MESSAGE_END') {
|
||||
const item = messages.get(eventKey(payload.messageId || 'current'))
|
||||
if (item) item.final = true
|
||||
@@ -74,6 +92,8 @@ const flow = computed<FlowItem[]>(() => {
|
||||
items.push(item)
|
||||
}
|
||||
item.text += String(payload.delta || payload.content || '')
|
||||
if (cleanText(item.text)) nonempty.add(item)
|
||||
else nonempty.delete(item)
|
||||
} 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'
|
||||
@@ -89,11 +109,12 @@ const flow = computed<FlowItem[]>(() => {
|
||||
} 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') {
|
||||
} else if (event.type === 'TOOL_CALL_RESULT') {
|
||||
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'
|
||||
const parsed = parseToolResult(payload.content ?? payload.result ?? '', item.name)
|
||||
const result = parsed.text
|
||||
item.status = parsed.failed ? 'failed' : 'done'
|
||||
if (result) {
|
||||
item.detail = summarize(result, 360)
|
||||
if (item.name === 'document_view') item.images = parseViewImages(result)
|
||||
@@ -111,7 +132,7 @@ const flow = computed<FlowItem[]>(() => {
|
||||
terminalRuns.set(event.runId, 'failed')
|
||||
closeActive(event.runId, 'failed', reasoning, tools)
|
||||
items.push({
|
||||
key: `e-${event.id}`, kind: 'notice', text: '执行遇到问题,已保留现有进度',
|
||||
key: `e-${event.id}`, kind: 'notice', text: String(payload.message || '执行遇到问题,已保留现有进度'),
|
||||
tone: 'error', time: formatTime(event.createdAt)
|
||||
})
|
||||
} else if (event.type === 'MODEL_RETRY') {
|
||||
@@ -137,9 +158,11 @@ const flow = computed<FlowItem[]>(() => {
|
||||
closeActive(event.runId, 'done', reasoning, tools)
|
||||
}
|
||||
}
|
||||
processed = props.events.length
|
||||
previousEvents = props.events
|
||||
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 filtered = items.filter(item => (item.kind !== 'message' && item.kind !== 'reasoning') || nonempty.has(item))
|
||||
const identifiedRuns = new Set<string | null>()
|
||||
for (const item of filtered) {
|
||||
if (item.kind === 'message' && !identifiedRuns.has(item.runId)) {
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box, Folder, MagicStick } from '@element-plus/icons-vue'
|
||||
import { api, type Project } from './api'
|
||||
import { api, type Project } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -12,11 +11,8 @@ 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) {
|
||||
@@ -45,26 +41,11 @@ async function createProject() {
|
||||
}
|
||||
|
||||
onMounted(loadProjects)
|
||||
watch(isLogin, value => { if (!value) void loadProjects() })
|
||||
defineExpose({ 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">
|
||||
<aside class="project-list">
|
||||
<div class="aside-title">
|
||||
<h2>项目</h2>
|
||||
<button class="icon-button" aria-label="新建项目" @click="createOpen = true">+</button>
|
||||
@@ -82,9 +63,6 @@ watch(isLogin, value => { if (!value) void loadProjects() })
|
||||
</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>
|
||||
@@ -102,5 +80,4 @@ watch(isLogin, value => { if (!value) void loadProjects() })
|
||||
<el-button type="primary" :loading="creating" :disabled="!companyName.trim()" @click="createProject">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
32
web-ui/packages/agent/src/eventUtils.test.ts
Normal file
32
web-ui/packages/agent/src/eventUtils.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentEvent } from './api'
|
||||
import { appendUniqueEvents, parseToolResult } 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])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('parseToolResult', () => {
|
||||
it('优先使用结构化结果并识别旧的脚本错误', () => {
|
||||
expect(parseToolResult(JSON.stringify(JSON.stringify({ success: false, exitCode: 1, output: '错误' })), 'execute').failed).toBe(true)
|
||||
expect(parseToolResult({ success: true, exitCode: 0, output: '示例 TypeError:' }, 'execute').failed).toBe(false)
|
||||
expect(parseToolResult(JSON.stringify('Exit code: 0\n\nTypeError: children is not iterable'), 'execute').failed).toBe(true)
|
||||
expect(parseToolResult(JSON.stringify('Exit code: 0\n\nSyntaxError: Unexpected token'), 'execute').failed).toBe(true)
|
||||
expect(parseToolResult('TypeError: 文档中的示例', 'read_file').failed).toBe(false)
|
||||
expect(parseToolResult('{"success":false,"error":"文件中的业务数据"}', 'read_file').failed).toBe(false)
|
||||
expect(parseToolResult('document_view_result={"images":[],"errors":["无法渲染"]}', 'document_view').failed).toBe(true)
|
||||
})
|
||||
})
|
||||
35
web-ui/packages/agent/src/eventUtils.ts
Normal file
35
web-ui/packages/agent/src/eventUtils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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)))
|
||||
}
|
||||
|
||||
/** 新工具使用结构化状态;解码 SDK 包装后兼容已保存的旧事件。 */
|
||||
export function parseToolResult(content: unknown, toolName: string) {
|
||||
let value = content
|
||||
for (let i = 0; i < 2 && typeof value === 'string'; i++) {
|
||||
try { value = JSON.parse(value) } catch { break }
|
||||
}
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
|
||||
if ((toolName === 'execute' || toolName === 'edit_file') && value && typeof value === 'object') {
|
||||
const result = value as Record<string, unknown>
|
||||
if (typeof result.success === 'boolean') return { text, failed: !result.success }
|
||||
if (typeof result.exitCode === 'number') return { text, failed: result.exitCode !== 0 }
|
||||
if (result.isError === true || result.error) return { text, failed: true }
|
||||
}
|
||||
if (toolName === 'document_view' && text.includes('document_view_result=')) {
|
||||
try {
|
||||
const metadata = JSON.parse(text.split('document_view_result=')[1]!.split('\n')[0]!)
|
||||
if (Array.isArray(metadata.errors) && metadata.errors.length) return { text, failed: true }
|
||||
} catch { return { text, failed: true } }
|
||||
}
|
||||
const error = /(?:执行失败|(?:^|\n)\s*(?:error:|failed\b|exit code:\s*-?[1-9]))/i.test(text.trim())
|
||||
const legacyShellError = toolName === 'execute'
|
||||
&& /(?:^|\n)(?:\w*Error:|Traceback \(most recent call last\):)/.test(text)
|
||||
return { text, failed: error || legacyShellError }
|
||||
}
|
||||
8
web-ui/packages/agent/src/index.ts
Normal file
8
web-ui/packages/agent/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as ProjectSidebar } from './components/ProjectSidebar.vue'
|
||||
|
||||
export const agentRoutes = [
|
||||
{ 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') }
|
||||
]
|
||||
7
web-ui/packages/agent/tsconfig.json
Normal file
7
web-ui/packages/agent/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
14
web-ui/packages/common/package.json
Normal file
14
web-ui/packages/common/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@manuagent/common",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./styles.css": "./src/styles.css"
|
||||
},
|
||||
"dependencies": {},
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
36
web-ui/packages/common/src/index.test.ts
Normal file
36
web-ui/packages/common/src/index.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { api, request, resetCsrf, setUnauthorizedHandler } from './index'
|
||||
|
||||
afterEach(() => {
|
||||
resetCsrf()
|
||||
setUnauthorizedHandler(() => {})
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('shares CSRF between JSON and streaming requests while preserving cancellation', async () => {
|
||||
const fetch = vi.fn()
|
||||
.mockResolvedValueOnce(Response.json({ headerName: 'X-CSRF-TOKEN', token: 'test-token' }))
|
||||
.mockResolvedValueOnce(Response.json({ id: 'project' }))
|
||||
.mockResolvedValueOnce(new Response('data: test\n\n'))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
await api('/api/projects', { method: 'POST', body: '{}' })
|
||||
const signal = new AbortController().signal
|
||||
const response = await request('/api/projects/project/agent', { method: 'POST', body: '{}', signal })
|
||||
expect(await response.text()).toBe('data: test\n\n')
|
||||
expect(fetch).toHaveBeenCalledTimes(3)
|
||||
for (const index of [1, 2]) {
|
||||
expect(fetch.mock.calls[index][1].headers.get('X-CSRF-TOKEN')).toBe('test-token')
|
||||
expect(fetch.mock.calls[index][1].credentials).toBe('include')
|
||||
}
|
||||
expect(fetch.mock.calls[2][1].signal).toBe(signal)
|
||||
})
|
||||
|
||||
it('redirects unauthorized streams and reports expired JSON sessions without redirecting login failures', async () => {
|
||||
const unauthorized = vi.fn()
|
||||
setUnauthorizedHandler(unauthorized)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response('{}', { status: 401 }))))
|
||||
expect((await request('/api/events')).status).toBe(401)
|
||||
await expect(api('/api/projects')).rejects.toThrow('登录状态已失效')
|
||||
await request('/api/auth/login', { method: 'POST', body: '{}' })
|
||||
expect(unauthorized).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
34
web-ui/packages/common/src/index.ts
Normal file
34
web-ui/packages/common/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
let csrf: { token: string; headerName: string } | null = null
|
||||
let onUnauthorized = () => {}
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void) {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
export function resetCsrf() {
|
||||
csrf = null
|
||||
}
|
||||
|
||||
export async function request(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const headers = new Headers(options.headers)
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && path !== '/api/auth/login') {
|
||||
if (!csrf) csrf = await api('/api/auth/csrf')
|
||||
headers.set(csrf!.headerName, csrf!.token)
|
||||
}
|
||||
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
|
||||
const response = await fetch(path, { credentials: 'include', ...options, headers })
|
||||
if (response.status === 401 && path !== '/api/auth/login') onUnauthorized()
|
||||
return response
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await request(path, options)
|
||||
if (response.status === 401 && path !== '/api/auth/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 as T
|
||||
return response.json()
|
||||
}
|
||||
7
web-ui/packages/common/tsconfig.json
Normal file
7
web-ui/packages/common/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
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)))
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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') }
|
||||
]
|
||||
})
|
||||
@@ -11,8 +11,13 @@
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user