feat: align controlled AI sessions and MVP controls

This commit is contained in:
2026-07-21 19:19:36 +08:00
parent 9fa6744516
commit 82829645c2
19 changed files with 850 additions and 465 deletions

View File

@@ -24,6 +24,7 @@ const requiredFiles = [
'src/api/mappers.tsx',
'src/api/search.ts',
'src/api/inbox.ts',
'src/api/ai.ts',
'scripts/api-client.test.mjs',
]
@@ -59,7 +60,7 @@ if (existsSync('src/app/App.tsx')) {
}
const apiFiles = existsSync('src/api')
? ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts']
? ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts', 'ai.ts']
.map((name) => `src/api/${name}`)
.filter((file) => existsSync(file))
: []
@@ -112,6 +113,48 @@ if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confi
const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : ''
if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts', 'utf8') : ''
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
}
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
const aiPageSource = existsSync('src/pages/projects/project-ai.tsx') ? readFileSync('src/pages/projects/project-ai.tsx', 'utf8') : ''
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
}
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
}
const unsupportedControls = [
{
file: 'src/pages/workspace-explore.tsx',
required: '暂未开放',
forbidden: ['同步数据源', '添加数据源', 'IconRefresh', 'IconEdit', 'IconDelete'],
},
{
file: 'src/pages/projects/project-new-channel.tsx',
required: '暂未开放',
forbidden: ['保存频道', '<Input', '<Select', '<TextArea'],
},
{
file: 'src/pages/projects/project-statusbar.tsx',
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲'],
},
{
file: 'src/pages/projects/project-topbar.tsx',
forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'],
},
]
for (const check of unsupportedControls) {
const source = existsSync(check.file) ? readFileSync(check.file, 'utf8') : ''
if (check.required && !source.includes(check.required)) failures.push(`${check.file} must show ${check.required}`)
for (const forbidden of check.forbidden) {
if (source.includes(forbidden)) failures.push(`${check.file} contains unsupported control ${forbidden}`)
}
}
const html = readFileSync('index.html', 'utf8')
if (!html.includes('<html lang="zh-CN">')) failures.push('index language must be zh-CN')
if (!html.includes('<title>森林AI</title>')) failures.push('document title must be 森林AI')

View File

