feat(ai): add local expert library
This commit is contained in:
@@ -111,7 +111,7 @@ const inboxPageSource = readFileSync('src/pages/projects/project-inbox.tsx', 'ut
|
||||
if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
|
||||
|
||||
const aiApiSource = readFileSync('src/api/ai.ts', 'utf8')
|
||||
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
|
||||
for (const required of ['/api/v1/projects/', '/ai-sessions', '/api/v1/ai-experts', 'listAIExperts', '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')
|
||||
@@ -119,7 +119,7 @@ if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass t
|
||||
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 ['新建会话', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) {
|
||||
for (const required of ['新建会话', '选择专家进行会话', 'agent-expert-picker', 'selectedExpertID', 'expertId', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) {
|
||||
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
|
||||
}
|
||||
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {
|
||||
|
||||
@@ -18,6 +18,20 @@ const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 })
|
||||
const errors = []
|
||||
const projectId = '019b0000-0000-7000-8000-000000000001'
|
||||
const visualExperts = [
|
||||
{
|
||||
id: '019b0000-0000-7000-8000-000000000020', slug: 'product-manager', category: 'product', categoryName: '产品',
|
||||
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
|
||||
},
|
||||
{
|
||||
id: '019b0000-0000-7000-8000-000000000021', slug: 'ui-designer', category: 'design', categoryName: '设计',
|
||||
name: 'UI 设计师', description: '负责界面设计、组件规范和视觉一致性。', emoji: '🎨', color: '#722ED1',
|
||||
},
|
||||
{
|
||||
id: '019b0000-0000-7000-8000-000000000022', slug: 'frontend-developer', category: 'engineering', categoryName: '工程',
|
||||
name: '前端开发者', description: '负责现代 Web 应用实现与性能优化。', emoji: '💻', color: '#0FC6C2',
|
||||
},
|
||||
]
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
@@ -138,6 +152,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) {
|
||||
if (route.request().method() === 'POST') {
|
||||
const input = route.request().postDataJSON()
|
||||
const expert = visualExperts.find((item) => item.id === input.expertId)
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: '019b0000-0000-7000-8000-000000000010',
|
||||
@@ -145,6 +160,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
title: input.title,
|
||||
context: input.context,
|
||||
status: 'ready',
|
||||
expert,
|
||||
createdAt: '2026-07-22T09:00:00Z',
|
||||
updatedAt: '2026-07-22T09:00:00Z',
|
||||
},
|
||||
@@ -158,12 +174,17 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
title: '报价分析',
|
||||
context: '分析当前方案的报价结构。',
|
||||
status: 'ready',
|
||||
expert: visualExperts[1],
|
||||
createdAt: '2026-07-22T08:30:00Z',
|
||||
updatedAt: '2026-07-22T08:30:00Z',
|
||||
}],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/v1/ai-experts') {
|
||||
await route.fulfill({ json: visualExperts })
|
||||
return
|
||||
}
|
||||
await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } })
|
||||
})
|
||||
|
||||
@@ -334,6 +355,8 @@ for (const channel of [
|
||||
hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')),
|
||||
hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')),
|
||||
hasComposer: Boolean(pageNode?.querySelector('.agent-composer')),
|
||||
hasExpertPicker: Boolean(pageNode?.querySelector('.agent-expert-picker')),
|
||||
expertHeading: pageNode?.querySelector('.agent-expert-heading')?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
|
||||
}
|
||||
}, channel)
|
||||
|
||||
@@ -350,6 +373,8 @@ for (const channel of [
|
||||
}
|
||||
|
||||
if (channel.label === 'AI 会话') {
|
||||
await page.screenshot({ path: 'test-results/project-ai-expert-picker-light.png', fullPage: true })
|
||||
await page.getByRole('button', { name: /产品经理/ }).click()
|
||||
await page.locator('.agent-composer textarea').fill('请分析这个项目的下一步计划')
|
||||
await page.getByRole('button', { name: '发送消息' }).click()
|
||||
await page.waitForSelector('.agent-user-message')
|
||||
@@ -357,6 +382,7 @@ for (const channel of [
|
||||
userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '',
|
||||
activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '',
|
||||
composerValue: document.querySelector('.agent-composer textarea')?.value ?? '',
|
||||
expertName: document.querySelector('.agent-assistant-name')?.textContent?.trim() ?? '',
|
||||
}))
|
||||
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
|
||||
}
|
||||
@@ -552,7 +578,10 @@ for (const check of channelPageChecks) {
|
||||
if (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) {
|
||||
failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
|
||||
}
|
||||
if (check.label === 'AI 会话' && (check.hasOverviewHead || !check.hasSessionList || !check.hasComposer)) {
|
||||
if (check.label === 'AI 会话' && (
|
||||
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||
|
||||
!check.expertHeading.includes('选择专家进行会话')
|
||||
)) {
|
||||
failures.push(`expected headerless AI session chat layout, got ${JSON.stringify(check)}`)
|
||||
}
|
||||
}
|
||||
@@ -570,7 +599,8 @@ if (
|
||||
!aiSessionCheck ||
|
||||
aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' ||
|
||||
!aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') ||
|
||||
aiSessionCheck.composerValue !== ''
|
||||
aiSessionCheck.composerValue !== '' ||
|
||||
aiSessionCheck.expertName !== '产品经理'
|
||||
) {
|
||||
failures.push(`expected AI composer to create and select a session, got ${JSON.stringify(aiSessionCheck)}`)
|
||||
}
|
||||
|
||||
@@ -1642,6 +1642,13 @@
|
||||
color: var(--senlin-muted);
|
||||
}
|
||||
|
||||
.agent-session-emoji {
|
||||
width: 18px;
|
||||
flex: 0 0 18px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.agent-session:hover,
|
||||
.agent-session.active {
|
||||
background: color-mix(in srgb, var(--senlin-primary) 8%, var(--senlin-panel));
|
||||
@@ -1670,6 +1677,148 @@
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.agent-expert-picker {
|
||||
width: min(920px, 100%);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.agent-expert-heading {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-expert-heading strong {
|
||||
color: var(--senlin-text);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.agent-expert-search {
|
||||
width: min(560px, 100%);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.agent-expert-search.arco-input-wrapper {
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--senlin-bg) 68%, var(--senlin-panel));
|
||||
}
|
||||
|
||||
.agent-expert-categories {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
overflow-x: auto;
|
||||
padding: 1px 0 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.agent-expert-categories button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--senlin-border);
|
||||
border-radius: 999px;
|
||||
background: var(--senlin-panel);
|
||||
color: var(--senlin-muted);
|
||||
padding: 5px 11px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-expert-categories button:hover,
|
||||
.agent-expert-categories button.active {
|
||||
border-color: color-mix(in srgb, var(--senlin-primary) 45%, var(--senlin-border));
|
||||
background: var(--senlin-soft-blue);
|
||||
color: var(--senlin-primary);
|
||||
}
|
||||
|
||||
.agent-expert-loading {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-expert-loading > .arco-spin-children {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-expert-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.agent-expert-card {
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 11px;
|
||||
border: 1px solid var(--senlin-border);
|
||||
border-radius: 13px;
|
||||
background: var(--senlin-panel);
|
||||
color: var(--senlin-text);
|
||||
padding: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.agent-expert-card:hover,
|
||||
.agent-expert-card.active {
|
||||
border-color: color-mix(in srgb, var(--senlin-primary) 55%, var(--senlin-border));
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.agent-expert-card.active {
|
||||
background: color-mix(in srgb, var(--senlin-soft-blue) 62%, var(--senlin-panel));
|
||||
}
|
||||
|
||||
.agent-expert-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.agent-expert-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.agent-expert-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--senlin-primary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-expert-copy strong {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-expert-copy > span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--senlin-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.agent-empty-state {
|
||||
align-self: center;
|
||||
display: grid;
|
||||
@@ -1819,6 +1968,10 @@
|
||||
.agent-composer {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.agent-expert-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.reply-box,
|
||||
|
||||
@@ -6,13 +6,46 @@ export type AISessionDTO = {
|
||||
title: string
|
||||
context: string
|
||||
status: string
|
||||
expert: AIExpertDTO | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AIExpertDTO = {
|
||||
id: string
|
||||
slug: string
|
||||
category: string
|
||||
categoryName: string
|
||||
name: string
|
||||
description: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type AIExpertDetailDTO = AIExpertDTO & {
|
||||
systemPrompt: string
|
||||
source: string
|
||||
sourceLicense: string
|
||||
}
|
||||
|
||||
export type CreateAISessionInput = {
|
||||
title: string
|
||||
context: string
|
||||
expertId?: string
|
||||
}
|
||||
|
||||
export async function listAIExperts(session: ApiSession, signal?: AbortSignal) {
|
||||
return apiRequest<AIExpertDTO[]>('/api/v1/ai-experts', {
|
||||
token: session.token,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAIExpert(session: ApiSession, expertId: string, signal?: AbortSignal) {
|
||||
return apiRequest<AIExpertDetailDTO>(`/api/v1/ai-experts/${expertId}`, {
|
||||
token: session.token,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export async function listAISessions(session: ApiSession, projectId: string, signal?: AbortSignal) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import '../App.css'
|
||||
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
|
||||
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai'
|
||||
import { createAISession, listAIExperts, listAISessions, type CreateAISessionInput } from '../api/ai'
|
||||
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
|
||||
import { mapWorkspace } from '../api/mappers'
|
||||
import {
|
||||
@@ -49,6 +49,11 @@ function App() {
|
||||
return listAISessions(session, projectId, signal)
|
||||
}, [session])
|
||||
|
||||
const handleListAIExperts = useCallback((signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listAIExperts(session, signal)
|
||||
}, [session])
|
||||
|
||||
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return createAISession(session, projectId, input, signal)
|
||||
@@ -358,6 +363,7 @@ function App() {
|
||||
onAnalyzeInbox={handleAnalyzeInbox}
|
||||
onConfirmInbox={handleConfirmInbox}
|
||||
onListAISessions={handleListAISessions}
|
||||
onListAIExperts={handleListAIExperts}
|
||||
onCreateAISession={handleCreateAISession}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user