Compare commits

..

14 Commits

8 changed files with 613 additions and 782 deletions

50
apps/desktop/design-qa.md Normal file
View File

@@ -0,0 +1,50 @@
# Desktop App design QA
## Comparison target
- Source visual truth: `C:\Users\david\AppData\Local\Temp\codex-clipboard-72c4b101-58e8-44b0-81b4-01abc35a29e6.png`
- Implementation screenshot: `test-results/app-home.png`
- Viewport: 420 × 820 CSS px at device scale factor 1; implementation image is 420 × 820 px.
- Source image: 144 × 343 px and only shows the compact icon-navigation language. It does not define the requested all-projects dashboard.
- State: signed-in light theme, “工作台 / 全部项目” selected, one pending task and one latest file. The content header is “全部项目” with its creation action aligned on the right.
- Primary interactions tested: default all-projects homepage, task editor modal, dataset-backed explore view, project picker, project-only right navigation, document panel, AI panel, and profile dropdown. Console errors: none.
## Evidence
- Full current implementation: `test-results/app-home.png`
- Explore reading flow: `test-results/app-explore.png`
- Earlier focused icon-rail comparison: `test-results/design-qa-rail-comparison.png`
The source rail is used only for the compact icon treatment. The latest requested behavior intentionally hides that rail in the all-projects dashboard and displays it only after selecting a specific project.
## Findings
No actionable P0, P1, or P2 findings.
- Fonts and typography: the existing Inter / Microsoft YaHei stack preserves clear hierarchy across compact project tabs, four statistics, task cards, and latest-file metadata.
- Spacing and layout rhythm: the all-projects page fills the former rail width after navigation is hidden. At 420 px, the header, tabs, statistics, actions, task lists, and latest files remain within the viewport with no horizontal overflow.
- Colors and visual tokens: workbench/explore selection uses the existing blue token, while statistic colors distinguish projects, pending work, files, and plan tasks without overpowering the task list.
- Image and icon fidelity: the existing product mark is retained and all controls use the installed Arco icon set; no placeholder or hand-drawn image assets were added.
- Copy and content: “工作台”, “探索”, and “全部项目” make scope explicit; its homepage shows only pending tasks and latest files. The project-only menu contains 概况、计划、资料、AI、计划任务 and no longer exposes 收集箱.
- Explore parity: the desktop view now uses the `web_v1` dataset endpoints and mirrors its core source-selection, item-list, and detail-reading workflow in a narrow reading layout. With 全部项目 selected, the “沉淀到项目” action is intentionally disabled until a concrete project is selected.
## Comparison history
- Current pass: removed the completed-task section and upload action from the all-projects homepage, then removed the content-level “工作台” label and moved 创建项目 beside 全部项目. Re-captured `test-results/app-home.png` and verified that only pending tasks and latest files remain.
- Explore pass: replaced the project-card placeholder with the dataset source and item reader. Captured `test-results/app-explore.png`; two sources and the selected article detail render without console errors.
## Follow-up polish
- [P3] If project count becomes large, the compact project picker can add a search field; the current list is appropriate for the MVP project volume.
## Implementation checklist
- [x] Replace the project strip with workbench, explore, and current-project controls.
- [x] Make all-projects the default selection.
- [x] Add all-project statistics, pending tasks, project creation, and latest files.
- [x] Hide right navigation for all-projects and explore views.
- [x] Remove 收集箱 and rename 周期计划 to 计划任务.
- [x] Align top-level 探索 with the `web_v1` dataset reading flow.
- [x] Verify key interaction paths and responsive composition.
final result: passed

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
<title>森林AI App</title> <title>森林AI Agent</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -4,110 +4,112 @@ import { chromium } from 'playwright'
const port = Number(process.env.MINI_VISUAL_PORT ?? 4180) const port = Number(process.env.MINI_VISUAL_PORT ?? 4180)
const server = await createServer({ root: process.cwd(), server: { host: '127.0.0.1', port, strictPort: true } }) const server = await createServer({ root: process.cwd(), server: { host: '127.0.0.1', port, strictPort: true } })
await server.listen() await server.listen()
const browser = await chromium.launch({ headless: true }) const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 420, height: 820 }, deviceScaleFactor: 1 }) const page = await browser.newPage({ viewport: { width: 420, height: 820 }, deviceScaleFactor: 1 })
const errors = [] const errors = []
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) }) page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) })
const projectId = '019b0000-0000-7000-8000-000000000101' const projectId = '019b0000-0000-7000-8000-000000000101'
const expert = { const user = { email: 'demo@senlin.ai', displayName: '演示用户' }
id: '019b0000-0000-7000-8000-000000000102', slug: 'product-manager', category: 'product', categoryName: '产品',
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
}
const workspace = { const workspace = {
project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '项目计划、资料和 AI 协作保持在同一条线上。', initials: '森林', unreadCount: 0 }, project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '项目协作空间', initials: '森林', unreadCount: 0 },
channels: [], channels: [], tags: [{ id: 'tag-product', name: '产品' }, { id: 'tag-design', name: '设计' }], recentSessions: [], inbox: [{ id: 'inbox-1', projectId, source: 'manual', title: '客户反馈', summary: '整理下周的反馈内容', status: 'open', tag: '产品', time: '2026-07-22T10:00:00Z' }],
tags: [{ id: 'tag-product', name: '产品' }, { id: 'tag-design', name: '设计' }],
recentSessions: [],
inbox: [],
tasks: [ tasks: [
{ id: 'task-1', projectId, title: '整理产品路线', summary: '确认下一阶段交付范围。', completed: false, owner: '张明', due: null, createdAt: '今天 09:00', completedAt: null, tagId: 'tag-product', tag: '产品' }, { id: 'task-1', projectId, title: '整理产品路线', summary: '确认下一阶段交付范围。', completed: false, owner: '张明', due: null, createdAt: '2026-07-22T09:00:00Z', completedAt: null, tagId: 'tag-product', tag: '产品' },
{ id: 'task-2', projectId, title: '检查迷你布局', summary: '验证窄屏下的导航和滚动。', completed: false, owner: '张明', due: null, createdAt: '今天 10:00', completedAt: null, tagId: 'tag-design', tag: '设计' }, { id: 'task-2', projectId, title: '完成迷你布局', summary: '验证窄屏下的导航和滚动。', completed: true, owner: '张明', due: null, createdAt: '2026-07-21T10:00:00Z', completedAt: '2026-07-22T10:00:00Z', tagId: 'tag-design', tag: '设计' },
],
aiSessions: [{ id: 'old-session', projectId, title: '梳理版本计划', summary: '项目会话', updatedAt: '今天', references: [] }],
documents: [
{ id: 'note-1', projectId, kind: 'markdown', name: '版本规划', extension: '.md', mimeType: 'text/markdown', updatedAt: '今天' },
{ id: 'source-1', projectId, kind: 'file', name: '需求说明.pdf', extension: '.pdf', mimeType: 'application/pdf', updatedAt: '昨天' },
], ],
aiSessions: [{ id: 'session-1', projectId, title: '梳理版本计划', summary: '下一步怎么安排', updatedAt: '2026-07-22T10:00:00Z', references: [] }],
documents: [{ id: 'doc-1', projectId, kind: 'markdown', name: '版本规划', extension: '.md', mimeType: 'text/markdown', updatedAt: '2026-07-22T10:00:00Z' }],
cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }], cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }],
} }
const expert = { id: 'expert-1', slug: 'product-manager', category: 'product', categoryName: '产品', name: '产品经理', description: '负责需求分析', emoji: '📘', color: '#165DFF' }
const datasetSource = { id: 'source-1', name: '行业资讯', kind: 'rss', url: 'https://example.com/feed', iconUrl: '', description: '行业动态', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1, createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }
const datasetItem = { id: 'item-1', sourceId: 'source-1', title: 'AI 工作台趋势', summary: '新的协作方式正在改变项目管理。', content: '这是来自数据源的探索内容,可在 Web 工作台继续管理数据源和同步任务。', url: '', imageUrl: '', status: 'unread', starred: false, publishedAt: '2026-07-22T10:00:00Z', createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }
await page.route('http://localhost:9150/api/v1/**', async (route) => { await page.route('http://localhost:9150/api/v1/**', async (route) => {
const request = route.request() const request = route.request(); const path = new URL(request.url()).pathname
const path = new URL(request.url()).pathname if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'app-token', user } })
if (path === '/api/v1/status') return route.fulfill({ json: { status: 'unavailable' } }) if (path === '/api/v1/auth/me') return route.fulfill({ json: user })
if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'mini-token' } }) if (path === '/api/v1/projects') return route.fulfill({ json: [workspace.project] })
if (path === '/api/v1/projects') {
if (request.method() === 'POST') return route.fulfill({ status: 201, json: workspace.project })
return route.fulfill({ json: [workspace.project] })
}
if (path === `/api/v1/projects/${projectId}/workspace`) return route.fulfill({ json: workspace }) if (path === `/api/v1/projects/${projectId}/workspace`) return route.fulfill({ json: workspace })
if (path === '/api/v1/ai-experts') return route.fulfill({ json: [expert, { ...expert, id: 'expert-2', name: 'UI 设计师', category: 'design', categoryName: '设计', emoji: '🎨', color: '#722ED1' }] }) if (path === `/api/v1/projects/${projectId}/documents/doc-1`) return route.fulfill({ json: { ...workspace.documents[0], revision: 1, markdown: '# 版本规划\n\n记录交付安排。', hasBlob: false, size: 32, createdAt: '2026-07-22T10:00:00Z' } })
if (path === `/api/v1/projects/${projectId}/ai-sessions`) { if (path === '/api/v1/ai-experts') return route.fulfill({ json: [expert] })
if (request.method() === 'POST') { if (path === '/api/v1/dataset-sources') return route.fulfill({ json: [datasetSource] })
const input = request.postDataJSON() if (path === '/api/v1/dataset-items') return route.fulfill({ json: { items: [datasetItem], total: 1, limit: 50, offset: 0 } })
return route.fulfill({ status: 201, json: { id: 'session-new', projectId, title: input.title, context: input.context, status: 'ready', expert, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } })
}
return route.fulfill({ json: [{ id: 'session-1', projectId, title: '梳理版本计划', context: '下一步怎么安排', status: 'ready', expert, createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }] })
}
if (path.includes('/tasks/') && request.method() === 'PATCH') return route.fulfill({ json: {} }) if (path.includes('/tasks/') && request.method() === 'PATCH') return route.fulfill({ json: {} })
if (path.endsWith('/tasks') && request.method() === 'POST') return route.fulfill({ status: 201, json: {} }) return route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '未配置的检查接口' } } })
if (path.endsWith('/cron-plans') && request.method() === 'POST') return route.fulfill({ status: 201, json: {} })
return route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '未配置的视觉检查接口' } } })
}) })
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' }) await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
await page.waitForSelector('.mini-connection.offline') await page.locator('.login-card input').nth(2).press('Enter')
const offlineConnection = await page.locator('.mini-connection').evaluate((element) => ({ await page.waitForSelector('.app-shell')
text: element.textContent, await page.waitForSelector('.task-row')
borderColor: getComputedStyle(element).borderColor,
}))
await page.screenshot({ path: 'test-results/app-login.png', fullPage: true })
const loginDefaults = await page.locator('.login-note input').evaluateAll((inputs) => inputs.map((input) => input.value))
await page.locator('.login-note input').nth(2).press('Enter')
await page.waitForSelector('.mini-shell')
await page.waitForTimeout(400)
await page.screenshot({ path: 'test-results/app-home.png', fullPage: true }) await page.screenshot({ path: 'test-results/app-home.png', fullPage: true })
const home = await page.evaluate(() => ({ const home = await page.evaluate(() => ({
width: document.querySelector('.mini-shell')?.getBoundingClientRect().width, width: document.querySelector('.app-shell')?.getBoundingClientRect().width,
navCount: document.querySelectorAll('.mini-nav button').length, selectedAll: document.querySelector('.project-current span')?.textContent,
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth, navCount: document.querySelectorAll('.channel-rail button').length,
title: document.querySelector('.page-intro h4')?.textContent ?? '', statCount: document.querySelectorAll('.stat-card').length,
pending: document.querySelectorAll('.task-row:not(.done)').length,
completed: document.querySelectorAll('.task-row.done').length,
})) }))
await page.locator('.task-row').first().click()
await page.getByRole('button', { name: '计划', exact: true }).click() await page.waitForSelector('.arco-modal')
await page.getByRole('button', { name: '设计', exact: true }).click() const taskModalWidth = (await page.locator('.arco-modal').boundingBox())?.width ?? 0
const tasks = await page.evaluate(() => ({ const taskModal = await page.getByText('编辑任务').count()
count: document.querySelectorAll('.task-note').length, await page.getByRole('button', { name: '取消' }).click()
title: document.querySelector('.task-note strong')?.textContent ?? '', await page.getByRole('button', { name: '探索' }).click()
})) await page.waitForSelector('.explore-detail')
await page.screenshot({ path: 'test-results/app-tasks.png', fullPage: true }) const exploreSources = await page.locator('.explore-source-strip button').count()
const exploreDetail = await page.locator('.explore-detail').count()
await page.getByRole('button', { name: '资料', exact: true }).click() await page.screenshot({ path: 'test-results/app-explore.png', fullPage: true })
const notes = await page.locator('.source-note').count() await page.locator('.project-current').click()
await page.getByRole('option', { name: '森林项目' }).click()
await page.getByRole('button', { name: 'AI', exact: true }).click() await page.waitForSelector('.channel-rail')
await page.getByRole('button', { name: /产品经理/ }).click() await page.locator('.channel-rail button[title="资料"]').click()
await page.locator('.mini-composer textarea').fill('请给出下一步计划') const documents = await page.locator('.detail-row').count()
const sendEnabled = await page.getByRole('button', { name: '开始会话' }).isEnabled() await page.locator('.document-row').first().click()
await page.waitForSelector('.markdown-editor-page')
const markdownEditing = await page.locator('.markdown-editor-page').count()
await page.locator('.markdown-editor-page .content-heading .arco-btn').click()
const documentActions = await page.locator('.document-actions .arco-btn').count()
await page.locator('.document-actions .arco-btn').nth(1).click()
await page.waitForSelector('.markdown-editor-page')
await page.screenshot({ path: 'test-results/app-markdown-new.png', fullPage: true })
const markdownEditor = await page.locator('.markdown-editor-page').count()
const markdownLayout = await page.evaluate(() => {
const rect = (selector) => { const node = document.querySelector(selector); const value = node?.getBoundingClientRect(); return value ? { left: value.left, width: value.width } : null }
return { scrollX: window.scrollX, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, shell: rect('.app-shell'), main: rect('.app-main'), rail: rect('.channel-rail'), editor: rect('.markdown-editor-page') }
})
await page.locator('.markdown-editor-page .content-heading .arco-btn').click()
await page.locator('.channel-rail button[title="AI"]').click()
await page.waitForSelector('.expert-chips')
const experts = await page.locator('.expert-chips button').count()
const aiSwitches = await page.locator('.ai-page-switch .arco-btn').count()
await page.screenshot({ path: 'test-results/app-ai-new.png', fullPage: true })
await page.locator('.ai-page-switch .arco-btn').nth(1).click()
await page.waitForSelector('.ai-history-page')
const aiHistory = await page.locator('.ai-history-page').count()
await page.screenshot({ path: 'test-results/app-ai-history.png', fullPage: true })
await page.locator('.profile-trigger').click()
await page.getByText('修改资料').waitFor()
const profileMenu = await page.getByText('修改资料').count()
await page.screenshot({ path: 'test-results/app-ai.png', fullPage: true }) await page.screenshot({ path: 'test-results/app-ai.png', fullPage: true })
await browser.close(); await server.close()
await browser.close()
await server.close()
const failures = [] const failures = []
if (!offlineConnection.text?.includes('连接异常') || offlineConnection.borderColor !== 'rgb(245, 63, 63)') failures.push(`offline connection must be red: ${JSON.stringify(offlineConnection)}`)
if (loginDefaults[1] !== 'demo@senlin.ai' || loginDefaults[2] !== 'password123') failures.push(`login defaults failed: ${JSON.stringify(loginDefaults)}`)
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`) if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
if (home.navCount !== 5) failures.push(`expected five top navigation items, got ${home.navCount}`) if (home.selectedAll !== '全部项目') failures.push(`default project scope must be 全部项目, got ${home.selectedAll}`)
if (home.overflowX) failures.push('mini client has horizontal overflow') if (home.navCount !== 0) failures.push(`all-projects view must hide the right rail, got ${home.navCount}`)
if (home.title !== '森林项目') failures.push(`project overview missing, got ${home.title}`) if (home.statCount !== 4) failures.push(`expected four summary cards, got ${home.statCount}`)
if (tasks.count !== 1 || tasks.title !== '检查迷你布局') failures.push(`task tag filtering failed: ${JSON.stringify(tasks)}`) if (home.pending !== 1 || home.completed !== 0) failures.push(`all-project task sections incorrect: ${JSON.stringify(home)}`)
if (notes !== 2) failures.push(`expected two note cards, got ${notes}`) if (taskModal !== 1) failures.push('task editor did not open')
if (!sendEnabled) failures.push('AI composer must enable after selecting an expert and entering a prompt') if (taskModalWidth > 396) failures.push(`task modal must fit the 420px window, got ${taskModalWidth}px`)
if (exploreSources !== 2 || exploreDetail !== 1) failures.push(`explore reader did not render: ${JSON.stringify({ exploreSources, exploreDetail })}`)
console.log(JSON.stringify({ home, tasks, notes, sendEnabled, errors }, null, 2)) if (documents !== 1) failures.push(`expected one document, got ${documents}`)
if (documentActions !== 2 || markdownEditor !== 1 || markdownEditing !== 1) failures.push(`document actions did not render: ${JSON.stringify({ documentActions, markdownEditor, markdownEditing })}`)
if (experts !== 1 || aiSwitches !== 2 || aiHistory !== 1) failures.push(`AI session pages did not render: ${JSON.stringify({ experts, aiSwitches, aiHistory })}`)
if (profileMenu !== 1) failures.push('profile menu did not open')
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
console.log(JSON.stringify({ home, documents, documentActions, markdownEditor, markdownEditing, markdownLayout, experts, aiSwitches, aiHistory, taskModal, taskModalWidth, exploreSources, exploreDetail, profileMenu, errors }, null, 2))
if (failures.length) throw new Error(failures.join('\n')) if (failures.length) throw new Error(failures.join('\n'))

View File

@@ -25,7 +25,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http: https:" "csp": "default-src 'self' blob:; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http: https:"
} }
}, },
"bundle": { "bundle": {

View File

@@ -1,398 +1,316 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react' import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
import { import {
IconApps, IconCheckCircle, IconClockCircle, IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings, IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot,
IconSun, IconUpload, IconUser,
} from '@arco-design/web-react/icon' } from '@arco-design/web-react/icon'
import { import {
ApiError, checkServerConnection, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts, ApiError, createAISession, createMarkdownDocument, createProject, fetchDocumentBlob, fetchProjectWorkspace, fetchProjects, getCurrentUser, getDocument,
fetchProjects, fetchWorkspace, login, updateTask, uploadSource, listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateDocument, updateTask,
type AISession, type ApiSession, type Expert, type Project, type Workspace, uploadDocument,
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type DocumentDetail, type Expert, type Project, type Workspace, type WorkspaceDocument, type WorkspaceTask,
} from './api' } from './api'
const { Text, Title } = Typography const { Text, Title } = Typography
type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more' const SESSION_KEY = 'senlin-app-session'
type Action = 'project' | 'task' | 'cron' | null type View = 'tasks' | 'documents' | 'ai' | 'cron'
type ConnectionStatus = 'checking' | 'online' | 'offline' type WorkspaceMode = 'workbench' | 'explore'
type SavedSession = ApiSession & { user?: CurrentUser }
type TaskWithProject = WorkspaceTask & { projectName: string }
const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [ const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [
{ id: 'home', label: '项目', icon: <IconApps /> }, { id: 'tasks', label: '计划', icon: <IconList /> },
{ id: 'tasks', label: '计划', icon: <IconCheckCircle /> }, { id: 'documents', label: '资料', icon: <IconFile /> },
{ id: 'notes', label: '资料', icon: <IconFile /> },
{ id: 'ai', label: 'AI', icon: <IconRobot /> }, { id: 'ai', label: 'AI', icon: <IconRobot /> },
{ id: 'more', label: '更多', icon: <IconSettings /> }, { id: 'cron', label: '任务', icon: <IconClockCircle /> },
] ]
export function App() { export function App() {
const [session, setSession] = useState<ApiSession | null>(() => readStoredSession()) 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 [projects, setProjects] = useState<Project[]>([])
const [workspaces, setWorkspaces] = useState<Record<string, Workspace>>({})
const [projectId, setProjectId] = useState('') const [projectId, setProjectId] = useState('')
const [workspace, setWorkspace] = useState<Workspace | null>(null) const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('workbench')
const [experts, setExperts] = useState<Expert[]>([]) const [view, setView] = useState<View>('tasks')
const [aiSessions, setAISessions] = useState<AISession[]>([]) const [dark, setDark] = useState(false)
const [view, setView] = useState<View>('home') const [loading, setLoading] = useState(Boolean(stored))
const [loading, setLoading] = useState(Boolean(session))
const [error, setError] = useState('') const [error, setError] = useState('')
const [action, setAction] = useState<Action>(null) const [taskEditor, setTaskEditor] = useState<TaskWithProject | null>(null)
const refreshGeneration = useRef(0) const [projectModalOpen, setProjectModalOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false)
const [experts, setExperts] = useState<Expert[]>([])
const workspaceRequestRef = useRef(0)
useEffect(() => { useEffect(() => {
if (!session) return if (!session) return
let cancelled = false if (!user) void getCurrentUser(session).then(setUser).catch((requestError) => setError(errorMessage(requestError)))
setLoading(true) void loadWorkspaces(session)
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]) }, [session])
useEffect(() => { async function loadWorkspaces(activeSession = session) {
if (!session || !projectId) return if (!activeSession) return
void refreshProject(session, projectId) const requestId = ++workspaceRequestRef.current
}, [projectId, session])
async function refreshProject(activeSession = session, activeProject = projectId) {
if (!activeSession || !activeProject) return
const generation = ++refreshGeneration.current
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const [nextWorkspace, nextExperts, nextSessions] = await Promise.all([ const nextProjects = await fetchProjects(activeSession)
fetchWorkspace(activeSession, activeProject), const items = await Promise.all(nextProjects.map(async (project) => [project.id, await fetchProjectWorkspace(activeSession, project.id)] as const))
experts.length ? Promise.resolve(experts) : fetchExperts(activeSession), if (requestId !== workspaceRequestRef.current) return
fetchAISessions(activeSession, activeProject), setProjects(nextProjects)
]) setWorkspaces(Object.fromEntries(items))
if (generation !== refreshGeneration.current) return setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '')
setWorkspace(nextWorkspace)
setExperts(nextExperts)
setAISessions(nextSessions)
} catch (requestError) { } catch (requestError) {
if (generation === refreshGeneration.current) setError(errorMessage(requestError)) if (requestId === workspaceRequestRef.current) setError(errorMessage(requestError))
} finally { } finally {
if (generation === refreshGeneration.current) setLoading(false) if (requestId === workspaceRequestRef.current) setLoading(false)
} }
} }
if (!session) return <Login onLogin={(next) => { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} /> async function refreshProjectWorkspace(targetProjectId: string, activeSession = session) {
if (!activeSession || !targetProjectId) return
const workspace = await fetchProjectWorkspace(activeSession, targetProjectId)
setWorkspaces((current) => ({ ...current, [targetProjectId]: workspace }))
}
const activeProject = projects.find((project) => project.id === projectId) ?? projects[0] async function loadAI() {
const content = !workspace || !activeProject if (!session || experts.length) return
? <Empty description="还没有项目,点击右上角新建" /> try { setExperts(await listAIExperts(session)) } catch (requestError) { setError(errorMessage(requestError)) }
: renderView(view, { }
session, workspace, experts, aiSessions,
refresh: () => refreshProject(), const visibleWorkspaces = projectId ? [workspaces[projectId]].filter(Boolean) : Object.values(workspaces)
openAction: setAction, 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 ( return (
<div className="mini-shell"> <div className={`app-shell ${dark ? 'theme-dark' : ''} ${!projectId || workspaceMode === 'explore' ? 'no-rail' : ''}`}>
<header className="mini-header" data-tauri-drag-region> <header className="app-header">
<div className="mini-brand" data-tauri-drag-region> <div className="app-brand" data-tauri-drag-region><img src="/senlinai-icon.svg" alt="" /><span>AI</span></div>
<img src="/senlinai-icon.svg" alt="" /> <div className="header-actions">
<span>AI</span> <Button type="text" shape="circle" aria-label={dark ? '切换浅色模式' : '切换深色模式'} icon={dark ? <IconSun /> : <IconMoon />} onClick={() => setDark((value) => !value)} />
<Tag size="small" color="arcoblue">App 1.0</Tag> <ProfileMenu user={user} onProfile={() => setProfileModalOpen(true)} onLogout={() => logout()} />
</div> </div>
</header> </header>
<div className="project-switcher"> <section className="project-strip" aria-label="项目列表">
<Select <button className={workspaceMode === 'workbench' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('workbench')}><IconApps /><span></span></button>
value={projectId || undefined} <button className={workspaceMode === 'explore' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('explore')}><IconCompass /><span></span></button>
placeholder="选择项目" <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>}>
onChange={setProjectId} <Select.Option value="all"></Select.Option>
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>)} {projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
</Select> </Select>
<Button aria-label="刷新" type="text" shape="circle" icon={<IconRefresh />} loading={loading} onClick={() => void refreshProject()} /> <Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
<Button aria-label="新建项目" type="text" shape="circle" icon={<IconPlus />} onClick={() => setAction('project')} /> <Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
</div> <Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
</section>
<nav className="mini-nav" aria-label="主要导航"> {error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
{navItems.map((item) => ( <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>
<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} {projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
<main className="mini-content"><Spin loading={loading} block>{content}</Spin></main> {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}
<footer className="mini-footer"> <TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
<span><i /> </span> if (!taskEditor) return
<button onClick={() => { localStorage.removeItem('senlin-mini-session'); setSession(null); setWorkspace(null) }}>退</button> await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed })
</footer> await refreshProjectWorkspace(taskEditor.projectId)
setTaskEditor(null)
<ActionModal }} />
action={action} <ProjectModal visible={projectModalOpen} onClose={() => setProjectModalOpen(false)} onSave={async (name) => {
workspace={workspace} const project = await createProject(session, { name, identifier: slugify(name) || `project-${Date.now().toString(36)}`, icon: 'folder', background: '#165DFF', description: '' })
onClose={() => setAction(null)} setProjectModalOpen(false)
onProject={async (name) => { await loadWorkspaces()
const project = await createProject(session, name)
const next = await fetchProjects(session)
setProjects(next)
setProjectId(project.id) setProjectId(project.id)
}} }} />
onTask={async (title, tag) => { <ProfileModal user={user} visible={profileModalOpen} onClose={() => setProfileModalOpen(false)} onSave={async (input) => {
if (!projectId) return const nextUser = await updateCurrentUser(session, input)
await createTask(session, projectId, title, tag) setUser(nextUser)
await refreshProject() persistSession({ ...session, user: nextUser })
}} setProfileModalOpen(false)
onCron={async (title, schedule) => { }} />
if (!projectId) return
await createCron(session, projectId, title, schedule)
await refreshProject()
}}
/>
</div> </div>
) )
} }
function renderView(view: View, props: ViewProps) { 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> }) {
if (view === 'tasks') return <TasksView {...props} /> let panel: React.ReactNode
if (view === 'notes') return <NotesView {...props} /> if (view === 'tasks') panel = <TaskBoard title="工作计划" subtitle="" tasks={tasks} onEditTask={onEditTask} />
if (view === 'ai') return <AIView {...props} /> else if (view === 'documents') panel = <DocumentPanel workspaces={workspaces} onUpload={onUploadDocument} onCreateMarkdown={onCreateMarkdown} onLoadMarkdown={onLoadMarkdown} onUpdateMarkdown={onUpdateMarkdown} onOpenExternalDocument={onOpenExternalDocument} />
if (view === 'more') return <MoreView {...props} /> else if (view === 'ai') panel = <AIPanel workspaces={workspaces} experts={experts} onLoad={onLoadAI} onCreateSession={onCreateAISession} />
return <HomeView {...props} /> else panel = <CronPanel title="" workspaces={workspaces} />
return <section className="project-content"><header className="project-page-header"><Title heading={3}>{title}</Title></header>{panel}</section>
} }
type ViewProps = { function TaskBoard({ title, subtitle, tasks, onEditTask, showCompleted = true }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; showCompleted?: boolean }) {
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.documents.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 [tag, setTag] = useState('全部')
const tags = workspace.tags.filter((item) => item.name !== '全部' && item.name !== 'all') const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))]
const tasks = tag === '全部' ? workspace.tasks : workspace.tasks.filter((task) => task.tag === tag) const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag)
return ( const pending = visible.filter((task) => !task.completed)
<section className="mini-page"> const completed = visible.filter((task) => task.completed)
<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> return <section className="content-page task-page">
<div className="filter-strip"> <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>
{['全部', ...tags.map((item) => item.name)].map((item) => <button className={tag === item ? 'active' : ''} key={item} onClick={() => setTag(item)}>{item}</button>)} <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>
</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>
<div className="sticky-list"> {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}
{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> </section>
)
} }
function NotesView({ session, workspace, refresh }: ViewProps) { function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) {
const fileInput = useRef<HTMLInputElement>(null) 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>
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.documents.map((document, index) => <article className={`source-note tone-${index % 4}`} key={document.id}><IconFile /><strong>{document.name}</strong><p>{document.extension || document.mimeType || document.kind}</p><span>{document.kind || '资料'} · {document.updatedAt}</span></article>)}
{!workspace.documents.length && <Empty description="还没有笔记或资料" />}
</div>
</section>
)
} }
function AIView({ session, workspace, experts, aiSessions, refresh }: ViewProps) {
const [query, setQuery] = useState('') function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void }) {
const [category, setCategory] = useState('全部') const pending = tasks.filter((task) => !task.completed)
const [expertId, setExpertId] = useState('') 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 [prompt, setPrompt] = useState('') const statItems = [
const [sending, setSending] = useState(false) { label: '项目', value: projects.length, color: 'blue' }, { label: '待办', value: pending.length, color: 'orange' },
const categories = [...new Set(experts.map((expert) => expert.categoryName))] { label: '文件', value: latestFiles.length, color: 'green' }, { label: '计划任务', value: workspaces.reduce((total, workspace) => total + workspace.cronPlans.length, 0), color: 'purple' },
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="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>
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) { function ExplorePanel({ session, projectName }: { session: ApiSession; projectName: string }) {
return ( const [sources, setSources] = useState<DatasetSource[]>([])
<section className="mini-page"> const [items, setItems] = useState<DatasetItem[]>([])
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button size="small" icon={<IconPlus />} onClick={() => openAction('cron')}></Button></div> const [sourceId, setSourceId] = useState('all')
<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> const [selectedId, setSelectedId] = useState('')
<div className="settings-card"><strong></strong><p>Web </p></div> const [loading, setLoading] = useState(true)
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>AI App</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 [error, setError] = useState('')
const [connection, setConnection] = useState<ConnectionStatus>('checking')
useEffect(() => { useEffect(() => { void refreshSources() }, [session])
const controller = new AbortController() useEffect(() => { void refreshItems() }, [session, sourceId])
const timer = window.setTimeout(() => void checkConnection(controller.signal), 300)
return () => {
controller.abort()
window.clearTimeout(timer)
}
}, [server])
async function checkConnection(signal?: AbortSignal) { async function refreshSources() {
setConnection('checking') setLoading(true); setError('')
try { try { setSources(await listDatasetSources(session)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
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 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) }
} }
async function submitLogin() { const allCount = sources.reduce((total, source) => total + source.itemCount, 0)
if (loading) return const selected = items.find((item) => item.id === selectedId) ?? items[0]
setLoading(true) 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('') setError('')
try { try {
onLogin(await login(server, email.trim(), password)) if (document.extension.toLowerCase() === '.md' || document.mimeType.includes('markdown')) { const detail = await onLoadMarkdown(document.id); setEditing(detail); setName(detail.name); setMarkdown(detail.markdown ?? '') }
} catch (requestError) { else await onOpenExternalDocument(document.id)
setError(errorMessage(requestError)) } catch (requestError) { setError(errorMessage(requestError)) }
} finally {
setLoading(false)
}
} }
return ( 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>
<div className="mini-login">
<form className="login-note" onSubmit={(event) => { event.preventDefault(); void submitLogin() }}> 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>
<img src="/senlinai-icon.svg" alt="" />
<Title heading={3}>AI App</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 }: { function AIPanel({ workspaces, experts, onLoad, onCreateSession }: { workspaces: Workspace[]; experts: Expert[]; onLoad: () => void; onCreateSession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
action: Action useEffect(() => { onLoad() }, [onLoad])
workspace: Workspace | null const [page, setPage] = useState<'new' | 'history'>('new')
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 [title, setTitle] = useState('')
const [tag, setTag] = useState('') const [context, setContext] = useState('')
const [schedule, setSchedule] = useState('0 9 * * 1-5') const [expertId, setExpertId] = useState('')
const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { if (action) { setTitle(''); setTag(''); setSchedule('0 9 * * 1-5') } }, [action]) const [error, setError] = useState('')
const labels = action === 'project' ? ['新建项目', '项目名称'] : action === 'task' ? ['新建计划', '计划标题'] : ['新建计划任务', '任务名称'] const sessions = workspaces.flatMap((workspace) => workspace.aiSessions.map((item) => ({ ...item, projectName: workspace.project.name })))
return ( useEffect(() => { setExpertId((current) => experts.some((expert) => expert.id === current) ? current : experts[0]?.id ?? '') }, [experts])
<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 { async function createSession() {
try { setSaving(true); setError('')
const value = JSON.parse(localStorage.getItem('senlin-mini-session') || 'null') as ApiSession | null 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 value?.baseUrl && value?.token ? value : null
} catch {
return null
} }
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 projectMark(project?: Project) { function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
if (!project) return <IconFolder /> const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
if (project.icon && !['folder', 'project'].includes(project.icon)) return project.icon 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>
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) { 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> }
if (error instanceof ApiError || error instanceof Error) return error.message
return '操作失败,请稍后重试' 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 : '操作失败,请稍后重试' }

View File

@@ -1,5 +1,7 @@
export type ApiSession = { baseUrl: string; token: string } export type ApiSession = { baseUrl: string; token: string }
export type CurrentUser = { email: string; displayName: string }
export type Project = { export type Project = {
id: string id: string
name: string name: string
@@ -9,6 +11,17 @@ export type Project = {
description: string description: string
} }
export type WorkspaceChannel = {
id: string
projectId: string
type: string
title: string
icon: string
count: number
url: string
sortOrder: number
}
export type WorkspaceTask = { export type WorkspaceTask = {
id: string id: string
projectId: string projectId: string
@@ -23,57 +36,31 @@ export type WorkspaceTask = {
tag: string tag: string
} }
export type WorkspaceDocument = { export type WorkspaceDocument = { id: string; projectId: string; kind: string; name: string; extension: string; mimeType: string; updatedAt: string }
id: string export type DocumentDetail = WorkspaceDocument & { revision: number; markdown?: string; hasBlob: boolean; size: number; createdAt: string }
projectId: string export type WorkspaceAISession = { id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }
kind: string export type WorkspaceInbox = { id: string; projectId: string; source: string; title: string; summary: string; status: string; tag: string; time: string }
name: string export type WorkspaceCronPlan = { id: string; projectId: string; title: string; schedule: string; nextRun: string | null; enabled: boolean; lastResult: string; owner: string }
extension: string
mimeType: string
updatedAt: string
}
export type CronPlan = {
id: string
projectId: string
title: string
schedule: string
nextRun: string | null
enabled: boolean
lastResult: string
owner: string
}
export type Workspace = { export type Workspace = {
project: Project & { initials: string; unreadCount: number } project: Project & { initials: string; unreadCount: number }
channels: WorkspaceChannel[]
tags: Array<{ id: string; name: string }> tags: Array<{ id: string; name: string }>
recentSessions: WorkspaceAISession[]
inbox: WorkspaceInbox[]
tasks: WorkspaceTask[] tasks: WorkspaceTask[]
aiSessions: WorkspaceAISession[]
documents: WorkspaceDocument[] documents: WorkspaceDocument[]
aiSessions: Array<{ id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }> cronPlans: WorkspaceCronPlan[]
cronPlans: CronPlan[]
} }
export type Expert = { export type Expert = { id: string; slug: string; category: string; categoryName: string; name: string; description: string; emoji: string; color: string }
id: string export type AISession = { id: string; projectId: string; title: string; context: string; status: string; expert: Expert | null; createdAt: string; updatedAt: string }
slug: string export type CreateTaskInput = { title: string; description?: string; status?: string; dueAt?: string; tag?: string }
category: string export type UpdateTaskInput = { title: string; description?: string; status?: string; completed: boolean; nextProjectId?: string; tag?: string }
categoryName: string export type DatasetSource = { id: string; name: string; kind: 'manual' | 'link' | 'rss'; url: string; iconUrl: string; description: string; enabled: boolean; builtIn: boolean; lastSyncedAt: string | null; itemCount: number; createdAt: string; updatedAt: string }
name: string export type DatasetItem = { id: string; sourceId: string; title: string; summary: string; content: string; url: string; imageUrl: string; status: 'unread' | 'read' | 'archived'; starred: boolean; publishedAt: string | null; createdAt: string; updatedAt: string }
description: string export type DatasetItemPage = { items: DatasetItem[]; total: number; limit: number; offset: number }
emoji: string
color: string
}
export type AISession = {
id: string
projectId: string
title: string
context: string
status: string
expert: Expert | null
createdAt: string
updatedAt: string
}
export class ApiError extends Error { export class ApiError extends Error {
constructor(public status: number, public code: string, message: string) { constructor(public status: number, public code: string, message: string) {
@@ -84,131 +71,143 @@ export class ApiError extends Error {
let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150') let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
export function configureApi(value: string) { export function setApiBaseUrl(value: string) { baseUrl = normalizeBaseUrl(value) }
baseUrl = normalizeBaseUrl(value) export function getApiBaseUrl() { return baseUrl }
export async function login(server: string, email: string, password: string): Promise<ApiSession & { user: CurrentUser }> {
setApiBaseUrl(server)
const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', { method: 'POST', body: { email, password } })
return { baseUrl, token: response.token, user: response.user }
} }
export async function login(server: string, email: string, password: string): Promise<ApiSession> { export function getCurrentUser(session: ApiSession) {
configureApi(server) useSessionBase(session)
const response = await request<{ token: string }>('/api/v1/auth/login', { method: 'POST', body: { email, password } }) return apiRequest<CurrentUser>('/api/v1/auth/me', { token: session.token })
return { baseUrl, token: response.token } }
export function updateCurrentUser(session: ApiSession, input: { displayName: string; currentPassword?: string; newPassword?: string }) {
useSessionBase(session)
return apiRequest<CurrentUser>('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
}
export function fetchProjects(session: ApiSession) {
useSessionBase(session)
return apiRequest<Project[]>('/api/v1/projects', { token: session.token })
}
export function fetchProjectWorkspace(session: ApiSession, projectId: string) {
useSessionBase(session)
return apiRequest<Workspace>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
}
export function createProject(session: ApiSession, input: Pick<Project, 'name' | 'identifier' | 'icon' | 'background' | 'description'>) {
useSessionBase(session)
return apiRequest<Project>('/api/v1/projects', { method: 'POST', token: session.token, body: input })
}
export function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
useSessionBase(session)
return apiRequest(`/api/v1/projects/${projectId}/tasks`, { method: 'POST', token: session.token, body: input })
}
export function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) {
useSessionBase(session)
return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', token: session.token, body: input })
}
export function uploadDocument(session: ApiSession, projectId: string, file: File, parentId = '') {
useSessionBase(session)
const body = new FormData()
body.append('file', file)
if (parentId) body.append('parentId', parentId)
return apiRequest(`/api/v1/projects/${projectId}/documents/uploads`, { method: 'POST', token: session.token, body })
}
export function createMarkdownDocument(session: ApiSession, projectId: string, input: { name: string; markdown?: string; parentId?: string }) {
useSessionBase(session)
return apiRequest(`/api/v1/projects/${projectId}/documents/markdown`, { method: 'POST', token: session.token, body: input })
}
export function getDocument(session: ApiSession, projectId: string, documentId: string) {
useSessionBase(session)
return apiRequest<DocumentDetail>(`/api/v1/projects/${projectId}/documents/${documentId}`, { token: session.token })
}
export function updateDocument(session: ApiSession, projectId: string, documentId: string, input: { name: string; markdown: string; revision: number }) {
useSessionBase(session)
return apiRequest<DocumentDetail>(`/api/v1/projects/${projectId}/documents/${documentId}`, { method: 'PATCH', token: session.token, body: input })
}
export async function fetchDocumentBlob(session: ApiSession, projectId: string, documentId: string) {
useSessionBase(session)
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/documents/${documentId}/content`, { headers: { Authorization: `Bearer ${session.token}` } })
if (!response.ok) throw new ApiError(response.status, fallbackCode(response.status), fallbackMessage(response.status))
return response.blob()
}
export function listAIExperts(session: ApiSession) {
useSessionBase(session)
return apiRequest<Expert[]>('/api/v1/ai-experts', { token: session.token })
}
export function listAISessions(session: ApiSession, projectId: string) {
useSessionBase(session)
return apiRequest<AISession[]>(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token })
}
export function createAISession(session: ApiSession, projectId: string, input: { title: string; context: string; expertId?: string }) {
useSessionBase(session)
return apiRequest<AISession>(`/api/v1/projects/${projectId}/ai-sessions`, { method: 'POST', token: session.token, body: input })
}
export function listDatasetSources(session: ApiSession) {
useSessionBase(session)
return apiRequest<DatasetSource[]>('/api/v1/dataset-sources', { token: session.token })
}
export function listDatasetItems(session: ApiSession, sourceId?: string, offset = 0, limit = 50) {
useSessionBase(session)
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) })
if (sourceId) params.set('sourceId', sourceId)
return apiRequest<DatasetItemPage>(`/api/v1/dataset-items?${params}`, { token: session.token })
} }
export async function checkServerConnection(server: string, signal?: AbortSignal) { export async function checkServerConnection(server: string, signal?: AbortSignal) {
try { try {
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal }) const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })
if (!response.ok) return false return response.ok
const payload = await response.json() as { timestamp?: unknown }
return typeof payload.timestamp === 'string'
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error if (error instanceof DOMException && error.name === 'AbortError') throw error
return false return false
} }
} }
export async function fetchProjects(session: ApiSession) { type RequestOptions = { method?: string; token?: string; body?: unknown; responseType?: 'void'; signal?: AbortSignal }
useSessionBase(session)
return request<Project[]>('/api/v1/projects', { token: session.token })
}
export async function fetchWorkspace(session: ApiSession, projectId: string) { export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
useSessionBase(session) const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
return request<Workspace>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
}
export async function createProject(session: ApiSession, name: string) {
useSessionBase(session)
const identifier = `${slugify(name) || 'project'}-${Date.now().toString(36)}`
return request<Project>('/api/v1/projects', {
method: 'POST', token: session.token,
body: { name, identifier, icon: 'folder', background: '#165DFF', description: '' },
})
}
export async function createTask(session: ApiSession, projectId: string, title: string, tag?: string) {
useSessionBase(session)
return request(`/api/v1/projects/${projectId}/tasks`, {
method: 'POST', token: session.token, body: { title, description: '', status: 'open', tag },
})
}
export async function updateTask(session: ApiSession, projectId: string, task: WorkspaceTask) {
useSessionBase(session)
return request(`/api/v1/projects/${projectId}/tasks/${task.id}`, {
method: 'PATCH', token: session.token,
body: { title: task.title, description: task.summary, completed: !task.completed, tag: task.tag },
})
}
export async function uploadSource(session: ApiSession, projectId: string, file: File) {
useSessionBase(session)
const body = new FormData()
body.append('file', file)
body.append('title', file.name)
return request(`/api/v1/projects/${projectId}/sources`, { method: 'POST', token: session.token, body })
}
export async function createCron(session: ApiSession, projectId: string, title: string, schedule: string) {
useSessionBase(session)
return request(`/api/v1/projects/${projectId}/cron-plans`, {
method: 'POST', token: session.token, body: { title, schedule, enabled: true },
})
}
export async function fetchExperts(session: ApiSession) {
useSessionBase(session)
return request<Expert[]>('/api/v1/ai-experts', { token: session.token })
}
export async function fetchAISessions(session: ApiSession, projectId: string) {
useSessionBase(session)
return request<AISession[]>(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token })
}
export async function createAISession(session: ApiSession, projectId: string, expertId: string, context: string) {
useSessionBase(session)
return request<AISession>(`/api/v1/projects/${projectId}/ai-sessions`, {
method: 'POST', token: session.token,
body: { title: Array.from(context).slice(0, 36).join(''), context, expertId },
})
}
type RequestOptions = { method?: string; token?: string; body?: unknown; signal?: AbortSignal }
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const isForm = options.body instanceof FormData
let response: Response let response: Response
try { try {
response = await fetch(`${baseUrl}${path}`, { response = await fetch(`${baseUrl}${path}`, {
method: options.method ?? 'GET', method: options.method ?? 'GET',
headers: { headers: { ...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) },
...(!isForm && options.body !== undefined ? { 'Content-Type': 'application/json' } : {}), body: options.body === undefined ? undefined : isFormData ? options.body as FormData : JSON.stringify(options.body),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: options.body === undefined ? undefined : isForm ? options.body as FormData : JSON.stringify(options.body),
signal: options.signal, signal: options.signal,
}) })
} catch { } catch {
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络') throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
} }
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null const payload = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null
throw new ApiError(response.status, payload?.error?.code ?? 'request_failed', payload?.error?.message ?? '请求失败,请稍后重试') throw new ApiError(response.status, payload?.error?.code ?? fallbackCode(response.status), payload?.error?.message ?? fallbackMessage(response.status))
} }
if (response.status === 204) return undefined as T if (response.status === 204 || options.responseType === 'void') return undefined as T
return response.json() as Promise<T> const text = await response.text()
if (!text.trim()) throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据')
try { return JSON.parse(text) as T } catch { throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据') }
} }
function useSessionBase(session: ApiSession) { function useSessionBase(session: ApiSession) { baseUrl = normalizeBaseUrl(session.baseUrl) }
baseUrl = normalizeBaseUrl(session.baseUrl) function normalizeBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/, ''); return !normalized || /^https?:\/\//i.test(normalized) ? normalized : `http://${normalized}` }
} function fallbackCode(status: number) { return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : status === 404 ? 'not_found' : status === 409 ? 'conflict' : status >= 500 ? 'internal_error' : 'request_failed' }
function fallbackMessage(status: number) { return status === 401 ? '登录已失效,请重新登录' : status === 403 ? '没有权限执行此操作' : status === 404 ? '请求的内容不存在' : status === 409 ? '操作冲突,请刷新后重试' : status >= 500 ? '服务器暂时无法处理请求,请稍后重试' : '请求失败,请稍后重试' }
function normalizeBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/, '')
if (!normalized || /^https?:\/\//i.test(normalized)) return normalized
return `http://${normalized}`
}
function slugify(value: string) {
return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '')
}