@@ -31,6 +31,8 @@ const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010'
const secondInboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000011'
const thirdInboxItemId = '019b0000-0000-7000-8000-000000000013'
const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014'
const aiSessionId = '019b0000-0000-7000-8000-000000000015'
const createdAISessionId = '019b0000-0000-7000-8000-000000000016'
const unknownProjectId = '019b0000-0000-7000-8000-000000000099'
const externalProjectId = '019b0000-0000-7000-8000-000000000088'
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
@@ -40,6 +42,7 @@ const workspaceRequests = []
const projectPatchRequests = []
const inboxAnalyzeRequests = []
const inboxConfirmRequests = []
const aiSessionRequests = []
let expectingProjectPatchError = false
let expectedProjectPatchConsoleErrorCount = 0
let expectingInboxConfirmError = false
@@ -55,6 +58,17 @@ let failNextInboxWorkspaceRefresh = false
let conflictNextInboxConfirm = false
let validationNextInboxConfirm = false
let invalidResponseNextInboxConfirm = false
const controlledAISessions = [
{
id: aiSessionId,
projectId,
title: '项目风险梳理',
context: '只维护会话上下文,不创建正式对象。',
status: 'ready',
createdAt: '2026-07-21T03:00:00Z',
updatedAt: '2026-07-21T03:00:00Z',
},
]
page.on('console', (message) => {
if (message.type() !== 'error') return
if (
@@ -217,6 +231,27 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
await route.fulfill({ json: secondVisualCheckWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
await new Promise((resolve) => setTimeout(resolve, 120))
await route.fulfill({ json: controlledAISessions })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'POST') {
const input = route.request().postDataJSON()
aiSessionRequests.push(input)
const created = {
id: createdAISessionId,
projectId,
title: input.title,
context: input.context,
status: 'ready',
createdAt: '2026-07-21T03:10:00Z',
updatedAt: '2026-07-21T03:10:00Z',
}
controlledAISessions.unshift(created)
await route.fulfill({ status: 201, json: created })
return
}
if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(inboxItemId)
if (inboxAnalyzeRequests.length === 1) await new Promise((resolve) => setTimeout(resolve, 250))
@@ -487,6 +522,10 @@ await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceExploreMetrics = await collectMetrics()
const workspaceExploreControls = await page.evaluate(() => ({
emptyState: document.querySelector('.workspace-explore-page')?.textContent ?? '',
buttonCount: document.querySelectorAll('.workspace-explore-page button').length,
}))
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
@@ -924,13 +963,22 @@ if (await inboxChannelButton.count() === 0) {
const channelPageChecks = []
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: '选择专家,开始对话' },
{ label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: 'AI 助手' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
if (channel.label === 'AI 助手') {
const aiPage = page.locator('.project-ai-page')
await aiPage.getByText('项目风险梳理', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await aiPage.locator('input').fill('新会话视觉检查')
await aiPage.locator('textarea').fill('只创建受控会话入口')
await aiPage.getByRole('button', { name: '创建会话', exact: true }).click()
await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.screenshot({ path: 'test-results/project-ai-controlled.png', fullPage: true })
}
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? ''
@@ -944,6 +992,13 @@ for (const channel of [
}, channel))
}
const unsupportedControlMetrics = await page.evaluate(() => ({
topbarLabels: [...document.querySelectorAll('.topbar-actions button')].map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()),
statusbarText: document.querySelector('.statusbar')?.textContent ?? '',
newChannelText: document.querySelector('.project-new-channel-page')?.textContent ?? '',
newChannelButtonCount: document.querySelectorAll('.project-new-channel-page button').length,
}))
await page.getByRole('button', { name: '切换深色模式', exact: true }).click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
@@ -953,12 +1008,15 @@ await server.close()
console.log(JSON.stringify({
workspaceMetrics,
workspaceExploreMetrics,
workspaceExploreControls,
projectMetrics,
mobileProjectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
unsupportedControlMetrics,
aiSessionRequests,
errors,
}, null, 2))
@@ -1038,6 +1096,8 @@ if (
}
if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
if (!workspaceExploreControls.emptyState.includes('暂未开放')) failures.push('workspace explore must present the unsupported feature as 暂未开放')
if (workspaceExploreControls.buttonCount !== 0) failures.push(`workspace explore must not render active controls, got ${workspaceExploreControls.buttonCount}`)
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
@@ -1110,6 +1170,23 @@ for (const check of channelPageChecks) {
}
}
if (aiSessionRequests.length !== 1) failures.push(`expected one controlled AI session request, got ${aiSessionRequests.length}`)
if (aiSessionRequests.length === 1) {
const requestKeys = Object.keys(aiSessionRequests[0]).sort()
if (JSON.stringify(requestKeys) !== JSON.stringify(['context', 'title'])) {
failures.push(`AI session create must send only title/context, got ${JSON.stringify(requestKeys)}`)
}
}
if (unsupportedControlMetrics.topbarLabels.some((label) => label?.includes('停靠'))) {
failures.push(`topbar must not expose dock controls, got ${JSON.stringify(unsupportedControlMetrics.topbarLabels)}`)
}
if (/升级|支付|AI 空闲|GB 可用/.test(unsupportedControlMetrics.statusbarText)) {
failures.push(`statusbar contains unsupported product state: ${unsupportedControlMetrics.statusbarText}`)
}
if (!unsupportedControlMetrics.newChannelText.includes('暂未开放') || unsupportedControlMetrics.newChannelButtonCount !== 0) {
failures.push(`new channel must be a non-interactive 暂未开放 state, got ${JSON.stringify(unsupportedControlMetrics)}`)
}
if (failures.length) {
throw new Error(failures.join('\n'))
}