Files
agent/apps/web_v1/src/pages/projects/project-ai.tsx

223 lines
8.3 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Button, Card, Empty, Input, Spin, Typography } from '@arco-design/web-react'
import { IconArrowUp, IconAttachment, IconPlus, IconRobot } from '@arco-design/web-react/icon'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { ProjectWorkspace } from './project-types'
2026-07-20 12:26:21 +08:00
const { Text } = Typography
2026-07-20 12:26:21 +08:00
export function ProjectAi({
activeWorkspace,
onSelectItem,
onListSessions,
onCreateSession,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
const [sessions, setSessions] = useState<AISessionDTO[]>([])
const [selectedSessionID, setSelectedSessionID] = useState<string | null>(null)
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(true)
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(() => {
const generation = ++generationRef.current
const controller = new AbortController()
listControllerRef.current?.abort()
createControllerRef.current?.abort()
listControllerRef.current = controller
createControllerRef.current = null
setSessions([])
setSelectedSessionID(null)
setPrompt('')
setLoading(true)
setCreating(false)
setError('')
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
void onListSessions(projectId, controller.signal)
.then((items) => {
if (isCurrent()) setSessions(items)
})
.catch((requestError: unknown) => {
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
})
.finally(() => {
if (isCurrent()) {
setLoading(false)
listControllerRef.current = null
}
})
return () => {
generationRef.current += 1
controller.abort()
if (listControllerRef.current === controller) listControllerRef.current = null
createControllerRef.current?.abort()
createControllerRef.current = null
}
}, [onListSessions, projectId])
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionID) ?? null,
[selectedSessionID, sessions],
)
const createSession = async () => {
const message = prompt.trim()
if (!message) {
setError('请输入会话内容')
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: sessionTitle(message),
context: message,
}, controller.signal)
if (!isCurrent()) return
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
setSelectedSessionID(created.id)
setPrompt('')
onSelectItem(created.title)
} catch (requestError) {
if (!isCurrent()) return
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
} finally {
if (isCurrent()) {
setCreating(false)
createControllerRef.current = null
}
}
}
function selectSession(session: AISessionDTO) {
setSelectedSessionID(session.id)
setError('')
onSelectItem(session.title)
}
2026-07-21 11:29:36 +08:00
return (
<div className="project-channel-page project-ai-page">
{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>
<Button
className="agent-new-chat"
icon={<IconPlus />}
onClick={() => {
setSelectedSessionID(null)
setPrompt('')
setError('')
}}
>
</Button>
<Spin loading={loading} style={{ width: '100%' }}>
{sessions.length ? (
<div className="agent-session-groups">
<section className="agent-session-group">
<Text type="secondary"></Text>
{sessions.map((session) => (
<button
className={selectedSessionID === session.id ? 'agent-session active' : 'agent-session'}
key={session.id}
title={session.title}
onClick={() => selectSession(session)}
>
<IconRobot />
<span>{session.title}</span>
</button>
))}
</section>
</div>
) : loading ? null : <Empty className="agent-session-empty" description="暂无会话" />}
</Spin>
2026-07-20 12:26:21 +08:00
</Card>
2026-07-21 11:29:36 +08:00
<Card className="agent-chat-panel queue-section" bordered>
<div className="agent-chat-main">
{selectedSession ? (
<div className="agent-thread">
<div className="agent-user-message">{selectedSession.context || selectedSession.title}</div>
<div className="agent-assistant-message">
<span className="agent-assistant-avatar"><IconRobot /></span>
<div>
<Text className="agent-assistant-name">AI</Text>
<p></p>
</div>
</div>
</div>
) : (
<div className="agent-empty-state">
<span className="agent-empty-icon"><IconRobot /></span>
<Text></Text>
</div>
)}
</div>
<div className="agent-composer">
<Input.TextArea
value={prompt}
placeholder={`给森林AI发送消息结合“${activeWorkspace.project.name}”项目上下文`}
autoSize={{ minRows: 2, maxRows: 7 }}
onChange={setPrompt}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
void createSession()
}
}}
/>
<div className="agent-composer-footer">
<Button className="agent-model-chip" icon={<IconRobot />}></Button>
<div className="agent-composer-actions">
<Button aria-label="添加附件" type="text" shape="circle" icon={<IconAttachment />} />
<Button
aria-label="发送消息"
className="agent-send-button"
type="primary"
shape="circle"
icon={<IconArrowUp />}
loading={creating}
disabled={!prompt.trim()}
onClick={() => void createSession()}
/>
</div>
2026-07-21 11:29:36 +08:00
</div>
<Text className="agent-composer-hint" type="secondary">AI </Text>
</div>
2026-07-20 12:26:21 +08:00
</Card>
</div>
</div>
)
}
2026-07-21 11:29:36 +08:00
function sessionTitle(message: string) {
const firstLine = message.split(/\r?\n/, 1)[0].trim()
const characters = Array.from(firstLine)
return characters.length > 36 ? `${characters.slice(0, 36).join('')}` : firstLine
2026-07-21 11:29:36 +08:00
}