chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块

This commit is contained in:
2026-09-08 15:02:18 +08:00
parent d392436620
commit 81a0d81f11
168 changed files with 2785 additions and 1375 deletions

View File

@@ -1,5 +1,5 @@
node_modules
dist
**/node_modules
**/dist
client
*.tsbuildinfo
npm-debug.log

View File

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

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

View File

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 223 B

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

View File

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

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

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"src/**/*.vue"
]
}

View File

@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: { dedupe: ['vue', 'vue-router'] },
server: {
port: 15173,
proxy: {

View File

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

View File

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

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

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

View File

@@ -0,0 +1,2 @@
export { default as LoginPage } from './pages/LoginPage.vue'
export { login } from './api'

View File

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

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"src/**/*.vue"
]
}

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

View File

@@ -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}`)

View File

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

View File

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

View File

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

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

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

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

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"src/**/*.vue"
]
}

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

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

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

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts",
"src/**/*.vue"
]
}

View File

@@ -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])
})
})

View File

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

View File

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

View File

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

View File

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