fix: harden controlled AI session consistency
This commit is contained in:
@@ -117,12 +117,17 @@ const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts',
|
||||
for (const required of ['/api/v1/projects/', '/ai-sessions', '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')
|
||||
if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass the AbortSignal to apiRequest')
|
||||
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 required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {
|
||||
if (!aiPageSource.includes(required)) failures.push(`project AI page must gate stale requests with ${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}`)
|
||||
}
|
||||
@@ -140,7 +145,7 @@ const unsupportedControls = [
|
||||
},
|
||||
{
|
||||
file: 'src/pages/projects/project-statusbar.tsx',
|
||||
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲'],
|
||||
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲', '服务已连接', 'IconCheckCircle'],
|
||||
},
|
||||
{
|
||||
file: 'src/pages/projects/project-topbar.tsx',
|
||||
|
||||
@@ -43,6 +43,12 @@ const projectPatchRequests = []
|
||||
const inboxAnalyzeRequests = []
|
||||
const inboxConfirmRequests = []
|
||||
const aiSessionRequests = []
|
||||
let delayedAIListsRemaining = 0
|
||||
let delayNextAICreate = false
|
||||
let delayedAICreateCompleted = false
|
||||
let failNextSecondProjectAIList = false
|
||||
let expectingSecondProjectAIListError = false
|
||||
let expectedSecondProjectAIListConsoleErrorCount = 0
|
||||
let expectingProjectPatchError = false
|
||||
let expectedProjectPatchConsoleErrorCount = 0
|
||||
let expectingInboxConfirmError = false
|
||||
@@ -71,6 +77,13 @@ const controlledAISessions = [
|
||||
]
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return
|
||||
if (
|
||||
expectingSecondProjectAIListError &&
|
||||
message.text().includes('Failed to load resource')
|
||||
) {
|
||||
expectedSecondProjectAIListConsoleErrorCount += 1
|
||||
return
|
||||
}
|
||||
if (
|
||||
expectingProjectPatchError &&
|
||||
expectedProjectPatchConsoleErrorCount === 0 &&
|
||||
@@ -188,6 +201,7 @@ const secondVisualCheckWorkspace = {
|
||||
},
|
||||
channels: [
|
||||
{ id: 'second-overview', projectId: secondProjectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
|
||||
{ id: 'second-ai', projectId: secondProjectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 1 },
|
||||
],
|
||||
tags: [],
|
||||
recentSessions: [],
|
||||
@@ -232,8 +246,11 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
return
|
||||
}
|
||||
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
await route.fulfill({ json: controlledAISessions })
|
||||
const responseSnapshot = structuredClone(controlledAISessions)
|
||||
const delay = delayedAIListsRemaining > 0 ? 700 : 120
|
||||
if (delayedAIListsRemaining > 0) delayedAIListsRemaining -= 1
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
await route.fulfill({ json: responseSnapshot }).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'POST') {
|
||||
@@ -248,8 +265,24 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
createdAt: '2026-07-21T03:10:00Z',
|
||||
updatedAt: '2026-07-21T03:10:00Z',
|
||||
}
|
||||
if (delayNextAICreate) {
|
||||
delayNextAICreate = false
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
delayedAICreateCompleted = true
|
||||
}
|
||||
controlledAISessions.unshift(created)
|
||||
await route.fulfill({ status: 201, json: created })
|
||||
await route.fulfill({ status: 201, json: created }).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (url.pathname === `/api/v1/projects/${secondProjectId}/ai-sessions` && method === 'GET') {
|
||||
if (failNextSecondProjectAIList) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
json: { error: { code: 'ai_list_failed', message: '新项目会话加载失败' } },
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({ json: [] })
|
||||
return
|
||||
}
|
||||
if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') {
|
||||
@@ -990,6 +1023,57 @@ for (const channel of [
|
||||
activeChannel,
|
||||
}
|
||||
}, channel))
|
||||
|
||||
if (channel.label === 'AI 助手') {
|
||||
await page.locator('.channel-button', { hasText: '工作计划' }).click()
|
||||
delayedAIListsRemaining = 2
|
||||
await page.locator('.channel-button', { hasText: 'AI 助手' }).click()
|
||||
const sameProjectRacePage = page.locator('.project-ai-page')
|
||||
await sameProjectRacePage.locator('input').fill('乱序请求保留的新会话')
|
||||
await sameProjectRacePage.locator('textarea').fill('POST 完成后,旧 GET 不得覆盖结果')
|
||||
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
|
||||
await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
|
||||
await page.waitForTimeout(850)
|
||||
if (await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).count() !== 1) {
|
||||
failures.push('a stale AI session GET must not overwrite a newer POST result in the same project')
|
||||
}
|
||||
delayedAIListsRemaining = 0
|
||||
|
||||
delayNextAICreate = true
|
||||
await sameProjectRacePage.locator('input').fill('旧项目延迟会话')
|
||||
await sameProjectRacePage.locator('textarea').fill('切换项目后不得回流')
|
||||
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
|
||||
failNextSecondProjectAIList = true
|
||||
expectingSecondProjectAIListError = true
|
||||
await page.locator('.project-button[title="并行项目"]').click()
|
||||
await page.locator('.project-title h5').getByText('并行项目', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
|
||||
await page.locator('.channel-button', { hasText: 'AI 助手' }).click()
|
||||
const secondProjectAIPage = page.locator('.project-ai-page')
|
||||
await secondProjectAIPage.waitFor({ state: 'visible', timeout: 2000 })
|
||||
await page.waitForTimeout(500)
|
||||
const secondProjectAIText = await secondProjectAIPage.textContent()
|
||||
if (!secondProjectAIText?.includes('新项目会话加载失败')) {
|
||||
failures.push(`the new project AI list failure must be visible, got ${JSON.stringify(secondProjectAIText)}`)
|
||||
}
|
||||
if (!secondProjectAIText?.includes('暂无 AI 会话')) {
|
||||
failures.push(`the new project AI list failure must retain an empty list, got ${JSON.stringify(secondProjectAIText)}`)
|
||||
}
|
||||
await page.waitForTimeout(850)
|
||||
if (!delayedAICreateCompleted) failures.push('the simulated old-project AI POST did not complete')
|
||||
if (await secondProjectAIPage.getByText('旧项目延迟会话', { exact: true }).count() !== 0) {
|
||||
failures.push('an old-project AI POST must not mutate the newly selected project')
|
||||
}
|
||||
if (await secondProjectAIPage.getByText('新项目会话加载失败', { exact: true }).count() !== 1) {
|
||||
failures.push('an old-project AI POST completion must not clear the new project load error')
|
||||
}
|
||||
failNextSecondProjectAIList = false
|
||||
expectingSecondProjectAIListError = false
|
||||
if (expectedSecondProjectAIListConsoleErrorCount < 1) {
|
||||
failures.push(`expected a simulated second-project AI list console error, got ${expectedSecondProjectAIListConsoleErrorCount}`)
|
||||
}
|
||||
await page.locator('.project-button[title="森林项目已更新"]').click()
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
|
||||
const unsupportedControlMetrics = await page.evaluate(() => ({
|
||||
@@ -1170,9 +1254,9 @@ 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 (aiSessionRequests.length !== 3) failures.push(`expected three controlled AI session requests, got ${aiSessionRequests.length}`)
|
||||
for (const request of aiSessionRequests) {
|
||||
const requestKeys = Object.keys(request).sort()
|
||||
if (JSON.stringify(requestKeys) !== JSON.stringify(['context', 'title'])) {
|
||||
failures.push(`AI session create must send only title/context, got ${JSON.stringify(requestKeys)}`)
|
||||
}
|
||||
@@ -1180,7 +1264,7 @@ if (aiSessionRequests.length === 1) {
|
||||
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)) {
|
||||
if (/升级|支付|AI 空闲|GB 可用|服务已连接/.test(unsupportedControlMetrics.statusbarText)) {
|
||||
failures.push(`statusbar contains unsupported product state: ${unsupportedControlMetrics.statusbarText}`)
|
||||
}
|
||||
if (!unsupportedControlMetrics.newChannelText.includes('暂未开放') || unsupportedControlMetrics.newChannelButtonCount !== 0) {
|
||||
|
||||
@@ -15,16 +15,18 @@ export type CreateAISessionInput = {
|
||||
context: string
|
||||
}
|
||||
|
||||
export async function listAISessions(session: ApiSession, projectId: string) {
|
||||
export async function listAISessions(session: ApiSession, projectId: string, signal?: AbortSignal) {
|
||||
return apiRequest<AISessionDTO[]>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
||||
token: session.token,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput) {
|
||||
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput, signal?: AbortSignal) {
|
||||
return apiRequest<AISessionDTO>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,14 +44,14 @@ function App() {
|
||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||
const workspaceSearch = useWorkbenchSearch(session)
|
||||
|
||||
const handleListAISessions = useCallback((projectId: string) => {
|
||||
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listAISessions(session, projectId)
|
||||
return listAISessions(session, projectId, signal)
|
||||
}, [session])
|
||||
|
||||
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput) => {
|
||||
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return createAISession(session, projectId, input)
|
||||
return createAISession(session, projectId, input, signal)
|
||||
}, [session])
|
||||
|
||||
const dark = theme === 'dark'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconPlusCircle, IconRobot } from '@arco-design/web-react/icon'
|
||||
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||
@@ -14,8 +14,8 @@ export function ProjectAi({
|
||||
}: {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onSelectItem: (title: string) => void
|
||||
onListSessions: (projectId: string) => Promise<AISessionDTO[]>
|
||||
onCreateSession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
||||
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<AISessionDTO[]>([])
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -24,23 +24,47 @@ export function ProjectAi({
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const projectId = activeWorkspace.project.id
|
||||
const projectRef = useRef(projectId)
|
||||
const generationRef = useRef(0)
|
||||
const listControllerRef = useRef<AbortController | null>(null)
|
||||
const createControllerRef = useRef<AbortController | null>(null)
|
||||
projectRef.current = projectId
|
||||
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
const generation = ++generationRef.current
|
||||
const controller = new AbortController()
|
||||
listControllerRef.current?.abort()
|
||||
createControllerRef.current?.abort()
|
||||
listControllerRef.current = controller
|
||||
createControllerRef.current = null
|
||||
setSessions([])
|
||||
setTitle('')
|
||||
setContext('')
|
||||
setLoading(true)
|
||||
setCreating(false)
|
||||
setError('')
|
||||
void onListSessions(projectId)
|
||||
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
||||
|
||||
void onListSessions(projectId, controller.signal)
|
||||
.then((items) => {
|
||||
if (current) setSessions(items)
|
||||
if (isCurrent()) setSessions(items)
|
||||
})
|
||||
.catch((requestError: unknown) => {
|
||||
if (current) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
||||
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (current) setLoading(false)
|
||||
if (isCurrent()) {
|
||||
setLoading(false)
|
||||
listControllerRef.current = null
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
current = false
|
||||
generationRef.current += 1
|
||||
controller.abort()
|
||||
if (listControllerRef.current === controller) listControllerRef.current = null
|
||||
createControllerRef.current?.abort()
|
||||
createControllerRef.current = null
|
||||
}
|
||||
}, [onListSessions, projectId])
|
||||
|
||||
@@ -50,21 +74,35 @@ export function ProjectAi({
|
||||
setError('请输入 AI 会话标题')
|
||||
return
|
||||
}
|
||||
const generation = ++generationRef.current
|
||||
listControllerRef.current?.abort()
|
||||
listControllerRef.current = null
|
||||
createControllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
createControllerRef.current = controller
|
||||
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
||||
|
||||
setLoading(false)
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await onCreateSession(projectId, {
|
||||
title: trimmedTitle,
|
||||
context: context.trim(),
|
||||
})
|
||||
}, controller.signal)
|
||||
if (!isCurrent()) return
|
||||
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
|
||||
setTitle('')
|
||||
setContext('')
|
||||
onSelectItem(created.title)
|
||||
} catch (requestError) {
|
||||
if (!isCurrent()) return
|
||||
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
if (isCurrent()) {
|
||||
setCreating(false)
|
||||
createControllerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ export function ProjectChannelPage({
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
||||
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||
}) {
|
||||
switch (activeChannel) {
|
||||
case 'inbox':
|
||||
@@ -49,7 +49,7 @@ export function ProjectChannelPage({
|
||||
case 'tasks':
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
||||
case 'ai':
|
||||
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
|
||||
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
|
||||
case 'notes':
|
||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||
case 'cron':
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Layout, Space } from '@arco-design/web-react'
|
||||
import { IconCheckCircle } from '@arco-design/web-react/icon'
|
||||
import { Layout } from '@arco-design/web-react'
|
||||
|
||||
const { Footer } = Layout
|
||||
|
||||
@@ -10,10 +9,6 @@ export function ProjectStatusbar() {
|
||||
<span className="status-avatar" aria-hidden="true">森</span>
|
||||
<span className="status-name">已登录</span>
|
||||
</div>
|
||||
<Space className="status-system" size={8}>
|
||||
<IconCheckCircle />
|
||||
<span>服务已连接</span>
|
||||
</Space>
|
||||
</Footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export function ProjectPage({
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
||||
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||
}) {
|
||||
const isProject = activeView === 'project'
|
||||
const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user