import { useEffect, useRef, useState } from 'react' import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react' import { IconApps, IconArrowLeft, IconArrowRight, IconCheckCircle, IconClockCircle, IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings, } from '@arco-design/web-react/icon' import { ApiError, checkServerConnection, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts, fetchProjects, fetchWorkspace, login, updateTask, uploadSource, type AISession, type ApiSession, type Expert, type Project, type Workspace, } from './api' const { Text, Title } = Typography type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more' type DockSide = 'left' | 'right' type Action = 'project' | 'task' | 'cron' | null type ConnectionStatus = 'checking' | 'online' | 'offline' const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [ { id: 'home', label: '项目', icon: }, { id: 'tasks', label: '计划', icon: }, { id: 'notes', label: '资料', icon: }, { id: 'ai', label: 'AI', icon: }, { id: 'more', label: '更多', icon: }, ] export function App() { const [session, setSession] = useState(() => readStoredSession()) const [projects, setProjects] = useState([]) const [projectId, setProjectId] = useState('') const [workspace, setWorkspace] = useState(null) const [experts, setExperts] = useState([]) const [aiSessions, setAISessions] = useState([]) const [view, setView] = useState('home') const [dockSide, setDockSide] = useState(() => { if ('__TAURI_INTERNALS__' in window) return 'right' return localStorage.getItem('senlin-mini-dock') === 'left' ? 'left' : 'right' }) const [loading, setLoading] = useState(Boolean(session)) const [error, setError] = useState('') const [action, setAction] = useState(null) const refreshGeneration = useRef(0) useEffect(() => { document.documentElement.dataset.dock = dockSide localStorage.setItem('senlin-mini-dock', dockSide) void moveDesktopWindow(dockSide) }, [dockSide]) useEffect(() => { if (!session) return let cancelled = false setLoading(true) void fetchProjects(session) .then((items) => { if (cancelled) return setProjects(items) setProjectId((current) => items.some((item) => item.id === current) ? current : items[0]?.id ?? '') }) .catch((requestError) => !cancelled && setError(errorMessage(requestError))) .finally(() => !cancelled && setLoading(false)) return () => { cancelled = true } }, [session]) useEffect(() => { if (!session || !projectId) return void refreshProject(session, projectId) }, [projectId, session]) async function refreshProject(activeSession = session, activeProject = projectId) { if (!activeSession || !activeProject) return const generation = ++refreshGeneration.current setLoading(true) setError('') try { const [nextWorkspace, nextExperts, nextSessions] = await Promise.all([ fetchWorkspace(activeSession, activeProject), experts.length ? Promise.resolve(experts) : fetchExperts(activeSession), fetchAISessions(activeSession, activeProject), ]) if (generation !== refreshGeneration.current) return setWorkspace(nextWorkspace) setExperts(nextExperts) setAISessions(nextSessions) } catch (requestError) { if (generation === refreshGeneration.current) setError(errorMessage(requestError)) } finally { if (generation === refreshGeneration.current) setLoading(false) } } async function dock(side: DockSide) { setDockSide(side) } if (!session) return { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} /> const activeProject = projects.find((project) => project.id === projectId) ?? projects[0] const content = !workspace || !activeProject ? : renderView(view, { session, workspace, experts, aiSessions, refresh: () => refreshProject(), openAction: setAction, }) return (
森林AI Mini 1.0
{error ? setError('')} /> : null}
{content}
服务已连接
setAction(null)} onProject={async (name) => { const project = await createProject(session, name) const next = await fetchProjects(session) setProjects(next) setProjectId(project.id) }} onTask={async (title, tag) => { if (!projectId) return await createTask(session, projectId, title, tag) await refreshProject() }} onCron={async (title, schedule) => { if (!projectId) return await createCron(session, projectId, title, schedule) await refreshProject() }} />
) } function renderView(view: View, props: ViewProps) { if (view === 'tasks') return if (view === 'notes') return if (view === 'ai') return if (view === 'more') return return } type ViewProps = { session: ApiSession workspace: Workspace experts: Expert[] aiSessions: AISession[] refresh: () => void | Promise openAction: (action: Action) => void } function HomeView({ workspace, openAction }: ViewProps) { const pending = workspace.tasks.filter((task) => !task.completed) return (
当前项目 {workspace.project.name}

{workspace.project.description || '把计划、资料与 AI 会话放进同一个项目上下文。'}

快速操作
接下来{pending.length}
{pending.slice(0, 4).map((task) =>
{task.title}{task.tag || '未分类'}
)} {!pending.length && }
) } function Metric({ value, label, color }: { value: number; label: string; color: string }) { return
{value}{label}
} function TasksView({ session, workspace, refresh, openAction }: ViewProps) { const [tag, setTag] = useState('全部') const tags = workspace.tags.filter((item) => item.name !== '全部' && item.name !== 'all') const tasks = tag === '全部' ? workspace.tasks : workspace.tasks.filter((task) => task.tag === tag) return (
工作计划轻点圆圈即可更新状态
{['全部', ...tags.map((item) => item.name)].map((item) => )}
{tasks.map((task) => (
{task.title}

{task.summary || '暂无说明'}

{task.tag || '未分类'} · {task.createdAt}
))} {!tasks.length && }
) } function NotesView({ session, workspace, refresh }: ViewProps) { const fileInput = useRef(null) return (
笔记资料项目资料随手可取
{ const file = event.target.files?.[0]; if (!file) return; await uploadSource(session, workspace.project.id, file); await refresh(); event.target.value = '' }} />
{workspace.notesSources.map((note, index) =>
{note.title}

{note.source || note.kind}

{note.tag || '资料'} · {note.updatedAt}
)} {!workspace.notesSources.length && }
) } function AIView({ session, workspace, experts, aiSessions, refresh }: ViewProps) { const [query, setQuery] = useState('') const [category, setCategory] = useState('全部') const [expertId, setExpertId] = useState('') const [prompt, setPrompt] = useState('') const [sending, setSending] = useState(false) const categories = [...new Set(experts.map((expert) => expert.categoryName))] const visible = experts.filter((expert) => (category === '全部' || expert.categoryName === category) && `${expert.name}${expert.description}`.toLowerCase().includes(query.toLowerCase())) const selected = experts.find((expert) => expert.id === expertId) return (
AI 会话先选择专家,再开始会话
{experts.length} 位
} value={query} onChange={setQuery} allowClear placeholder="搜索专家" />
{['全部', ...categories].map((item) => )}
{visible.slice(0, 30).map((expert) => )}
{selected ? <>{selected.emoji}{selected.name}{selected.description} : 选择一位专家进行会话}
最近会话
{aiSessions.slice(0, 5).map((item) =>
{item.expert?.emoji || '🤖'}{item.title}{item.expert?.name || '森林AI'}
)}
) } function MoreView({ workspace, openAction }: ViewProps) { return (
更多项目周期安排与客户端信息
计划任务{workspace.cronPlans.map((plan) =>
{plan.title}{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}
)}{!workspace.cronPlans.length && 暂无周期计划}
停靠说明

顶部箭头可将桌面窗口停靠至当前屏幕左侧或右侧。Web 模式会记住布局偏好,桌面模式会同时移动实际窗口。

森林AI Miniv1.0 完整版 · 窄屏停靠客户端
) } function Login({ onLogin }: { onLogin: (session: ApiSession) => 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('') const [connection, setConnection] = useState('checking') useEffect(() => { const controller = new AbortController() const timer = window.setTimeout(() => void checkConnection(controller.signal), 300) return () => { controller.abort() window.clearTimeout(timer) } }, [server]) async function checkConnection(signal?: AbortSignal) { setConnection('checking') try { const online = await checkServerConnection(server, signal) if (!signal?.aborted) setConnection(online ? 'online' : 'offline') } catch (requestError) { if (!(requestError instanceof DOMException && requestError.name === 'AbortError')) setConnection('offline') } } async function submitLogin() { if (loading) return setLoading(true) setError('') try { onLogin(await login(server, email.trim(), password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } } return (
{ event.preventDefault(); void submitLogin() }}> 森林AI Mini 停靠在屏幕一侧,随时记录与推进项目
{connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'}
{error ? : null} v1.0 完整版
) } function ActionModal({ action, workspace, onClose, onProject, onTask, onCron }: { action: Action workspace: Workspace | null onClose: () => void onProject: (name: string) => Promise onTask: (title: string, tag: string) => Promise onCron: (title: string, schedule: string) => Promise }) { const [title, setTitle] = useState('') const [tag, setTag] = useState('') const [schedule, setSchedule] = useState('0 9 * * 1-5') const [loading, setLoading] = useState(false) useEffect(() => { if (action) { setTitle(''); setTag(''); setSchedule('0 9 * * 1-5') } }, [action]) const labels = action === 'project' ? ['新建项目', '项目名称'] : action === 'task' ? ['新建计划', '计划标题'] : ['新建计划任务', '任务名称'] return (
{action === 'task' ? : null} {action === 'cron' ? : null}
) } function readStoredSession(): ApiSession | null { try { const value = JSON.parse(localStorage.getItem('senlin-mini-session') || 'null') as ApiSession | null return value?.baseUrl && value?.token ? value : null } catch { return null } } function projectMark(project?: Project) { if (!project) return if (project.icon && !['folder', 'project'].includes(project.icon)) return project.icon const chars = Array.from(project.name.trim()) const chinese = chars.some((char) => /[\u3400-\u9fff]/.test(char)) return chars.slice(0, chinese ? 2 : 4).join('').toUpperCase() } function errorMessage(error: unknown) { if (error instanceof ApiError || error instanceof Error) return error.message return '操作失败,请稍后重试' } async function moveDesktopWindow(side: DockSide) { if (!('__TAURI_INTERNALS__' in window)) return try { const { invoke } = await import('@tauri-apps/api/core') await invoke('dock_window', { side }) } catch (requestError) { Message.warning(errorMessage(requestError)) } }