feat(ai): add local expert library

This commit is contained in:
2026-07-22 17:09:54 +08:00
parent b28a5d635c
commit 42dd0dce47
22 changed files with 3432 additions and 60 deletions

View File

@@ -1,7 +1,7 @@
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 { 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
@@ -10,17 +10,24 @@ 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
@@ -38,9 +45,14 @@ export function ProjectAi({
listControllerRef.current = controller
createControllerRef.current = null
setSessions([])
setExperts([])
setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('')
setLoading(true)
setLoadingExperts(true)
setCreating(false)
setError('')
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
@@ -52,9 +64,20 @@ export function ProjectAi({
.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()) {
setLoading(false)
setLoadingExperts(false)
listControllerRef.current = null
}
})
@@ -66,15 +89,36 @@ export function ProjectAi({
createControllerRef.current?.abort()
createControllerRef.current = null
}
}, [onListSessions, projectId])
}, [onListExperts, onListSessions, projectId])
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
@@ -94,10 +138,12 @@ export function ProjectAi({
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) {
@@ -113,27 +159,27 @@ export function ProjectAi({
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={() => {
setSelectedSessionID(null)
setPrompt('')
setError('')
}}
>
</Button>
<Button className="agent-new-chat" icon={<IconPlus />} onClick={startNewSession}></Button>
<Spin loading={loading} style={{ width: '100%' }}>
{sessions.length ? (
@@ -147,7 +193,7 @@ export function ProjectAi({
title={session.title}
onClick={() => selectSession(session)}
>
<IconRobot />
<span className="agent-session-emoji">{session.expert?.emoji || <IconRobot />}</span>
<span>{session.title}</span>
</button>
))}
@@ -163,25 +209,36 @@ export function ProjectAi({
<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>
<span className="agent-assistant-avatar" style={{ background: selectedExpert?.color || undefined }}>
{selectedExpert?.emoji || <IconRobot />}
</span>
<div>
<Text className="agent-assistant-name">AI</Text>
<Text className="agent-assistant-name">{selectedExpert?.name || '森林AI'}</Text>
<p></p>
</div>
</div>
</div>
) : (
<div className="agent-empty-state">
<span className="agent-empty-icon"><IconRobot /></span>
<Text></Text>
</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={`给森林AI发送消息结合“${activeWorkspace.project.name}”项目上下文`}
placeholder={selectedExpert
? `${selectedExpert.name}发送消息,结合“${activeWorkspace.project.name}”项目上下文`
: '请先从上方选择一位专家'}
autoSize={{ minRows: 2, maxRows: 7 }}
onChange={setPrompt}
onPressEnter={(event) => {
@@ -192,7 +249,9 @@ export function ProjectAi({
}}
/>
<div className="agent-composer-footer">
<Button className="agent-model-chip" icon={<IconRobot />}></Button>
<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
@@ -202,7 +261,7 @@ export function ProjectAi({
shape="circle"
icon={<IconArrowUp />}
loading={creating}
disabled={!prompt.trim()}
disabled={!selectedExpert || !prompt.trim()}
onClick={() => void createSession()}
/>
</div>
@@ -215,6 +274,72 @@ export function ProjectAi({
)
}
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)

View File

@@ -8,7 +8,7 @@ import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
export function ProjectChannelPage({
activeChannel,
@@ -25,6 +25,7 @@ export function ProjectChannelPage({
onAnalyzeInbox,
onConfirmInbox,
onListAISessions,
onListAIExperts,
onCreateAISession,
}: {
activeChannel: ChannelKey
@@ -41,6 +42,7 @@ export function ProjectChannelPage({
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
switch (activeChannel) {
@@ -49,7 +51,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 key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onListExperts={onListAIExperts} onCreateSession={onCreateAISession} />
case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':

View File

@@ -9,7 +9,7 @@ import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search'
import type { InboxSuggestionDTO } from '../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
@@ -46,6 +46,7 @@ export function ProjectPage({
onAnalyzeInbox,
onConfirmInbox,
onListAISessions,
onListAIExperts,
onCreateAISession,
}: {
activeView: WorkbenchView
@@ -79,6 +80,7 @@ export function ProjectPage({
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
const isProject = activeView === 'project'
@@ -152,6 +154,7 @@ export function ProjectPage({
onAnalyzeInbox={onAnalyzeInbox}
onConfirmInbox={onConfirmInbox}
onListAISessions={onListAISessions}
onListAIExperts={onListAIExperts}
onCreateAISession={onCreateAISession}
/>
)}