feat: align controlled AI sessions and MVP controls

This commit is contained in:
2026-07-21 19:19:36 +08:00
parent 8767446b78
commit 122f5d8c52
18 changed files with 758 additions and 468 deletions

View File

@@ -1,80 +1,134 @@
import { Button, Card, Input, Space, Typography } from '@arco-design/web-react'
import {
IconArrowUp,
IconAttachment,
IconPlusCircle,
IconRobot,
} from '@arco-design/web-react/icon'
import type { AISession, ProjectWorkspace } from './project-types'
import { useEffect, 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'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectAi({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
const { aiSessions } = activeWorkspace
const groupedSessions = groupSessions(aiSessions)
export function ProjectAi({
activeWorkspace,
onSelectItem,
onListSessions,
onCreateSession,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onListSessions: (projectId: string) => Promise<AISessionDTO[]>
onCreateSession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
}) {
const [sessions, setSessions] = useState<AISessionDTO[]>([])
const [title, setTitle] = useState('')
const [context, setContext] = useState('')
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const projectId = activeWorkspace.project.id
useEffect(() => {
let current = true
setLoading(true)
setError('')
void onListSessions(projectId)
.then((items) => {
if (current) setSessions(items)
})
.catch((requestError: unknown) => {
if (current) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
})
.finally(() => {
if (current) setLoading(false)
})
return () => {
current = false
}
}, [onListSessions, projectId])
const createSession = async () => {
const trimmedTitle = title.trim()
if (!trimmedTitle) {
setError('请输入 AI 会话标题')
return
}
setCreating(true)
setError('')
try {
const created = await onCreateSession(projectId, {
title: trimmedTitle,
context: context.trim(),
})
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
setTitle('')
setContext('')
onSelectItem(created.title)
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
} finally {
setCreating(false)
}
}
return (
<div className="project-channel-page project-ai-page overview-page">
<div className="agent-chat-shell">
<Card className="agent-session-list queue-section" bordered>
<Button className="agent-new-chat" icon={<IconPlusCircle />}>
</Button>
<div className="overview-head">
<div>
<Title heading={4}>AI </Title>
<Text type="secondary"></Text>
</div>
</div>
<div className="agent-session-groups">
{groupedSessions.length ? (
groupedSessions.map((group) => (
<section className="agent-session-group" key={group.label}>
<Text type="secondary">{group.label}</Text>
{group.items.map((session, index) => (
<button
className={index === 0 && group.label === groupedSessions[0]?.label ? 'agent-session active' : 'agent-session'}
key={session.id}
onClick={() => onSelectItem(session.title)}
>
{session.title}
</button>
))}
</section>
))
) : (
<Text type="secondary"></Text>
)}
</div>
{error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null}
<div className="agent-chat-shell">
<Card className="agent-session-list queue-section" bordered title="会话列表">
<Spin loading={loading} style={{ width: '100%' }}>
{sessions.length ? (
<div className="agent-session-groups">
{sessions.map((session) => (
<button className="agent-session" key={session.id} onClick={() => onSelectItem(session.title)}>
<span>{session.title}</span>
<Tag size="small" color={session.status === 'ready' ? 'arcoblue' : 'gray'}>
{sessionStatusLabel(session.status)}
</Tag>
</button>
))}
</div>
) : loading ? null : <Empty description="暂无 AI 会话" />}
</Spin>
</Card>
<Card className="agent-chat-panel queue-section" bordered>
<div className="agent-chat-main">
<Title heading={2}></Title>
</div>
<div className="agent-composer">
<Input.TextArea placeholder="给 DeepSeek 发送消息" autoSize={{ minRows: 3, maxRows: 6 }} />
<div className="agent-composer-footer">
<Button className="agent-model-chip" icon={<IconRobot />}>
DeepSeek V4.0 Flash
</Button>
<Space>
<Button type="text" icon={<IconAttachment />} />
<Button className="agent-send-button" type="primary" shape="circle" icon={<IconArrowUp />} />
</Space>
<Space direction="vertical" size={16} className="action-form">
<div>
<Title heading={5}></Title>
<Text type="secondary"> AI </Text>
</div>
</div>
<label>
<Text></Text>
<Input value={title} onChange={setTitle} placeholder="例如:报价分析" maxLength={120} />
</label>
<label>
<Text></Text>
<Input.TextArea
value={context}
onChange={setContext}
placeholder="描述本次会话要参考的项目背景"
autoSize={{ minRows: 5, maxRows: 10 }}
/>
</label>
<Button type="primary" icon={<IconPlusCircle />} loading={creating} onClick={() => void createSession()}>
</Button>
<Text type="secondary"><IconRobot /> AI </Text>
</Space>
</Card>
</div>
</div>
)
}
function groupSessions(sessions: AISession[]) {
const labels = ['今天', '昨天', '7 天内']
return labels.map((label) => ({
label,
items: sessions.filter((session, index) => session.time.includes(label) || (!labels.some((item) => session.time.includes(item)) && labels.indexOf(label) === fallbackGroupIndex(index))),
})).filter((group) => group.items.length > 0)
}
function fallbackGroupIndex(index: number) {
if (index === 0) return 0
if (index === 1) return 1
return 2
function sessionStatusLabel(status: string) {
if (status === 'ready') return '待开始'
if (status === 'failed') return '创建失败'
return status || '未知状态'
}