Files
agent/apps/mini/src/App.tsx

428 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: <IconApps /> },
{ id: 'tasks', label: '计划', icon: <IconCheckCircle /> },
{ id: 'notes', label: '资料', icon: <IconFile /> },
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
{ id: 'more', label: '更多', icon: <IconSettings /> },
]
export function App() {
const [session, setSession] = useState<ApiSession | null>(() => readStoredSession())
const [projects, setProjects] = useState<Project[]>([])
const [projectId, setProjectId] = useState('')
const [workspace, setWorkspace] = useState<Workspace | null>(null)
const [experts, setExperts] = useState<Expert[]>([])
const [aiSessions, setAISessions] = useState<AISession[]>([])
const [view, setView] = useState<View>('home')
const [dockSide, setDockSide] = useState<DockSide>(() => {
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<Action>(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 <Login onLogin={(next) => { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} />
const activeProject = projects.find((project) => project.id === projectId) ?? projects[0]
const content = !workspace || !activeProject
? <Empty description="还没有项目,点击右上角新建" />
: renderView(view, {
session, workspace, experts, aiSessions,
refresh: () => refreshProject(),
openAction: setAction,
})
return (
<div className="mini-shell">
<header className="mini-header" data-tauri-drag-region>
<div className="mini-brand" data-tauri-drag-region>
<img src="/senlinai-icon.svg" alt="" />
<span>AI</span>
<Tag size="small" color="arcoblue">Mini 1.0</Tag>
</div>
<div className="window-actions">
<Button aria-label="停靠左侧" className={dockSide === 'left' ? 'active' : ''} type="text" size="mini" icon={<IconArrowLeft />} onClick={() => void dock('left')} />
<Button aria-label="停靠右侧" className={dockSide === 'right' ? 'active' : ''} type="text" size="mini" icon={<IconArrowRight />} onClick={() => void dock('right')} />
</div>
</header>
<div className="project-switcher">
<Select
value={projectId || undefined}
placeholder="选择项目"
onChange={setProjectId}
triggerElement={() => (
<button className="project-trigger">
<Avatar size={26} style={{ background: activeProject?.background || '#165DFF' }}>{projectMark(activeProject)}</Avatar>
<span>{activeProject?.name || '选择项目'}</span>
<small>{activeProject?.identifier || ''}</small>
</button>
)}
>
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
</Select>
<Button aria-label="刷新" type="text" shape="circle" icon={<IconRefresh />} loading={loading} onClick={() => void refreshProject()} />
<Button aria-label="新建项目" type="text" shape="circle" icon={<IconPlus />} onClick={() => setAction('project')} />
</div>
<nav className="mini-nav" aria-label="主要导航">
{navItems.map((item) => (
<button className={view === item.id ? 'active' : ''} key={item.id} onClick={() => setView(item.id)}>
{item.icon}<span>{item.label}</span>
</button>
))}
</nav>
{error ? <Alert className="mini-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
<main className="mini-content"><Spin loading={loading} block>{content}</Spin></main>
<footer className="mini-footer">
<span><i /> </span>
<button onClick={() => { localStorage.removeItem('senlin-mini-session'); setSession(null); setWorkspace(null) }}>退</button>
</footer>
<ActionModal
action={action}
workspace={workspace}
onClose={() => 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()
}}
/>
</div>
)
}
function renderView(view: View, props: ViewProps) {
if (view === 'tasks') return <TasksView {...props} />
if (view === 'notes') return <NotesView {...props} />
if (view === 'ai') return <AIView {...props} />
if (view === 'more') return <MoreView {...props} />
return <HomeView {...props} />
}
type ViewProps = {
session: ApiSession
workspace: Workspace
experts: Expert[]
aiSessions: AISession[]
refresh: () => void | Promise<void>
openAction: (action: Action) => void
}
function HomeView({ workspace, openAction }: ViewProps) {
const pending = workspace.tasks.filter((task) => !task.completed)
return (
<section className="mini-page home-view">
<div className="page-intro">
<Text type="secondary"></Text>
<Title heading={4}>{workspace.project.name}</Title>
<p>{workspace.project.description || '把计划、资料与 AI 会话放进同一个项目上下文。'}</p>
</div>
<div className="metric-grid">
<Metric value={pending.length} label="待办计划" color="blue" />
<Metric value={workspace.notesSources.length} label="笔记资料" color="green" />
<Metric value={workspace.aiSessions.length} label="AI 会话" color="purple" />
<Metric value={workspace.cronPlans.length} label="计划任务" color="orange" />
</div>
<div className="section-title"><strong></strong></div>
<div className="quick-actions">
<button onClick={() => openAction('task')}><IconPlus /><span><b></b><small></small></span></button>
<button onClick={() => openAction('cron')}><IconClockCircle /><span><b></b><small></small></span></button>
</div>
<div className="section-title"><strong></strong><Tag color="arcoblue">{pending.length}</Tag></div>
<div className="compact-list">
{pending.slice(0, 4).map((task) => <div key={task.id}><IconCheckCircle /><span><b>{task.title}</b><small>{task.tag || '未分类'}</small></span></div>)}
{!pending.length && <Empty description="当前没有未完成计划" />}
</div>
</section>
)
}
function Metric({ value, label, color }: { value: number; label: string; color: string }) {
return <div className={`metric ${color}`}><strong>{value}</strong><span>{label}</span></div>
}
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 (
<section className="mini-page">
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button type="primary" size="small" icon={<IconPlus />} onClick={() => openAction('task')}></Button></div>
<div className="filter-strip">
{['全部', ...tags.map((item) => item.name)].map((item) => <button className={tag === item ? 'active' : ''} key={item} onClick={() => setTag(item)}>{item}</button>)}
</div>
<div className="sticky-list">
{tasks.map((task) => (
<article className={task.completed ? 'task-note completed' : 'task-note'} key={task.id}>
<button className="task-check" aria-label={task.completed ? '恢复计划' : '完成计划'} onClick={async () => { await updateTask(session, workspace.project.id, task); await refresh() }}><IconCheckCircle /></button>
<div><strong>{task.title}</strong><p>{task.summary || '暂无说明'}</p><span>{task.tag || '未分类'} · {task.createdAt}</span></div>
</article>
))}
{!tasks.length && <Empty description="当前筛选下没有计划" />}
</div>
</section>
)
}
function NotesView({ session, workspace, refresh }: ViewProps) {
const fileInput = useRef<HTMLInputElement>(null)
return (
<section className="mini-page">
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button type="primary" size="small" icon={<IconPlus />} onClick={() => fileInput.current?.click()}></Button></div>
<input className="hidden-file" ref={fileInput} type="file" onChange={async (event) => { const file = event.target.files?.[0]; if (!file) return; await uploadSource(session, workspace.project.id, file); await refresh(); event.target.value = '' }} />
<div className="note-grid">
{workspace.notesSources.map((note, index) => <article className={`source-note tone-${index % 4}`} key={note.id}><IconFile /><strong>{note.title}</strong><p>{note.source || note.kind}</p><span>{note.tag || '资料'} · {note.updatedAt}</span></article>)}
{!workspace.notesSources.length && <Empty description="还没有笔记或资料" />}
</div>
</section>
)
}
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 (
<section className="mini-page ai-view">
<div className="page-title"><div><Title heading={5}>AI </Title><Text type="secondary"></Text></div><Tag color="purple">{experts.length} </Tag></div>
<Input prefix={<IconSearch />} value={query} onChange={setQuery} allowClear placeholder="搜索专家" />
<div className="filter-strip">
{['全部', ...categories].map((item) => <button className={category === item ? 'active' : ''} key={item} onClick={() => setCategory(item)}>{item}</button>)}
</div>
<div className="expert-strip">
{visible.slice(0, 30).map((expert) => <button className={expertId === expert.id ? 'active' : ''} key={expert.id} onClick={() => setExpertId(expert.id)}><i style={{ background: expert.color }}>{expert.emoji}</i><span><b>{expert.name}</b><small>{expert.categoryName}</small></span></button>)}
</div>
<div className="mini-composer">
<div className="selected-expert">{selected ? <><i style={{ background: selected.color }}>{selected.emoji}</i><span><b>{selected.name}</b><small>{selected.description}</small></span></> : <Text type="secondary"></Text>}</div>
<Input.TextArea value={prompt} onChange={setPrompt} autoSize={{ minRows: 3, maxRows: 6 }} placeholder={selected ? `${selected.name}发送消息` : '请先选择专家'} />
<Button type="primary" long loading={sending} disabled={!selected || !prompt.trim()} onClick={async () => { if (!selected) return; setSending(true); try { await createAISession(session, workspace.project.id, selected.id, prompt.trim()); setPrompt(''); await refresh() } finally { setSending(false) } }}></Button>
</div>
<div className="section-title"><strong></strong></div>
<div className="session-list">{aiSessions.slice(0, 5).map((item) => <div key={item.id}><i style={{ background: item.expert?.color }}>{item.expert?.emoji || '🤖'}</i><span><b>{item.title}</b><small>{item.expert?.name || '森林AI'}</small></span></div>)}</div>
</section>
)
}
function MoreView({ workspace, openAction }: ViewProps) {
return (
<section className="mini-page">
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button size="small" icon={<IconPlus />} onClick={() => openAction('cron')}></Button></div>
<div className="settings-card"><strong></strong>{workspace.cronPlans.map((plan) => <div key={plan.id}><IconClockCircle /><span><b>{plan.title}</b><small>{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}</small></span></div>)}{!workspace.cronPlans.length && <Text type="secondary"></Text>}</div>
<div className="settings-card"><strong></strong><p>Web </p></div>
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>AI Mini</b><small>v1.0 · </small></span></div>
</section>
)
}
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<ConnectionStatus>('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 (
<div className="mini-login">
<form className="login-note" onSubmit={(event) => { event.preventDefault(); void submitLogin() }}>
<img src="/senlinai-icon.svg" alt="" />
<Title heading={3}>AI Mini</Title>
<Text type="secondary"></Text>
<label><Input value={server} onChange={setServer} /></label>
<div className={`mini-connection ${connection}`} role="status">
<i />
<strong>{connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'}</strong>
<button type="button" onClick={() => void checkConnection()}>{connection === 'checking' ? '检查中' : '重新检查'}</button>
</div>
<label><Input value={email} onChange={setEmail} /></label>
<label><Input.Password value={password} onChange={setPassword} /></label>
{error ? <Alert type="error" content={error} /> : null}
<Button type="primary" htmlType="submit" long loading={loading}></Button>
<small>v1.0 </small>
</form>
</div>
)
}
function ActionModal({ action, workspace, onClose, onProject, onTask, onCron }: {
action: Action
workspace: Workspace | null
onClose: () => void
onProject: (name: string) => Promise<void>
onTask: (title: string, tag: string) => Promise<void>
onCron: (title: string, schedule: string) => Promise<void>
}) {
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 (
<Modal className="mini-modal" visible={Boolean(action)} title={labels[0]} onCancel={onClose} footer={null} unmountOnExit>
<div className="mini-form">
<label>{labels[1]}<Input autoFocus value={title} onChange={setTitle} /></label>
{action === 'task' ? <label><Select value={tag || undefined} placeholder="可选" onChange={setTag}>{workspace?.tags.filter((item) => item.name !== 'all' && item.name !== '全部').map((item) => <Select.Option key={item.id} value={item.name}>{item.name}</Select.Option>)}</Select></label> : null}
{action === 'cron' ? <label>Cron <Input value={schedule} onChange={setSchedule} /></label> : null}
<Button type="primary" long loading={loading} disabled={!title.trim()} onClick={async () => { setLoading(true); try { if (action === 'project') await onProject(title.trim()); if (action === 'task') await onTask(title.trim(), tag); if (action === 'cron') await onCron(title.trim(), schedule.trim()); onClose() } catch (requestError) { Message.error(errorMessage(requestError)) } finally { setLoading(false) } }}></Button>
</div>
</Modal>
)
}
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 <IconFolder />
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))
}
}