View File

@@ -1,235 +1,97 @@
:root { :root { font-family: Inter, "Microsoft YaHei", sans-serif; color: #1d2129; background: #f4f5f7; }
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #1d2129;
background: #e8edf4;
font-synthesis: none;
--blue: #165dff;
--line: #e5e8ef;
--muted: #86909c;
--panel: #fff;
--canvas: #f5f7fa;
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 320px; height: 100%; margin: 0; } body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; }
body { overflow: hidden; background: #e8edf4; } button { font: inherit; }
button, input, textarea { font: inherit; } .arco-modal-wrapper { padding: 12px; }
button { color: inherit; } .arco-modal { width: min(520px, calc(100vw - 24px)); max-width: calc(100vw - 24px); }
.arco-modal-header { min-width: 0; }
.arco-modal-title { overflow-wrap: anywhere; }
.arco-modal-body { max-height: calc(100vh - 180px); overflow-y: auto; overscroll-behavior: contain; }
.arco-modal-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.arco-trigger-popup { max-width: calc(100vw - 24px); }
.mini-shell { .app-shell { --canvas: #f6f7f9; --surface: #fff; --line: #e5e6eb; --text: #1d2129; --muted: #86909c; --rail: #f2f3f5; display: grid; grid-template: 48px 46px minmax(0, 1fr) / minmax(0, 1fr) 58px; min-height: 100vh; color: var(--text); background: var(--canvas); overflow: clip; }
width: 100%; .app-shell.theme-dark { --canvas: #171a1f; --surface: #23272e; --line: #3a3f47; --text: #f2f3f5; --muted: #a9b0bb; --rail: #2b3038; color-scheme: dark; }
height: 100vh; .app-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; min-width: 0; padding: 0 12px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface); }
display: grid; .app-brand { display: flex; align-items: center; gap: 7px; min-width: 0; height: 100%; font-weight: 700; letter-spacing: .1px; }
grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px; .app-brand img { width: 23px; height: 23px; }
overflow: hidden; .header-actions { display: flex; align-items: center; gap: 5px; }
background: var(--canvas); .profile-trigger { display: inline-flex; align-items: center; gap: 6px; max-width: 144px; border: 0; padding: 4px; color: var(--text); background: transparent; border-radius: 8px; cursor: pointer; }
border: 0; .profile-trigger:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
box-shadow: none; .profile-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
} .profile-trigger > svg { flex: 0 0 auto; color: var(--muted); font-size: 13px; }
.mini-header, .project-strip { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; min-width: 0; padding: 5px 9px; border-bottom: 1px solid var(--line); background: var(--surface); }
.project-switcher, .project-scroller { display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; flex: 1; }
.mini-nav, .project-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; max-width: 122px; min-height: 30px; padding: 4px 8px; border: 1px solid transparent; border-radius: 8px; color: #4e5969; background: transparent; cursor: pointer; }
.mini-footer { .project-pill:hover, .project-pill.active { color: #165dff; background: #e8f3ff; border-color: #d3e7ff; }
background: rgba(255, 255, 255, 0.96); .project-pill i { display: grid; place-items: center; width: 19px; height: 19px; border-radius: 6px; color: #fff; font-style: normal; font-size: 9px; font-weight: 700; }
} .project-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
.project-select { min-width: 0; flex: 1; }.project-select .arco-select-view { border: 0; padding: 0; background: transparent; }.project-select .arco-select-view-value { display: none; }.project-current { width: 100%; max-width: none; justify-content: flex-start; }.project-current .project-dropdown-icon { flex: 0 0 auto; margin-left: 1px; color: var(--muted); font-size: 12px; }
.mini-header { .app-alert { grid-column: 1 / 2; z-index: 2; margin: 8px 12px -46px; align-self: start; }
display: flex; .app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; }
align-items: center; .app-shell.no-rail .app-main { grid-column: 1 / -1; }
justify-content: space-between; .app-main > .arco-spin { min-height: 100%; }
padding: 0 10px 0 12px; .content-page { width: 100%; max-width: 650px; min-width: 0; margin: 0 auto; }
border-bottom: 1px solid var(--line); .project-content { min-width: 0; }
user-select: none; .project-page-header { width: calc(100% + 30px); margin: -18px -15px 18px; padding: 20px 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface); }
} .project-page-header .arco-typography { margin: 0; font-size: 26px; line-height: 1.25; }
.document-actions, .markdown-editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.file-picker { display: none; }
.document-row { width: 100%; color: var(--text); text-align: left; cursor: pointer; }
.document-row:hover { border-color: #9bc7ff; background: #f7fbff; }
.documents-page .arco-alert, .markdown-editor-page .arco-alert { margin-bottom: 12px; }
.markdown-editor-page .arco-form { display: grid; min-width: 0; gap: 2px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.markdown-editor-page .arco-form-item, .markdown-editor-page .arco-form-item-wrapper { min-width: 0; }
.markdown-editor-page .arco-textarea { min-height: 280px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.65; }
.content-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.content-heading .arco-typography { margin: 0; }
.content-heading h4 { margin-top: 2px; font-size: 20px; }
.tag-filter { display: flex; gap: 6px; margin-bottom: 16px; overflow-x: auto; padding-bottom: 2px; }
.tag-filter button { flex: 0 0 auto; padding: 5px 10px; border: 0; border-radius: 999px; color: var(--muted); background: var(--surface); cursor: pointer; font-size: 12px; }
.tag-filter button.active { color: #fff; background: #165dff; }
.task-section { margin-bottom: 18px; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.completed-section { opacity: .82; }
.section-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; font-size: 13px; }
.section-label span { display: grid; place-items: center; min-width: 21px; height: 21px; border-radius: 50%; color: #165dff; background: #e8f3ff; font-size: 11px; font-weight: 700; }
.task-list { display: grid; gap: 7px; }
.task-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; border: 1px solid transparent; border-radius: 9px; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.task-row:hover { border-color: #c9d7ee; background: #f7faff; }
.task-row.done .task-copy strong { color: var(--muted); text-decoration: line-through; }
.task-check { color: #165dff; font-size: 21px; line-height: 0; }
.task-row.done .task-check { color: #00b42a; }
.task-copy { display: grid; min-width: 0; gap: 3px; }
.task-copy strong, .task-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-copy strong { font-size: 13px; }
.task-copy small { color: var(--muted); font-size: 11px; }
.task-meta { display: grid; justify-items: end; gap: 4px; }
.task-meta small { color: var(--muted); font-size: 10px; }
.mini-brand, .channel-rail { grid-column: 2; grid-row: 3; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 6px; border-left: 1px solid var(--line); background: var(--rail); overflow-y: auto; }
.project-trigger, .channel-rail button { display: grid; place-items: center; width: 42px; min-height: 43px; gap: 2px; border: 0; border-radius: 10px; color: #6b7077; background: transparent; cursor: pointer; }
.mini-footer, .channel-rail button:hover { color: #165dff; background: #e8f3ff; }
.section-title, .channel-rail button.active { color: #165dff; background: #dbeeff; box-shadow: inset 0 0 0 1px #c9e2ff; }
.page-title, .channel-rail svg { font-size: 20px; }
.selected-expert, .channel-rail span { font-size: 9px; line-height: 1; }
.session-list > div,
.settings-card > div,
.version-card {
display: flex;
align-items: center;
}
.mini-brand { gap: 7px; font-weight: 800; } .detail-list { display: grid; gap: 8px; }
.mini-brand img { width: 25px; height: 25px; } .detail-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
.mini-brand .arco-tag { margin-left: 2px; } .detail-row > svg { color: #165dff; font-size: 18px; }
.detail-row div { min-width: 0; }
.detail-row strong, .detail-row p { display: block; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-row strong { font-size: 13px; }.detail-row p { margin-top: 3px; color: var(--muted); font-size: 11px; }
.ai-page-switch, .ai-session-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.ai-session-page .arco-alert { margin-bottom: 12px; }
.ai-session-form { display: grid; gap: 2px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.ai-session-form .arco-textarea { min-height: 180px; line-height: 1.65; }
.expert-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
.expert-chips button { display: inline-flex; align-items: center; gap: 4px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); background: var(--surface); cursor: pointer; font-size: 11px; }.expert-chips button.active { color: #165dff; border-color: #9bc7ff; background: #edf6ff; }.expert-chips i { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; font-style: normal; }
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 13px; }.stat-card { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }.stat-card strong { font-size: 22px; line-height: 1; }.stat-card span { color: var(--muted); font-size: 11px; }.stat-card.blue strong { color: #165dff; }.stat-card.orange strong { color: #ff7d00; }.stat-card.green strong { color: #00b42a; }.stat-card.purple strong { color: #722ed1; }
.quick-workspace-actions { display: flex; gap: 8px; margin-bottom: 18px; }.all-projects-home .task-page { margin-top: 4px; }.latest-files { padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.project-card-grid { display: grid; gap: 9px; }.project-explore-card { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; padding: 13px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.project-explore-card:hover { border-color: #9fc9ff; background: #f7faff; }.project-explore-card > i { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: #fff; font-style: normal; font-weight: 700; font-size: 11px; }.project-explore-card span { display: grid; min-width: 0; gap: 3px; }.project-explore-card strong, .project-explore-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.project-explore-card strong { font-size: 13px; }.project-explore-card small { color: var(--muted); font-size: 11px; }
.explore-source-strip { display: flex; gap: 7px; margin-bottom: 12px; overflow-x: auto; padding-bottom: 2px; }.explore-source-strip button { display: grid; grid-template-columns: 18px auto; grid-template-rows: auto auto; column-gap: 5px; align-items: center; min-width: 82px; padding: 8px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.explore-source-strip button.active { border-color: #9bc7ff; background: #edf6ff; color: #165dff; }.explore-source-strip svg, .explore-source-strip img { grid-row: 1 / 3; width: 18px; height: 18px; border-radius: 5px; object-fit: cover; }.explore-source-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 600; }.explore-source-strip small { color: var(--muted); font-size: 10px; }.explore-reader { display: grid; gap: 10px; }.explore-item-list { display: grid; gap: 6px; }.explore-item-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.explore-item-list button.active { border-color: #9bc7ff; background: #f0f7ff; }.explore-item-list span { display: grid; min-width: 0; gap: 4px; }.explore-item-list b, .explore-item-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.explore-item-list b { font-size: 13px; }.explore-item-list small { color: var(--muted); font-size: 11px; }.explore-item-list i { align-self: center; color: #86909c; font-style: normal; font-size: 10px; }.explore-detail { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.explore-detail .arco-typography { margin-top: 9px; }.explore-detail-meta { display: flex; align-items: center; justify-content: space-between; }.explore-detail-meta small { color: var(--muted); font-size: 11px; }.explore-detail > img { width: 100%; max-height: 180px; margin: 4px 0 10px; border-radius: 9px; object-fit: cover; }.explore-detail p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; white-space: pre-wrap; }.explore-detail-actions { display: flex; gap: 4px; margin-top: 8px; }
.project-switcher { .login-page { display: grid; min-height: 100vh; place-items: center; padding: 20px; background: #edf2f9; }.login-card { display: grid; width: min(100%, 360px); gap: 12px; padding: 28px; border: 1px solid #e5e6eb; border-radius: 16px; background: #fff; box-shadow: 0 18px 48px rgba(31, 35, 41, .12); }.login-card > img { width: 34px; }.login-card .arco-typography { margin: 0; }.login-card label { display: grid; gap: 5px; color: #4e5969; font-size: 12px; }
display: grid; .theme-dark .project-strip, .theme-dark .app-header, .theme-dark .task-section, .theme-dark .detail-row, .theme-dark .tag-filter button { color: var(--text); background: var(--surface); }.theme-dark .project-pill { color: var(--muted); }.theme-dark .project-pill.active, .theme-dark .channel-rail button.active { color: #8ac7ff; background: #1d4d77; }.theme-dark .task-row:hover { background: #293847; border-color: #477da7; }.theme-dark .section-label span { color: #8ac7ff; background: #1d4d77; }
grid-template-columns: minmax(0, 1fr) 32px 32px;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--line);
}
.project-switcher > .arco-select { width: 100%; } @media (max-width: 360px) { .profile-trigger > span, .project-cycle { display: none; }.project-pill { max-width: 90px; }.project-strip { gap: 2px; padding-inline: 5px; }.app-main { padding: 13px 9px 20px; }.project-page-header { width: calc(100% + 18px); margin: -13px -9px 15px; padding: 17px 9px 14px; }.document-actions { width: 100%; justify-content: flex-start; }.document-actions .arco-btn { padding-inline: 8px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; }.stat-grid { grid-template-columns: repeat(2, 1fr); } }
.project-trigger {
width: 100%;
min-width: 0;
height: 36px;
gap: 8px;
border: 0;
border-radius: 9px;
background: transparent;
padding: 3px 6px;
text-align: left;
cursor: pointer;
}
.project-trigger:hover { background: #f2f5fa; }
.project-trigger > span:nth-of-type(1) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
.project-trigger small { margin-left: auto; color: var(--muted); font-size: 10px; }
.mini-nav {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
}
.mini-nav button {
position: relative;
display: grid;
place-items: center;
align-content: center;
gap: 2px;
border: 0;
background: transparent;
color: #6b778c;
cursor: pointer;
font-size: 11px;
}
.mini-nav button::after { content: ""; position: absolute; left: 22%; right: 22%; bottom: 0; height: 2px; border-radius: 99px; background: transparent; }
.mini-nav svg { font-size: 18px; }
.mini-nav button:hover,
.mini-nav button.active { color: var(--blue); background: linear-gradient(180deg, transparent, #f3f7ff); }
.mini-nav button.active::after { background: var(--blue); }
.mini-alert { position: fixed; z-index: 30; top: 102px; left: 50%; width: min(388px, calc(100% - 24px)); transform: translateX(-50%); }
.mini-content { min-height: 0; overflow: hidden; }
.mini-content > .arco-spin { width: 100%; height: 100%; }
.mini-content > .arco-spin > .arco-spin-children { height: 100%; }
.mini-page { height: 100%; overflow-x: hidden; overflow-y: auto; padding: 14px 14px 24px; scrollbar-width: thin; scrollbar-color: #c9cdd4 transparent; }
.mini-footer { justify-content: space-between; padding: 0 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; }
.mini-footer span { display: inline-flex; align-items: center; gap: 5px; }
.mini-footer i { width: 6px; height: 6px; border-radius: 50%; background: #00b42a; box-shadow: 0 0 0 2px #e8ffea; }
.mini-footer button { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 10px; }
.page-intro {
position: relative;
overflow: hidden;
border-radius: 16px;
background: linear-gradient(135deg, #165dff, #6aa1ff);
color: white;
padding: 18px;
box-shadow: 0 12px 28px rgba(22, 93, 255, 0.2);
}
.page-intro::after { content: ""; position: absolute; width: 130px; height: 130px; right: -45px; top: -52px; border: 24px solid rgba(255,255,255,.1); border-radius: 50%; }
.page-intro .arco-typography { color: rgba(255,255,255,.78); }
.page-intro h4 { margin: 4px 0 5px; color: white; }
.page-intro p { position: relative; z-index: 1; margin: 0; color: rgba(255,255,255,.86); font-size: 12px; line-height: 1.6; }
.metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 12px; }
.metric { display: grid; gap: 2px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 12px; }
.metric strong { font-size: 22px; }
.metric span { color: var(--muted); font-size: 11px; }
.metric.blue strong { color: #165dff; }.metric.green strong { color: #00b42a; }.metric.purple strong { color: #722ed1; }.metric.orange strong { color: #ff7d00; }
.section-title { justify-content: space-between; gap: 8px; margin: 18px 2px 9px; font-size: 13px; }
.quick-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; }
.quick-actions button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 12px; text-align: left; cursor: pointer; }
.quick-actions button:hover { border-color: #94bfff; box-shadow: 0 5px 16px rgba(22,93,255,.1); }
.quick-actions svg { color: var(--blue); font-size: 20px; }
.quick-actions span, .compact-list span, .session-list span, .settings-card span { min-width: 0; display: grid; gap: 2px; }
.quick-actions small, .compact-list small, .session-list small, .settings-card small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.compact-list { overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: white; }
.compact-list > div { display: grid; grid-template-columns: 18px minmax(0,1fr); gap: 9px; align-items: center; padding: 10px 12px; border-bottom: 1px solid #f0f1f4; }
.compact-list > div:last-child { border-bottom: 0; }
.compact-list svg { color: var(--blue); }
.page-title { justify-content: space-between; gap: 10px; margin-bottom: 13px; }
.page-title h5 { margin: 0 0 2px; }
.page-title .arco-typography { font-size: 11px; }
.filter-strip { display: flex; gap: 6px; overflow-x: auto; margin: 10px 0 12px; padding-bottom: 2px; scrollbar-width: none; }
.filter-strip button { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 999px; background: white; color: var(--muted); padding: 4px 10px; cursor: pointer; font-size: 11px; }
.filter-strip button.active { border-color: #8eb7ff; background: #edf3ff; color: var(--blue); }
.sticky-list, .note-grid { display: grid; gap: 9px; }
.task-note { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; border: 1px solid #eadfac; border-radius: 4px 13px 13px 13px; background: #fffbea; padding: 13px 12px 12px 9px; box-shadow: 0 4px 14px rgba(78, 89, 105, .08); }
.task-note:nth-child(3n+2) { border-color: #cde5d7; background: #f0fff5; }
.task-note:nth-child(3n+3) { border-color: #d7def4; background: #f2f5ff; }
.task-note.completed { opacity: .64; }
.task-note.completed strong { text-decoration: line-through; }
.task-check { width: 27px; height: 27px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(255,255,255,.8); color: #8b9aab; cursor: pointer; }
.task-note:not(.completed) .task-check:hover, .task-note.completed .task-check { color: #00b42a; }
.task-note strong { font-size: 13px; }
.task-note p { margin: 5px 0; color: #4e5969; font-size: 11px; line-height: 1.5; }
.task-note span { color: var(--muted); font-size: 10px; }
.hidden-file { display: none; }
.note-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.source-note { min-height: 135px; display: flex; flex-direction: column; border: 1px solid #eadfac; border-radius: 4px 14px 14px 14px; background: #fff9d8; padding: 13px; }
.source-note.tone-1 { background: #eaf8ff; border-color: #c7e6f5; }.source-note.tone-2 { background: #f3edff; border-color: #ded0f7; }.source-note.tone-3 { background: #edfff3; border-color: #caead4; }
.source-note svg { color: #ff9a2e; font-size: 18px; }
.source-note strong { margin-top: 9px; font-size: 12px; }
.source-note p { flex: 1; margin: 5px 0; color: #4e5969; font-size: 10px; }
.source-note span { color: var(--muted); font-size: 9px; }
.ai-view > .arco-input-wrapper { border-radius: 10px; }
.expert-strip { max-height: 210px; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; overflow-y: auto; padding: 1px; }
.expert-strip button { min-width: 0; display: grid; grid-template-columns: 34px minmax(0,1fr); align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 8px; text-align: left; cursor: pointer; }
.expert-strip button.active { border-color: #75a8ff; background: #f2f6ff; box-shadow: 0 0 0 1px #c8dcff; }
.expert-strip i, .selected-expert i, .session-list i { display: grid; place-items: center; border-radius: 9px; color: white; font-style: normal; }
.expert-strip i { width: 34px; height: 34px; font-size: 17px; }
.expert-strip span { min-width: 0; display: grid; }
.expert-strip b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.expert-strip small { color: var(--muted); font-size: 9px; }
.mini-composer { display: grid; gap: 8px; margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; background: white; padding: 11px; box-shadow: 0 7px 22px rgba(29,33,41,.08); }
.selected-expert { min-height: 38px; gap: 9px; }
.selected-expert i { width: 36px; height: 36px; flex: 0 0 36px; }
.selected-expert span { min-width: 0; display: grid; }
.selected-expert b { font-size: 12px; }
.selected-expert small { overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.mini-composer .arco-textarea-wrapper { border-radius: 9px; background: #f6f7f9; }
.session-list { display: grid; gap: 7px; }
.session-list > div { gap: 9px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 9px; }
.session-list i { width: 31px; height: 31px; }
.settings-card { display: grid; gap: 10px; margin-bottom: 10px; border: 1px solid var(--line); border-radius: 13px; background: white; padding: 13px; }
.settings-card > strong { font-size: 13px; }
.settings-card > div { gap: 9px; border-top: 1px solid #f0f1f4; padding-top: 9px; }
.settings-card svg { color: var(--blue); }
.settings-card p { margin: 0; color: #4e5969; font-size: 11px; line-height: 1.65; }
.version-card { grid-template-columns: 34px minmax(0,1fr); }
.version-card img { width: 34px; }
.mini-login { width: 100%; height: 100%; display: grid; place-items: center; overflow: auto; padding: 22px; background: linear-gradient(160deg, #edf4ff 0%, #e6ebf2 52%, #dce4ef 100%); }
.login-note { width: min(360px, 100%); display: grid; justify-items: stretch; gap: 13px; border: 1px solid rgba(255,255,255,.85); border-radius: 4px 22px 22px 22px; background: #fffbea; padding: 26px 22px 20px; box-shadow: 0 24px 60px rgba(29,33,41,.16); }
.login-note > img { width: 52px; }
.login-note h3 { margin: 0; }
.login-note label, .mini-form label { display: grid; gap: 5px; color: #4e5969; font-size: 11px; }
.login-note .arco-input-wrapper, .mini-form .arco-input-wrapper { border-radius: 9px; background: rgba(255,255,255,.82); }
.login-note > small { color: var(--muted); text-align: center; }
.mini-connection { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 8px; margin-top: -5px; border: 1px solid #bedaff; border-radius: 9px; background: #f2f8ff; padding: 8px 10px; color: #165dff; font-size: 11px; }
.mini-connection > i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.mini-connection > strong { font-weight: 600; }
.mini-connection > button { border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; font-size: 11px; }
.mini-connection.online { border-color: #7be188; background: #f0fff4; color: #168a2f; }
.mini-connection.offline { border-color: #f53f3f; background: #fff2f0; color: #d91d35; }
.mini-modal .arco-modal { width: min(380px, calc(100vw - 24px)); }
.mini-modal .arco-modal-content { border-radius: 15px; }
.mini-form { display: grid; gap: 13px; }
.mini-form .arco-select { width: 100%; }
@media (max-width: 359px) {
.mini-brand .arco-tag { display: none; }
.note-grid, .expert-strip { grid-template-columns: 1fr; }
}

View File

@@ -21,5 +21,5 @@ func main() {
datasetScheduler := crontab.NewDatasetScheduler(datasetService, log.Default()) datasetScheduler := crontab.NewDatasetScheduler(datasetService, log.Default())
schedulerContext, stopScheduler := context.WithCancel(context.Background()) schedulerContext, stopScheduler := context.WithCancel(context.Background())
defer stopScheduler() defer stopScheduler()
go datasetScheduler.Run(schedulerContext) datasetScheduler.Run(schedulerContext)
} }