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

369 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, IconSearch } from '@arco-design/web-react/icon'
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { ProjectWorkspace } from './project-types'
const { Text } = Typography
export function ProjectAi({
activeWorkspace,
onSelectItem,
onListSessions,
onListExperts,
onCreateSession,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
const [sessions, setSessions] = useState<AISessionDTO[]>([])
const [experts, setExperts] = useState<AIExpertDTO[]>([])
const [selectedSessionID, setSelectedSessionID] = useState<string | null>(null)
const [selectedExpertID, setSelectedExpertID] = useState<string | null>(null)
const [expertQuery, setExpertQuery] = useState('')
const [expertCategory, setExpertCategory] = useState('all')
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(true)
const [loadingExperts, setLoadingExperts] = 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([])
setExperts([])
setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt(readPromptDraft(projectId))
setLoading(true)
setLoadingExperts(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)
})
void onListExperts(controller.signal)
.then((items) => {
if (isCurrent()) setExperts(items)
})
.catch((requestError: unknown) => {
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : '专家库加载失败,请稍后重试')
})
.finally(() => {
if (isCurrent()) {
setLoadingExperts(false)
listControllerRef.current = null
}
})
return () => {
generationRef.current += 1
controller.abort()
if (listControllerRef.current === controller) listControllerRef.current = null
createControllerRef.current?.abort()
createControllerRef.current = null
}
}, [onListExperts, onListSessions, projectId])
useEffect(() => {
try {
if (prompt) localStorage.setItem(promptDraftKey(projectId), prompt)
else localStorage.removeItem(promptDraftKey(projectId))
} catch {
// Draft persistence is best effort; an unavailable browser store must not block chat.
}
}, [projectId, prompt])
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionID) ?? null,
[selectedSessionID, sessions],
)
const selectedExpert = useMemo(
() => experts.find((expert) => expert.id === selectedExpertID) ?? selectedSession?.expert ?? null,
[experts, selectedExpertID, selectedSession],
)
const expertCategories = useMemo(() => {
const categories = new Map<string, string>()
experts.forEach((expert) => categories.set(expert.category, expert.categoryName))
return [...categories.entries()].map(([id, name]) => ({ id, name }))
}, [experts])
const visibleExperts = useMemo(() => {
const keyword = expertQuery.trim().toLocaleLowerCase()
return experts.filter((expert) => {
if (expertCategory !== 'all' && expert.category !== expertCategory) return false
if (!keyword) return true
return `${expert.name} ${expert.description} ${expert.categoryName}`.toLocaleLowerCase().includes(keyword)
})
}, [expertCategory, expertQuery, experts])
const createSession = async () => {
const message = prompt.trim()
if (!selectedExpert) {
setError('请先选择一位专家')
return
}
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,
expertId: selectedExpert.id,
}, controller.signal)
if (!isCurrent()) return
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
setSelectedSessionID(created.id)
setSelectedExpertID(created.expert?.id ?? selectedExpert.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)
setSelectedExpertID(session.expert?.id ?? null)
setError('')
onSelectItem(session.title)
}
function startNewSession() {
setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('')
setError('')
}
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={startNewSession}></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)}
>
<span className="agent-session-emoji">{session.expert?.emoji || <IconRobot />}</span>
<span>{session.title}</span>
</button>
))}
</section>
</div>
) : loading ? null : <Empty className="agent-session-empty" description="暂无会话" />}
</Spin>
</Card>
<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" style={{ background: selectedExpert?.color || undefined }}>
{selectedExpert?.emoji || <IconRobot />}
</span>
<div>
<Text className="agent-assistant-name">{selectedExpert?.name || '森林AI'}</Text>
<p></p>
</div>
</div>
</div>
) : (
<ExpertPicker
experts={visibleExperts}
categories={expertCategories}
selectedExpertID={selectedExpertID}
query={expertQuery}
category={expertCategory}
loading={loadingExperts}
onQueryChange={setExpertQuery}
onCategoryChange={setExpertCategory}
onSelect={setSelectedExpertID}
/>
)}
</div>
<div className="agent-composer">
<Input.TextArea
value={prompt}
placeholder={selectedExpert
? `${selectedExpert.name}发送消息,结合“${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={<span>{selectedExpert?.emoji || <IconRobot />}</span>}>
{selectedExpert?.name || '选择专家'}
</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={!selectedExpert || !prompt.trim()}
onClick={() => void createSession()}
/>
</div>
</div>
<Text className="agent-composer-hint" type="secondary">AI </Text>
</div>
</Card>
</div>
</div>
)
}
function ExpertPicker({
experts,
categories,
selectedExpertID,
query,
category,
loading,
onQueryChange,
onCategoryChange,
onSelect,
}: {
experts: AIExpertDTO[]
categories: Array<{ id: string; name: string }>
selectedExpertID: string | null
query: string
category: string
loading: boolean
onQueryChange: (value: string) => void
onCategoryChange: (value: string) => void
onSelect: (value: string) => void
}) {
return (
<div className="agent-expert-picker">
<div className="agent-expert-heading">
<span className="agent-empty-icon"><IconRobot /></span>
<strong></strong>
<Text type="secondary"></Text>
</div>
<Input
className="agent-expert-search"
prefix={<IconSearch />}
value={query}
onChange={onQueryChange}
placeholder="搜索专家名称、简介或分类"
allowClear
/>
<div className="agent-expert-categories">
<button className={category === 'all' ? 'active' : ''} onClick={() => onCategoryChange('all')}></button>
{categories.map((item) => (
<button className={category === item.id ? 'active' : ''} key={item.id} onClick={() => onCategoryChange(item.id)}>{item.name}</button>
))}
</div>
<Spin className="agent-expert-loading" loading={loading}>
{experts.length ? (
<div className="agent-expert-grid">
{experts.map((expert) => (
<button
className={selectedExpertID === expert.id ? 'agent-expert-card active' : 'agent-expert-card'}
key={expert.id}
onClick={() => onSelect(expert.id)}
>
<span className="agent-expert-avatar" style={{ background: expert.color }}>{expert.emoji || expert.name.slice(0, 1)}</span>
<span className="agent-expert-copy">
<small>{expert.categoryName}</small>
<strong>{expert.name}</strong>
<span>{expert.description}</span>
</span>
</button>
))}
</div>
) : loading ? null : <Empty description="没有匹配的专家,换个关键词试试" />}
</Spin>
</div>
)
}
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
}
function promptDraftKey(projectId: string) {
return `senlin:ai:prompt:${projectId}`
}
function readPromptDraft(projectId: string) {
try {
return localStorage.getItem(promptDraftKey(projectId)) ?? ''
} catch {
return ''
}
}