fix: harden controlled AI session consistency

This commit is contained in:
2026-07-21 19:54:25 +08:00
parent 122f5d8c52
commit 251b212e06
15 changed files with 406 additions and 62 deletions

View File

@@ -106,12 +106,17 @@ const aiApiSource = 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 (!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 = 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}`)
}
@@ -119,7 +124,7 @@ for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'Ic
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-statusbar.tsx', forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲', '服务已连接', 'IconCheckCircle'] },
{ file: 'src/pages/projects/project-topbar.tsx', forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'] },
]
for (const check of unsupportedControls) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,8 +78,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 projects = workspaces.map((workspace) => workspace.project)