317 lines
35 KiB
TypeScript
317 lines
35 KiB
TypeScript
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, fetchDocumentBlob, fetchProjectWorkspace, fetchProjects, getCurrentUser, getDocument,
|
||
listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateDocument, updateTask,
|
||
uploadDocument,
|
||
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type DocumentDetail, type Expert, type Project, type Workspace, type WorkspaceDocument, 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: <IconList /> },
|
||
{ id: 'documents', label: '资料', icon: <IconFile /> },
|
||
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
|
||
{ id: 'cron', label: '任务', icon: <IconClockCircle /> },
|
||
]
|
||
|
||
export function App() {
|
||
const stored = useMemo(readStoredSession, [])
|
||
const [session, setSession] = useState<ApiSession | null>(stored)
|
||
const [user, setUser] = useState<CurrentUser | null>(stored?.user ?? null)
|
||
const [projects, setProjects] = useState<Project[]>([])
|
||
const [workspaces, setWorkspaces] = useState<Record<string, Workspace>>({})
|
||
const [projectId, setProjectId] = useState('')
|
||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('workbench')
|
||
const [view, setView] = useState<View>('tasks')
|
||
const [dark, setDark] = useState(false)
|
||
const [loading, setLoading] = useState(Boolean(stored))
|
||
const [error, setError] = useState('')
|
||
const [taskEditor, setTaskEditor] = useState<TaskWithProject | null>(null)
|
||
const [projectModalOpen, setProjectModalOpen] = useState(false)
|
||
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
||
const [experts, setExperts] = useState<Expert[]>([])
|
||
const workspaceRequestRef = useRef(0)
|
||
|
||
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
|
||
const requestId = ++workspaceRequestRef.current
|
||
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))
|
||
if (requestId !== workspaceRequestRef.current) return
|
||
setProjects(nextProjects)
|
||
setWorkspaces(Object.fromEntries(items))
|
||
setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '')
|
||
} catch (requestError) {
|
||
if (requestId === workspaceRequestRef.current) setError(errorMessage(requestError))
|
||
} finally {
|
||
if (requestId === workspaceRequestRef.current) setLoading(false)
|
||
}
|
||
}
|
||
|
||
async function refreshProjectWorkspace(targetProjectId: string, activeSession = session) {
|
||
if (!activeSession || !targetProjectId) return
|
||
const workspace = await fetchProjectWorkspace(activeSession, targetProjectId)
|
||
setWorkspaces((current) => ({ ...current, [targetProjectId]: workspace }))
|
||
}
|
||
|
||
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() {
|
||
workspaceRequestRef.current += 1
|
||
localStorage.removeItem(SESSION_KEY)
|
||
localStorage.removeItem('senlin-mini-session')
|
||
setSession(null); setUser(null); setProjects([]); setWorkspaces({}); setProjectId(''); setView('tasks')
|
||
}
|
||
|
||
if (!session) return <Login onLogin={(next) => { workspaceRequestRef.current += 1; setProjects([]); setWorkspaces({}); setProjectId(''); setSession(next); setUser(next.user); persistSession(next) }} />
|
||
|
||
return (
|
||
<div className={`app-shell ${dark ? 'theme-dark' : ''} ${!projectId || workspaceMode === 'explore' ? 'no-rail' : ''}`}>
|
||
<header className="app-header">
|
||
<div className="app-brand" data-tauri-drag-region><img src="/senlinai-icon.svg" alt="" /><span>森林AI</span></div>
|
||
<div className="header-actions">
|
||
<Button type="text" shape="circle" aria-label={dark ? '切换浅色模式' : '切换深色模式'} icon={dark ? <IconSun /> : <IconMoon />} onClick={() => setDark((value) => !value)} />
|
||
<ProfileMenu user={user} onProfile={() => setProfileModalOpen(true)} onLogout={() => logout()} />
|
||
</div>
|
||
</header>
|
||
|
||
<section className="project-strip" aria-label="项目列表">
|
||
<button className={workspaceMode === 'workbench' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('workbench')}><IconApps /><span>工作台</span></button>
|
||
<button className={workspaceMode === 'explore' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('explore')}><IconCompass /><span>探索</span></button>
|
||
<Select className="project-select" value={projectId || 'all'} onChange={(value) => { setProjectId(value === 'all' ? '' : value); setWorkspaceMode('workbench'); setView('tasks') }} triggerElement={() => <button className={`project-pill project-current ${projectId ? 'active' : ''}`} aria-label={`切换项目,当前为${title}`}><i style={{ background: activeProject?.background || '#165DFF' }}>{activeProject ? projectLabel(activeProject) : <IconFolder />}</i><span>{title}</span><IconDown className="project-dropdown-icon" /></button>}>
|
||
<Select.Option value="all">全部项目</Select.Option>
|
||
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
|
||
</Select>
|
||
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
|
||
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
|
||
<Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
|
||
</section>
|
||
|
||
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel session={session} projectName={activeProject?.name ?? ''} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} onUploadDocument={async (file) => { await uploadDocument(session, projectId, file); await refreshProjectWorkspace(projectId) }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await refreshProjectWorkspace(projectId) }} onLoadMarkdown={(documentId) => getDocument(session, projectId, documentId)} onUpdateMarkdown={async (input) => { await updateDocument(session, projectId, input.id, input); await refreshProjectWorkspace(projectId) }} onOpenExternalDocument={async (documentId) => { const preview = window.open('', '_blank'); try { const blob = await fetchDocumentBlob(session, projectId, documentId); const url = URL.createObjectURL(blob); if (preview) { preview.opener = null; preview.location.href = url } else { const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.click() } window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } catch (requestError) { preview?.close(); throw requestError } }} onCreateAISession={async (input) => { await createAISession(session, projectId, input); await refreshProjectWorkspace(projectId) }} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
|
||
|
||
{projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
|
||
{navItems.map((item) => <button key={item.id} title={item.label} className={view === item.id ? 'active' : ''} onClick={() => { setView(item.id); if (item.id === 'ai') void loadAI() }}>{item.icon}<span>{item.label}</span></button>)}
|
||
</aside> : null}
|
||
|
||
<TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => 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 refreshProjectWorkspace(taskEditor.projectId)
|
||
setTaskEditor(null)
|
||
}} />
|
||
<ProjectModal visible={projectModalOpen} onClose={() => 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)
|
||
}} />
|
||
<ProfileModal user={user} visible={profileModalOpen} onClose={() => setProfileModalOpen(false)} onSave={async (input) => {
|
||
const nextUser = await updateCurrentUser(session, input)
|
||
setUser(nextUser)
|
||
persistSession({ ...session, user: nextUser })
|
||
setProfileModalOpen(false)
|
||
}} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts, onUploadDocument, onCreateMarkdown, onLoadMarkdown, onUpdateMarkdown, onOpenExternalDocument, onCreateAISession }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[]; onUploadDocument: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void>; onLoadMarkdown: (documentId: string) => Promise<DocumentDetail>; onUpdateMarkdown: (input: { id: string; name: string; markdown: string; revision: number }) => Promise<void>; onOpenExternalDocument: (documentId: string) => Promise<void>; onCreateAISession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
|
||
let panel: React.ReactNode
|
||
if (view === 'tasks') panel = <TaskBoard title="工作计划" subtitle="" tasks={tasks} onEditTask={onEditTask} />
|
||
else if (view === 'documents') panel = <DocumentPanel workspaces={workspaces} onUpload={onUploadDocument} onCreateMarkdown={onCreateMarkdown} onLoadMarkdown={onLoadMarkdown} onUpdateMarkdown={onUpdateMarkdown} onOpenExternalDocument={onOpenExternalDocument} />
|
||
else if (view === 'ai') panel = <AIPanel workspaces={workspaces} experts={experts} onLoad={onLoadAI} onCreateSession={onCreateAISession} />
|
||
else panel = <CronPanel title="" workspaces={workspaces} />
|
||
return <section className="project-content"><header className="project-page-header"><Title heading={3}>{title}</Title></header>{panel}</section>
|
||
}
|
||
|
||
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 <section className="content-page task-page">
|
||
<div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div><Tag color="arcoblue">{pending.length} 项待办</Tag></div>
|
||
<div className="tag-filter"><button className={tag === '全部' ? 'active' : ''} onClick={() => setTag('全部')}>全部</button>{tags.map((item) => <button key={item} className={tag === item ? 'active' : ''} onClick={() => setTag(item)}>{item}</button>)}</div>
|
||
<section className="task-section"><div className="section-label"><strong>待办任务</strong><span>{pending.length}</span></div><div className="task-list">{pending.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!pending.length && <Empty description="当前没有待办任务" />}</div></section>
|
||
{showCompleted ? <section className="task-section completed-section"><div className="section-label"><strong>已完成</strong><span>{completed.length}</span></div><div className="task-list">{completed.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!completed.length && <Empty description="尚未完成任何任务" />}</div></section> : null}
|
||
</section>
|
||
}
|
||
|
||
function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) {
|
||
return <button className={`task-row ${task.completed ? 'done' : ''}`} onClick={onClick}><span className="task-check">{task.completed ? <IconCheckCircleFill /> : <IconCheckCircle />}</span><span className="task-copy"><strong>{task.title}</strong><small>{task.summary || '暂无任务说明'}</small></span><span className="task-meta"><Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || task.projectName}</Tag><small>{formatDate(task.completedAt || task.createdAt)}</small></span></button>
|
||
}
|
||
|
||
|
||
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 <section className="content-page all-projects-home"><div className="content-heading"><div><Title heading={4}>全部项目</Title></div><Button type="primary" icon={<IconPlus />} onClick={onCreateProject}>创建项目</Button></div><div className="stat-grid">{statItems.map((item) => <div className={`stat-card ${item.color}`} key={item.label}><strong>{item.value}</strong><span>{item.label}</span></div>)}</div><TaskBoard title="待办任务" subtitle="" tasks={tasks} onEditTask={onEditTask} showCompleted={false} /><section className="latest-files"><div className="section-label"><strong>最新文件</strong><span>{latestFiles.length}</span></div><div className="detail-list">{latestFiles.map((file) => <article className="detail-row" key={file.id}><IconFile /><div><strong>{file.name}</strong><p>{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}</p></div><Tag>{file.projectName}</Tag></article>)}{!latestFiles.length && <Empty description="暂无文件" />}</div></section></section>
|
||
}
|
||
|
||
function ExplorePanel({ session, projectName }: { session: ApiSession; projectName: string }) {
|
||
const [sources, setSources] = useState<DatasetSource[]>([])
|
||
const [items, setItems] = useState<DatasetItem[]>([])
|
||
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 <section className="content-page explore-page"><div className="content-heading"><div><Title heading={4}>探索</Title><Text type="secondary">集中阅读数据源内容,筛选后沉淀到项目资料。</Text></div><Button type="text" shape="circle" aria-label="刷新探索内容" loading={loading} icon={<IconRefresh />} onClick={() => { void refreshSources(); void refreshItems() }} /></div>{error ? <Alert type="error" content={error} /> : null}<div className="explore-source-strip"><button className={sourceId === 'all' ? 'active' : ''} onClick={() => setSourceId('all')}><IconCompass /><span>全部</span><small>{allCount}</small></button>{sources.map((source) => <button className={sourceId === source.id ? 'active' : ''} key={source.id} onClick={() => setSourceId(source.id)}>{source.iconUrl ? <img src={source.iconUrl} alt="" /> : <IconBook />}<span>{source.name}</span><small>{source.itemCount}</small></button>)}</div><div className="explore-reader">{items.length ? <><div className="explore-item-list">{items.map((item) => <button className={item.id === selected?.id ? 'active' : ''} key={item.id} onClick={() => setSelectedId(item.id)}><span><b>{item.title}</b><small>{item.summary || '暂无摘要'}</small></span><i>{item.starred ? '★' : formatDate(item.publishedAt || item.createdAt)}</i></button>)}</div><article className="explore-detail"><div className="explore-detail-meta"><Tag color="arcoblue">{selectedSource?.name || '全部'}</Tag><small>{formatDate(selected?.publishedAt || selected?.createdAt || '')}</small></div><Title heading={5}>{selected?.title}</Title>{selected?.imageUrl ? <img src={selected.imageUrl} alt="" /> : null}<p>{selected?.content || selected?.summary || '暂无正文'}</p><div className="explore-detail-actions"><Button type="text" icon={<IconBook />} disabled={!projectName}>沉淀到{projectName || '项目'}</Button>{selected?.url ? <Button type="text" href={selected.url} target="_blank">打开原文</Button> : null}</div></article></> : <Empty description="暂无探索内容,请先在 Web 工作台添加或同步数据源" />}</div></section>
|
||
}
|
||
|
||
function DocumentPanel({ workspaces, onUpload, onCreateMarkdown, onLoadMarkdown, onUpdateMarkdown, onOpenExternalDocument }: { workspaces: Workspace[]; onUpload: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void>; onLoadMarkdown: (documentId: string) => Promise<DocumentDetail>; onUpdateMarkdown: (input: { id: string; name: string; markdown: string; revision: number }) => Promise<void>; onOpenExternalDocument: (documentId: string) => Promise<void> }) {
|
||
const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
const [creating, setCreating] = useState(false)
|
||
const [editing, setEditing] = useState<DocumentDetail | null>(null)
|
||
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 { if (editing) await onUpdateMarkdown({ id: editing.id, name: name.trim(), markdown, revision: editing.revision }); else await onCreateMarkdown({ name: name.trim(), markdown }); setCreating(false); setEditing(null); 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 = '' }
|
||
}
|
||
|
||
async function openDocument(document: WorkspaceDocument) {
|
||
setError('')
|
||
try {
|
||
if (document.extension.toLowerCase() === '.md' || document.mimeType.includes('markdown')) { const detail = await onLoadMarkdown(document.id); setEditing(detail); setName(detail.name); setMarkdown(detail.markdown ?? '') }
|
||
else await onOpenExternalDocument(document.id)
|
||
} catch (requestError) { setError(errorMessage(requestError)) }
|
||
}
|
||
|
||
if (creating || editing) return <section className="content-page markdown-editor-page"><div className="content-heading"><div><Text type="secondary">项目资料</Text><Title heading={4}>{editing ? '编辑 Markdown' : '新建 Markdown'}</Title></div><Button type="text" onClick={() => { setCreating(false); setEditing(null); setError('') }}>返回资料</Button></div>{error ? <Alert type="error" content={error} /> : null}<Form layout="vertical"><Form.Item label="文件名称" required><Input value={name} onChange={setName} placeholder="例如:会议记录.md" /></Form.Item><Form.Item label="Markdown 内容"><Input.TextArea value={markdown} onChange={setMarkdown} placeholder={'# 标题\n\n开始记录…'} autoSize={{ minRows: 14, maxRows: 24 }} /></Form.Item><div className="markdown-editor-actions"><Button onClick={() => { setCreating(false); setEditing(null) }}>取消</Button><Button type="primary" loading={saving} onClick={() => { void saveMarkdown() }}>{editing ? '保存修改' : '创建文件'}</Button></div></Form></section>
|
||
|
||
return <section className="content-page documents-page"><div className="content-heading"><div><Title heading={4}>笔记资料</Title></div><div className="document-actions"><input ref={inputRef} className="file-picker" type="file" multiple onChange={(event) => { void uploadFiles(event.target.files) }} /><Button icon={<IconUpload />} loading={uploading} onClick={() => inputRef.current?.click()}>上传文件</Button><Button type="primary" icon={<IconPlus />} onClick={() => setCreating(true)}>新建 Markdown</Button></div></div>{error ? <Alert type="error" content={error} /> : null}<div className="detail-list">{documents.map((item) => <button className="detail-row document-row" key={item.id} onClick={() => { void openDocument(item) }}><IconFile /><div><strong>{item.name}</strong><p>{item.extension || item.mimeType || item.kind} · 更新于 {formatDate(item.updatedAt)}</p></div><Tag>{item.extension.toLowerCase() === '.md' ? '编辑' : '浏览器打开'}</Tag></button>)}{!documents.length && <Empty description="暂无笔记或资料" />}</div></section>
|
||
}
|
||
|
||
function AIPanel({ workspaces, experts, onLoad, onCreateSession }: { workspaces: Workspace[]; experts: Expert[]; onLoad: () => void; onCreateSession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
|
||
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 <section className="content-page ai-session-page"><div className="content-heading"><div><Title heading={4}>{page === 'new' ? '新建会话' : '历史会话'}</Title></div><div className="ai-page-switch"><Button type={page === 'new' ? 'primary' : 'text'} icon={<IconPlus />} onClick={() => setPage('new')}>新建会话</Button><Button type={page === 'history' ? 'primary' : 'text'} icon={<IconClockCircle />} onClick={() => setPage('history')}>历史会话</Button></div></div>{error ? <Alert type="error" content={error} /> : null}{page === 'new' ? <Form className="ai-session-form" layout="vertical"><Form.Item label="会话标题"><Input value={title} onChange={setTitle} placeholder="例如:梳理本周项目计划" /></Form.Item><Form.Item label="选择专家"><div className="expert-chips">{experts.map((expert) => <button className={expert.id === expertId ? 'active' : ''} key={expert.id} type="button" onClick={() => setExpertId(expert.id)}><i style={{ background: expert.color }}>{expert.emoji}</i>{expert.name}</button>)}{!experts.length && <Text type="secondary">暂无可用专家</Text>}</div></Form.Item><Form.Item label="上下文或问题"><Input.TextArea value={context} onChange={setContext} placeholder="描述你希望 AI 协助完成的内容…" autoSize={{ minRows: 8, maxRows: 16 }} /></Form.Item><div className="ai-session-actions"><Button type="primary" loading={saving} icon={<IconRobot />} onClick={() => { void createSession() }}>创建会话</Button></div></Form> : <div className="ai-history-page detail-list">{sessions.map((item) => <article className="detail-row" key={item.id}><IconRobot /><div><strong>{item.title}</strong><p>{item.summary || 'AI 会话'} · {formatDate(item.updatedAt)}</p></div><Tag>{item.projectName}</Tag></article>)}{!sessions.length && <Empty description="暂无历史会话,先新建一个会话吧" />}</div>}</section>
|
||
}
|
||
|
||
function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
|
||
const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||
return <Panel title="计划任务" subtitle={title}>{plans.map((item) => <article className="detail-row" key={item.id}><IconClockCircle /><div><strong>{item.title}</strong><p>{item.schedule} · {item.enabled ? '已启用' : '已停用'}</p></div><Tag>{item.projectName}</Tag></article>)}{!plans.length && <Empty description="暂无计划任务" />}</Panel>
|
||
}
|
||
|
||
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <section className="content-page"><div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div></div><div className="detail-list">{children}</div></section> }
|
||
|
||
function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) {
|
||
const menu = <Menu><Menu.Item key="profile" onClick={onProfile}>修改资料</Menu.Item><Menu.Item key="logout" onClick={onLogout}>退出登录</Menu.Item></Menu>
|
||
return <Dropdown droplist={menu} position="br" trigger="click"><button className="profile-trigger" aria-label={`用户菜单,${user?.displayName || '用户'}`}><Avatar size={28}>{user?.displayName.slice(0, 1) || <IconUser />}</Avatar><span>{user?.displayName || '用户'}</span><IconDown /></button></Dropdown>
|
||
}
|
||
|
||
function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise<void> }) {
|
||
const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||
useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed); setError('') } }, [task])
|
||
return <Modal title="编辑任务" visible={Boolean(task)} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!title.trim()) return; setSaving(true); setError(''); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="标题" required><Input value={title} onChange={setTitle} /></Form.Item><Form.Item label="说明"><Input.TextArea value={summary} onChange={setSummary} autoSize={{ minRows: 3, maxRows: 6 }} /></Form.Item><Form.Item label="标签"><Select value={tag || undefined} allowClear onChange={(value) => setTag(value ?? '')}>{[...new Set(tags.filter(Boolean))].map((item) => <Select.Option key={item} value={item}>{item}</Select.Option>)}</Select></Form.Item><Form.Item label="完成状态"><Switch checked={completed} checkedText="已完成" uncheckedText="待办" onChange={completed => setCompleted(completed)} /></Form.Item></Form></Modal>
|
||
}
|
||
|
||
function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
|
||
const [name, setName] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||
useEffect(() => { if (visible) setError('') }, [visible])
|
||
return <Modal title="新建项目" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!name.trim()) return; setSaving(true); setError(''); try { await onSave(name.trim()); setName('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}>{error ? <Alert type="error" content={error} /> : null}<Input autoFocus placeholder="项目名称" value={name} onChange={setName} /></Modal>
|
||
}
|
||
|
||
function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void> }) {
|
||
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||
useEffect(() => { setDisplayName(user?.displayName ?? ''); if (visible) setError('') }, [user, visible])
|
||
return <Modal title="修改资料" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!displayName.trim()) return; setSaving(true); setError(''); try { await onSave({ displayName: displayName.trim(), currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="名称" required><Input value={displayName} onChange={setDisplayName} /></Form.Item><Form.Item label="邮箱"><Input disabled value={user?.email} /></Form.Item><Form.Item label="当前密码"><Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" /></Form.Item><Form.Item label="新密码"><Input.Password value={newPassword} onChange={setNewPassword} placeholder="不修改可留空" /></Form.Item></Form></Modal>
|
||
}
|
||
|
||
function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) {
|
||
const [server, setServer] = useState('http://agent.apinb.com'); const [email, setEmail] = useState('root'); const [password, setPassword] = useState('123456'); const [loading, setLoading] = useState(false); const [error, setError] = useState('')
|
||
return <div className="login-page"><form className="login-card" onSubmit={(event) => { event.preventDefault(); void submit() }}><Title heading={3} align="center"><img src="/senlinai-icon.svg" alt="" width="50%" /> <br/> 森林AI Agent</Title><Text type="secondary">连接你的私有工作台</Text><label>服务器地址<Input value={server} onChange={setServer} /></label><label>邮箱<Input value={email} onChange={setEmail} /></label><label>密码<Input.Password value={password} onChange={setPassword} /></label>{error && <Alert type="error" content={error} />}<Button type="primary" htmlType="submit" long loading={loading}>登录工作台</Button></form></div>
|
||
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() || <IconFolder /> }
|
||
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 : '操作失败,请稍后重试' }
|