feat(ai): add local expert library

This commit is contained in:
2026-07-22 17:09:54 +08:00
parent b28a5d635c
commit 42dd0dce47
22 changed files with 3432 additions and 60 deletions

View File

@@ -111,7 +111,7 @@ const inboxPageSource = readFileSync('src/pages/projects/project-inbox.tsx', 'ut
if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
const aiApiSource = readFileSync('src/api/ai.ts', 'utf8')
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
for (const required of ['/api/v1/projects/', '/ai-sessions', '/api/v1/ai-experts', 'listAIExperts', 'listAISessions', 'createAISession']) {
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
}
if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal')
@@ -119,7 +119,7 @@ if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass t
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
const aiPageSource = readFileSync('src/pages/projects/project-ai.tsx', 'utf8')
for (const required of ['新建会话', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) {
for (const required of ['新建会话', '选择专家进行会话', 'agent-expert-picker', 'selectedExpertID', 'expertId', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
}
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {

View File

@@ -18,6 +18,20 @@ const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 })
const errors = []
const projectId = '019b0000-0000-7000-8000-000000000001'
const visualExperts = [
{
id: '019b0000-0000-7000-8000-000000000020', slug: 'product-manager', category: 'product', categoryName: '产品',
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
},
{
id: '019b0000-0000-7000-8000-000000000021', slug: 'ui-designer', category: 'design', categoryName: '设计',
name: 'UI 设计师', description: '负责界面设计、组件规范和视觉一致性。', emoji: '🎨', color: '#722ED1',
},
{
id: '019b0000-0000-7000-8000-000000000022', slug: 'frontend-developer', category: 'engineering', categoryName: '工程',
name: '前端开发者', description: '负责现代 Web 应用实现与性能优化。', emoji: '💻', color: '#0FC6C2',
},
]
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
@@ -138,6 +152,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) {
if (route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const expert = visualExperts.find((item) => item.id === input.expertId)
await route.fulfill({
json: {
id: '019b0000-0000-7000-8000-000000000010',
@@ -145,6 +160,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
title: input.title,
context: input.context,
status: 'ready',
expert,
createdAt: '2026-07-22T09:00:00Z',
updatedAt: '2026-07-22T09:00:00Z',
},
@@ -158,12 +174,17 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
title: '报价分析',
context: '分析当前方案的报价结构。',
status: 'ready',
expert: visualExperts[1],
createdAt: '2026-07-22T08:30:00Z',
updatedAt: '2026-07-22T08:30:00Z',
}],
})
return
}
if (url.pathname === '/api/v1/ai-experts') {
await route.fulfill({ json: visualExperts })
return
}
await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } })
})
@@ -334,6 +355,8 @@ for (const channel of [
hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')),
hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')),
hasComposer: Boolean(pageNode?.querySelector('.agent-composer')),
hasExpertPicker: Boolean(pageNode?.querySelector('.agent-expert-picker')),
expertHeading: pageNode?.querySelector('.agent-expert-heading')?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
}
}, channel)
@@ -350,6 +373,8 @@ for (const channel of [
}
if (channel.label === 'AI 会话') {
await page.screenshot({ path: 'test-results/project-ai-expert-picker-light.png', fullPage: true })
await page.getByRole('button', { name: /产品经理/ }).click()
await page.locator('.agent-composer textarea').fill('请分析这个项目的下一步计划')
await page.getByRole('button', { name: '发送消息' }).click()
await page.waitForSelector('.agent-user-message')
@@ -357,6 +382,7 @@ for (const channel of [
userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '',
activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '',
composerValue: document.querySelector('.agent-composer textarea')?.value ?? '',
expertName: document.querySelector('.agent-assistant-name')?.textContent?.trim() ?? '',
}))
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
}
@@ -552,7 +578,10 @@ for (const check of channelPageChecks) {
if (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) {
failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
}
if (check.label === 'AI 会话' && (check.hasOverviewHead || !check.hasSessionList || !check.hasComposer)) {
if (check.label === 'AI 会话' && (
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||
!check.expertHeading.includes('选择专家进行会话')
)) {
failures.push(`expected headerless AI session chat layout, got ${JSON.stringify(check)}`)
}
}
@@ -570,7 +599,8 @@ if (
!aiSessionCheck ||
aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' ||
!aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') ||
aiSessionCheck.composerValue !== ''
aiSessionCheck.composerValue !== '' ||
aiSessionCheck.expertName !== '产品经理'
) {
failures.push(`expected AI composer to create and select a session, got ${JSON.stringify(aiSessionCheck)}`)
}