import { useEffect, useMemo, useRef, useState } from 'react' import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react' import { IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile, IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot, IconSun, IconUpload, IconUser, } from '@arco-design/web-react/icon' import { ApiError, createAISession, createMarkdownDocument, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser, listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateTask, uploadDocument, type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type Expert, type Project, type Workspace, type WorkspaceTask, } from './api' const { Text, Title } = Typography const SESSION_KEY = 'senlin-app-session' type View = 'tasks' | 'documents' | 'ai' | 'cron' type WorkspaceMode = 'workbench' | 'explore' type SavedSession = ApiSession & { user?: CurrentUser } type TaskWithProject = WorkspaceTask & { projectName: string } const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [ { id: 'tasks', label: '计划', icon: }, { id: 'documents', label: '资料', icon: }, { id: 'ai', label: 'AI', icon: }, { id: 'cron', label: '计划任务', icon: }, ] export function App() { const stored = useMemo(readStoredSession, []) const [session, setSession] = useState(stored) const [user, setUser] = useState(stored?.user ?? null) const [projects, setProjects] = useState([]) const [workspaces, setWorkspaces] = useState>({}) const [projectId, setProjectId] = useState('') const [workspaceMode, setWorkspaceMode] = useState('workbench') const [view, setView] = useState('tasks') const [dark, setDark] = useState(false) const [loading, setLoading] = useState(Boolean(stored)) const [error, setError] = useState('') const [taskEditor, setTaskEditor] = useState(null) const [projectModalOpen, setProjectModalOpen] = useState(false) const [profileModalOpen, setProfileModalOpen] = useState(false) const [experts, setExperts] = useState([]) useEffect(() => { if (!session) return if (!user) void getCurrentUser(session).then(setUser).catch((requestError) => setError(errorMessage(requestError))) void loadWorkspaces(session) }, [session]) async function loadWorkspaces(activeSession = session) { if (!activeSession) return setLoading(true) setError('') try { const nextProjects = await fetchProjects(activeSession) const items = await Promise.all(nextProjects.map(async (project) => [project.id, await fetchProjectWorkspace(activeSession, project.id)] as const)) setProjects(nextProjects) setWorkspaces(Object.fromEntries(items)) setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '') } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } } async function loadAI() { if (!session || experts.length) return try { setExperts(await listAIExperts(session)) } catch (requestError) { setError(errorMessage(requestError)) } } const visibleWorkspaces = projectId ? [workspaces[projectId]].filter(Boolean) : Object.values(workspaces) const activeProject = projectId ? projects.find((project) => project.id === projectId) : undefined const tasks = visibleWorkspaces.flatMap((workspace) => workspace.tasks.map((task) => ({ ...task, projectName: workspace.project.name }))) const title = activeProject?.name ?? '全部项目' function cycleProject(direction: number) { const index = projects.findIndex((project) => project.id === projectId) const next = projectId ? (index + direction + projects.length) % projects.length : direction > 0 ? 0 : projects.length - 1 setProjectId(projects[next]?.id ?? '') setWorkspaceMode('workbench') setView('tasks') } function logout() { localStorage.removeItem(SESSION_KEY) localStorage.removeItem('senlin-mini-session') setSession(null); setUser(null); setProjects([]); setWorkspaces({}); setProjectId(''); setView('tasks') } if (!session) return { setSession(next); setUser(next.user); persistSession(next) }} /> return (
森林AI
{error ? setError('')} /> : null}
{workspaceMode === 'explore' ? : !projectId ? setProjectModalOpen(true)} /> : visibleWorkspaces.length ? { await uploadDocument(session, projectId, file); await loadWorkspaces() }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await loadWorkspaces() }} onCreateAISession={async (input) => { await createAISession(session, projectId, input); await loadWorkspaces() }} /> : }
{projectId && workspaceMode === 'workbench' ? : null} workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => { if (!taskEditor) return await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed }) await loadWorkspaces() setTaskEditor(null) }} /> setProjectModalOpen(false)} onSave={async (name) => { const project = await createProject(session, { name, identifier: slugify(name) || `project-${Date.now().toString(36)}`, icon: 'folder', background: '#165DFF', description: '' }) setProjectModalOpen(false) await loadWorkspaces() setProjectId(project.id) }} /> setProfileModalOpen(false)} onSave={async (input) => { const nextUser = await updateCurrentUser(session, input) setUser(nextUser) persistSession({ ...session, user: nextUser }) setProfileModalOpen(false) }} />
) } function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts, onUploadDocument, onCreateMarkdown, onCreateAISession }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[]; onUploadDocument: (file: File) => Promise; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise; onCreateAISession: (input: { title: string; context: string; expertId?: string }) => Promise }) { let panel: React.ReactNode if (view === 'tasks') panel = else if (view === 'documents') panel = else if (view === 'ai') panel = else panel = return
{title}
{panel}
} function TaskBoard({ title, subtitle, tasks, onEditTask, showCompleted = true }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; showCompleted?: boolean }) { const [tag, setTag] = useState('全部') const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))] const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag) const pending = visible.filter((task) => !task.completed) const completed = visible.filter((task) => task.completed) return
{subtitle ? {subtitle} : null}{title}
{pending.length} 项待办
{tags.map((item) => )}
待办任务{pending.length}
{pending.map((task) => onEditTask(task)} />)}{!pending.length && }
{showCompleted ?
已完成{completed.length}
{completed.map((task) => onEditTask(task)} />)}{!completed.length && }
: null}
} function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) { return } function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void }) { const pending = tasks.filter((task) => !task.completed) const latestFiles = workspaces.flatMap((workspace) => workspace.documents.map((document) => ({ ...document, projectName: workspace.project.name }))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6) const statItems = [ { label: '项目', value: projects.length, color: 'blue' }, { label: '待办', value: pending.length, color: 'orange' }, { label: '文件', value: latestFiles.length, color: 'green' }, { label: '计划任务', value: workspaces.reduce((total, workspace) => total + workspace.cronPlans.length, 0), color: 'purple' }, ] return
全部项目
{statItems.map((item) =>
{item.value}{item.label}
)}
最新文件{latestFiles.length}
{latestFiles.map((file) =>
{file.name}

{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}

{file.projectName}
)}{!latestFiles.length && }
} function ExplorePanel({ session, projectName }: { session: ApiSession; projectName: string }) { const [sources, setSources] = useState([]) const [items, setItems] = useState([]) const [sourceId, setSourceId] = useState('all') const [selectedId, setSelectedId] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState('') useEffect(() => { void refreshSources() }, [session]) useEffect(() => { void refreshItems() }, [session, sourceId]) async function refreshSources() { setLoading(true); setError('') try { setSources(await listDatasetSources(session)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } } async function refreshItems() { setLoading(true); setError('') try { const page = await listDatasetItems(session, sourceId === 'all' ? undefined : sourceId); setItems(page.items); setSelectedId((current) => page.items.some((item) => item.id === current) ? current : page.items[0]?.id ?? '') } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } } const allCount = sources.reduce((total, source) => total + source.itemCount, 0) const selected = items.find((item) => item.id === selectedId) ?? items[0] const selectedSource = sources.find((source) => source.id === selected?.sourceId) return
探索集中阅读数据源内容,筛选后沉淀到项目资料。
{error ? : null}
{sources.map((source) => )}
{items.length ? <>
{items.map((item) => )}
{selectedSource?.name || '全部'}{formatDate(selected?.publishedAt || selected?.createdAt || '')}
{selected?.title}{selected?.imageUrl ? : null}

{selected?.content || selected?.summary || '暂无正文'}

{selected?.url ? : null}
: }
} function DocumentPanel({ workspaces, onUpload, onCreateMarkdown }: { workspaces: Workspace[]; onUpload: (file: File) => Promise; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise }) { const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name }))) const inputRef = useRef(null) const [creating, setCreating] = useState(false) const [name, setName] = useState('未命名笔记.md') const [markdown, setMarkdown] = useState('') const [saving, setSaving] = useState(false) const [uploading, setUploading] = useState(false) const [error, setError] = useState('') async function saveMarkdown() { if (!name.trim()) return setSaving(true); setError('') try { await onCreateMarkdown({ name: name.trim(), markdown }); setCreating(false); setName('未命名笔记.md'); setMarkdown('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } } async function uploadFiles(files: FileList | null) { if (!files?.length) return setUploading(true); setError('') try { for (const file of Array.from(files)) await onUpload(file) } catch (requestError) { setError(errorMessage(requestError)) } finally { setUploading(false); if (inputRef.current) inputRef.current.value = '' } } if (creating) return
项目资料新建 Markdown
{error ? : null}
return
笔记资料
{ void uploadFiles(event.target.files) }} />
{error ? : null}
{documents.map((item) =>
{item.name}

{item.extension || item.mimeType || item.kind} · 更新于 {formatDate(item.updatedAt)}

{item.projectName}
)}{!documents.length && }
} function AIPanel({ workspaces, experts, onLoad, onCreateSession }: { workspaces: Workspace[]; experts: Expert[]; onLoad: () => void; onCreateSession: (input: { title: string; context: string; expertId?: string }) => Promise }) { useEffect(() => { onLoad() }, [onLoad]) const [page, setPage] = useState<'new' | 'history'>('new') const [title, setTitle] = useState('') const [context, setContext] = useState('') const [expertId, setExpertId] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') const sessions = workspaces.flatMap((workspace) => workspace.aiSessions.map((item) => ({ ...item, projectName: workspace.project.name }))) useEffect(() => { setExpertId((current) => experts.some((expert) => expert.id === current) ? current : experts[0]?.id ?? '') }, [experts]) async function createSession() { setSaving(true); setError('') try { await onCreateSession({ title: title.trim() || '新会话', context: context.trim(), expertId: expertId || undefined }); setTitle(''); setContext(''); setPage('history') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } } return
{page === 'new' ? '新建会话' : '历史会话'}
{error ? : null}{page === 'new' ?
{experts.map((expert) => )}{!experts.length && 暂无可用专家}
:
{sessions.map((item) =>
{item.title}

{item.summary || 'AI 会话'} · {formatDate(item.updatedAt)}

{item.projectName}
)}{!sessions.length && }
}
} function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name }))) return {plans.map((item) =>
{item.title}

{item.schedule} · {item.enabled ? '已启用' : '已停用'}

{item.projectName}
)}{!plans.length && }
} function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return
{subtitle ? {subtitle} : null}{title}
{children}
} function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) { const menu = 修改资料退出登录 return } function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise }) { const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false) useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed) } }, [task]) return { if (!title.trim()) return; setSaving(true); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } finally { setSaving(false) } }}>
} function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise }) { const [name, setName] = useState(''); const [saving, setSaving] = useState(false) return { if (!name.trim()) return; setSaving(true); try { await onSave(name.trim()); setName('') } finally { setSaving(false) } }}> } function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise }) { const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false) useEffect(() => setDisplayName(user?.displayName ?? ''), [user]) return { setSaving(true); try { await onSave({ displayName, currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } finally { setSaving(false) } }}>
} function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) { const [server, setServer] = useState('http://localhost:9150'); const [email, setEmail] = useState('demo@senlin.ai'); const [password, setPassword] = useState('password123'); const [loading, setLoading] = useState(false); const [error, setError] = useState('') return
{ event.preventDefault(); void submit() }}>森林AI App连接你的私有工作台{error && }
async function submit() { setLoading(true); setError(''); try { onLogin(await login(server, email.trim(), password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } } } function readStoredSession(): SavedSession | null { try { const value = JSON.parse(localStorage.getItem(SESSION_KEY) || localStorage.getItem('senlin-mini-session') || 'null') as SavedSession | null; if (!value?.baseUrl || !value.token) return null; setApiBaseUrl(value.baseUrl); return value } catch { return null } } function persistSession(session: SavedSession) { localStorage.setItem(SESSION_KEY, JSON.stringify(session)) } function projectLabel(project: Project) { if (project.icon && !['folder', 'project'].includes(project.icon)) return Array.from(project.icon).slice(0, 2).join(''); return Array.from(project.name.trim()).slice(0, 2).join('').toUpperCase() || } function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `${date.getMonth() + 1}/${date.getDate()}` } function slugify(value: string) { return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') } function errorMessage(error: unknown) { return error instanceof ApiError || error instanceof Error ? error.message : '操作失败,请稍后重试' }