2026-07-22 16:26:05 +08:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import { Alert, Button, Card, Empty, Input, Spin, Typography } from '@arco-design/web-react'
|
2026-07-22 17:09:54 +08:00
|
|
|
|
import { IconArrowUp, IconAttachment, IconPlus, IconRobot, IconSearch } from '@arco-design/web-react/icon'
|
|
|
|
|
|
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
2026-07-21 19:19:36 +08:00
|
|
|
|
import type { ProjectWorkspace } from './project-types'
|
2026-07-20 12:26:21 +08:00
|
|
|
|
|
2026-07-22 16:26:05 +08:00
|
|
|
|
const { Text } = Typography
|
2026-07-20 12:26:21 +08:00
|
|
|
|
|
2026-07-21 19:19:36 +08:00
|
|
|
|
export function ProjectAi({
|
|
|
|
|
|
activeWorkspace,
|
|
|
|
|
|
onSelectItem,
|
|
|
|
|
|
onListSessions,
|
2026-07-22 17:09:54 +08:00
|
|
|
|
onListExperts,
|
2026-07-21 19:19:36 +08:00
|
|
|
|
onCreateSession,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
activeWorkspace: ProjectWorkspace
|
|
|
|
|
|
onSelectItem: (title: string) => void
|
2026-07-21 19:54:25 +08:00
|
|
|
|
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
2026-07-22 17:09:54 +08:00
|
|
|
|
onListExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
|
2026-07-21 19:54:25 +08:00
|
|
|
|
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
2026-07-21 19:19:36 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [sessions, setSessions] = useState<AISessionDTO[]>([])
|
2026-07-22 17:09:54 +08:00
|
|
|
|
const [experts, setExperts] = useState<AIExpertDTO[]>([])
|
2026-07-22 16:26:05 +08:00
|
|
|
|
const [selectedSessionID, setSelectedSessionID] = useState<string | null>(null)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
const [selectedExpertID, setSelectedExpertID] = useState<string | null>(null)
|
|
|
|
|
|
const [expertQuery, setExpertQuery] = useState('')
|
|
|
|
|
|
const [expertCategory, setExpertCategory] = useState('all')
|
2026-07-22 16:26:05 +08:00
|
|
|
|
const [prompt, setPrompt] = useState('')
|
2026-07-21 19:19:36 +08:00
|
|
|
|
const [loading, setLoading] = useState(true)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
const [loadingExperts, setLoadingExperts] = useState(true)
|
2026-07-21 19:19:36 +08:00
|
|
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
const projectId = activeWorkspace.project.id
|
2026-07-21 19:54:25 +08:00
|
|
|
|
const projectRef = useRef(projectId)
|
|
|
|
|
|
const generationRef = useRef(0)
|
|
|
|
|
|
const listControllerRef = useRef<AbortController | null>(null)
|
|
|
|
|
|
const createControllerRef = useRef<AbortController | null>(null)
|
|
|
|
|
|
projectRef.current = projectId
|
2026-07-21 19:19:36 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
const generation = ++generationRef.current
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
listControllerRef.current?.abort()
|
|
|
|
|
|
createControllerRef.current?.abort()
|
|
|
|
|
|
listControllerRef.current = controller
|
|
|
|
|
|
createControllerRef.current = null
|
|
|
|
|
|
setSessions([])
|
2026-07-22 17:09:54 +08:00
|
|
|
|
setExperts([])
|
2026-07-22 16:26:05 +08:00
|
|
|
|
setSelectedSessionID(null)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
setSelectedExpertID(null)
|
|
|
|
|
|
setExpertQuery('')
|
|
|
|
|
|
setExpertCategory('all')
|
2026-07-22 16:26:05 +08:00
|
|
|
|
setPrompt('')
|
2026-07-21 19:19:36 +08:00
|
|
|
|
setLoading(true)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
setLoadingExperts(true)
|
2026-07-21 19:54:25 +08:00
|
|
|
|
setCreating(false)
|
2026-07-21 19:19:36 +08:00
|
|
|
|
setError('')
|
2026-07-21 19:54:25 +08:00
|
|
|
|
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
|
|
|
|
|
|
|
|
|
|
|
void onListSessions(projectId, controller.signal)
|
2026-07-21 19:19:36 +08:00
|
|
|
|
.then((items) => {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
if (isCurrent()) setSessions(items)
|
2026-07-21 19:19:36 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch((requestError: unknown) => {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
2026-07-21 19:19:36 +08:00
|
|
|
|
})
|
2026-07-22 17:09:54 +08:00
|
|
|
|
.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 : '专家库加载失败,请稍后重试')
|
|
|
|
|
|
})
|
2026-07-21 19:19:36 +08:00
|
|
|
|
.finally(() => {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
if (isCurrent()) {
|
2026-07-22 17:09:54 +08:00
|
|
|
|
setLoadingExperts(false)
|
2026-07-21 19:54:25 +08:00
|
|
|
|
listControllerRef.current = null
|
|
|
|
|
|
}
|
2026-07-21 19:19:36 +08:00
|
|
|
|
})
|
2026-07-21 19:54:25 +08:00
|
|
|
|
|
2026-07-21 19:19:36 +08:00
|
|
|
|
return () => {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
generationRef.current += 1
|
|
|
|
|
|
controller.abort()
|
|
|
|
|
|
if (listControllerRef.current === controller) listControllerRef.current = null
|
|
|
|
|
|
createControllerRef.current?.abort()
|
|
|
|
|
|
createControllerRef.current = null
|
2026-07-21 19:19:36 +08:00
|
|
|
|
}
|
2026-07-22 17:09:54 +08:00
|
|
|
|
}, [onListExperts, onListSessions, projectId])
|
2026-07-21 19:19:36 +08:00
|
|
|
|
|
2026-07-22 16:26:05 +08:00
|
|
|
|
const selectedSession = useMemo(
|
|
|
|
|
|
() => sessions.find((session) => session.id === selectedSessionID) ?? null,
|
|
|
|
|
|
[selectedSessionID, sessions],
|
|
|
|
|
|
)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
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])
|
2026-07-22 16:26:05 +08:00
|
|
|
|
|
2026-07-21 19:19:36 +08:00
|
|
|
|
const createSession = async () => {
|
2026-07-22 16:26:05 +08:00
|
|
|
|
const message = prompt.trim()
|
2026-07-22 17:09:54 +08:00
|
|
|
|
if (!selectedExpert) {
|
|
|
|
|
|
setError('请先选择一位专家')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-22 16:26:05 +08:00
|
|
|
|
if (!message) {
|
|
|
|
|
|
setError('请输入会话内容')
|
2026-07-21 19:19:36 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-21 19:54:25 +08:00
|
|
|
|
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)
|
2026-07-21 19:19:36 +08:00
|
|
|
|
setCreating(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const created = await onCreateSession(projectId, {
|
2026-07-22 16:26:05 +08:00
|
|
|
|
title: sessionTitle(message),
|
|
|
|
|
|
context: message,
|
2026-07-22 17:09:54 +08:00
|
|
|
|
expertId: selectedExpert.id,
|
2026-07-21 19:54:25 +08:00
|
|
|
|
}, controller.signal)
|
|
|
|
|
|
if (!isCurrent()) return
|
2026-07-21 19:19:36 +08:00
|
|
|
|
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
|
2026-07-22 16:26:05 +08:00
|
|
|
|
setSelectedSessionID(created.id)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
setSelectedExpertID(created.expert?.id ?? selectedExpert.id)
|
2026-07-22 16:26:05 +08:00
|
|
|
|
setPrompt('')
|
2026-07-21 19:19:36 +08:00
|
|
|
|
onSelectItem(created.title)
|
|
|
|
|
|
} catch (requestError) {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
if (!isCurrent()) return
|
2026-07-21 19:19:36 +08:00
|
|
|
|
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
|
|
|
|
|
|
} finally {
|
2026-07-21 19:54:25 +08:00
|
|
|
|
if (isCurrent()) {
|
|
|
|
|
|
setCreating(false)
|
|
|
|
|
|
createControllerRef.current = null
|
|
|
|
|
|
}
|
2026-07-21 19:19:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-20 13:02:26 +08:00
|
|
|
|
|
2026-07-22 16:26:05 +08:00
|
|
|
|
function selectSession(session: AISessionDTO) {
|
|
|
|
|
|
setSelectedSessionID(session.id)
|
2026-07-22 17:09:54 +08:00
|
|
|
|
setSelectedExpertID(session.expert?.id ?? null)
|
2026-07-22 16:26:05 +08:00
|
|
|
|
setError('')
|
|
|
|
|
|
onSelectItem(session.title)
|
|
|
|
|
|
}
|
2026-07-21 11:29:36 +08:00
|
|
|
|
|
2026-07-22 17:09:54 +08:00
|
|
|
|
function startNewSession() {
|
|
|
|
|
|
setSelectedSessionID(null)
|
|
|
|
|
|
setSelectedExpertID(null)
|
|
|
|
|
|
setExpertQuery('')
|
|
|
|
|
|
setExpertCategory('all')
|
|
|
|
|
|
setPrompt('')
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:26:05 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="project-channel-page project-ai-page">
|
2026-07-21 19:19:36 +08:00
|
|
|
|
{error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="agent-chat-shell">
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<Card className="agent-session-list queue-section" bordered>
|
2026-07-22 17:09:54 +08:00
|
|
|
|
<Button className="agent-new-chat" icon={<IconPlus />} onClick={startNewSession}>新建会话</Button>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
|
2026-07-21 19:19:36 +08:00
|
|
|
|
<Spin loading={loading} style={{ width: '100%' }}>
|
|
|
|
|
|
{sessions.length ? (
|
|
|
|
|
|
<div className="agent-session-groups">
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<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)}
|
|
|
|
|
|
>
|
2026-07-22 17:09:54 +08:00
|
|
|
|
<span className="agent-session-emoji">{session.expert?.emoji || <IconRobot />}</span>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<span>{session.title}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</section>
|
2026-07-21 19:19:36 +08:00
|
|
|
|
</div>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
) : loading ? null : <Empty className="agent-session-empty" description="暂无会话" />}
|
2026-07-21 19:19:36 +08:00
|
|
|
|
</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>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<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">
|
2026-07-22 17:09:54 +08:00
|
|
|
|
<span className="agent-assistant-avatar" style={{ background: selectedExpert?.color || undefined }}>
|
|
|
|
|
|
{selectedExpert?.emoji || <IconRobot />}
|
|
|
|
|
|
</span>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<div>
|
2026-07-22 17:09:54 +08:00
|
|
|
|
<Text className="agent-assistant-name">{selectedExpert?.name || '森林AI'}</Text>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<p>会话已创建。你可以继续补充问题或项目背景。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-07-22 17:09:54 +08:00
|
|
|
|
<ExpertPicker
|
|
|
|
|
|
experts={visibleExperts}
|
|
|
|
|
|
categories={expertCategories}
|
|
|
|
|
|
selectedExpertID={selectedExpertID}
|
|
|
|
|
|
query={expertQuery}
|
|
|
|
|
|
category={expertCategory}
|
|
|
|
|
|
loading={loadingExperts}
|
|
|
|
|
|
onQueryChange={setExpertQuery}
|
|
|
|
|
|
onCategoryChange={setExpertCategory}
|
|
|
|
|
|
onSelect={setSelectedExpertID}
|
|
|
|
|
|
/>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="agent-composer">
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
value={prompt}
|
2026-07-22 17:09:54 +08:00
|
|
|
|
placeholder={selectedExpert
|
|
|
|
|
|
? `向${selectedExpert.name}发送消息,结合“${activeWorkspace.project.name}”项目上下文`
|
|
|
|
|
|
: '请先从上方选择一位专家'}
|
2026-07-22 16:26:05 +08:00
|
|
|
|
autoSize={{ minRows: 2, maxRows: 7 }}
|
|
|
|
|
|
onChange={setPrompt}
|
|
|
|
|
|
onPressEnter={(event) => {
|
|
|
|
|
|
if (!event.shiftKey) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
void createSession()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="agent-composer-footer">
|
2026-07-22 17:09:54 +08:00
|
|
|
|
<Button className="agent-model-chip" icon={<span>{selectedExpert?.emoji || <IconRobot />}</span>}>
|
|
|
|
|
|
{selectedExpert?.name || '选择专家'}
|
|
|
|
|
|
</Button>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<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}
|
2026-07-22 17:09:54 +08:00
|
|
|
|
disabled={!selectedExpert || !prompt.trim()}
|
2026-07-22 16:26:05 +08:00
|
|
|
|
onClick={() => void createSession()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-21 11:29:36 +08:00
|
|
|
|
</div>
|
2026-07-22 16:26:05 +08:00
|
|
|
|
<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
|
|
|
|
|
2026-07-22 17:09:54 +08:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:26:05 +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
|
|
|
|
}
|