Compare commits
23 Commits
68f4149d34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d98f65ca8d | |||
| 076be30146 | |||
| 93b671c746 | |||
| 139e0d59ed | |||
| f67660f3e5 | |||
| 28e0ea10e5 | |||
| 562c6b1bdb | |||
| dbf5c33c99 | |||
| 0cbf6d1311 | |||
| 4ffd51a831 | |||
| ac674d71e7 | |||
| b01f14ac00 | |||
| 4b3f0d9167 | |||
| b2afa4edf8 | |||
| 7534fadfca | |||
| b2c22901ba | |||
| 563e0fe115 | |||
| 81ffe64446 | |||
| 1e1718caee | |||
| 8e15c06148 | |||
| 794ec74c3f | |||
| 10790b233e | |||
| 5cd779c22c |
@@ -1,20 +1,19 @@
|
||||
# 森林AI 桌面客户端
|
||||
# 森林AI App
|
||||
|
||||
桌面端提供两个独立可执行文件:
|
||||
|
||||
- `senlinai-mini.exe`:默认启动入口。窄屏、置顶,启动后按当前显示器高度停靠在屏幕右侧,也可以切换到左侧。
|
||||
- `senlinai-full.exe`:完整工作台客户端。
|
||||
森林AI唯一的桌面客户端,采用窄屏停靠布局,并在 Windows 上通过系统 AppBar 预留屏幕左侧或右侧工作区。
|
||||
|
||||
```bash
|
||||
# 默认启动 Mini
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 分别启动
|
||||
npm run mini:dev
|
||||
npm run full:dev
|
||||
|
||||
# 一次构建两个 EXE
|
||||
npm run build
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
两个构建产物会汇总到 `apps/desktop/release`。
|
||||
生产构建:
|
||||
|
||||
```bash
|
||||
npm run desktop:build
|
||||
```
|
||||
|
||||
- Web 开发端口:`5180`
|
||||
- 默认窗口宽度:`420px`,可调整
|
||||
- Windows 会为面板预留工作区;最大化的其他应用不会遮挡它
|
||||
|
||||
50
apps/desktop/design-qa.md
Normal 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
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
|
||||
<title>森林AI Mini</title>
|
||||
<title>森林AI Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
1810
apps/desktop/package-lock.json
generated
@@ -1,20 +1,32 @@
|
||||
{
|
||||
"name": "desktop",
|
||||
"name": "senlinai-app",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"visual-check": "node scripts/visual-check.mjs",
|
||||
"tauri": "tauri",
|
||||
"start": "npm run mini:dev",
|
||||
"dev": "npm run mini:dev",
|
||||
"mini:dev": "npm --prefix ../mini run desktop:dev",
|
||||
"full:dev": "tauri dev",
|
||||
"mini:build": "npm --prefix ../mini run desktop:build",
|
||||
"full:build": "tauri build --no-bundle",
|
||||
"build": "node scripts/build-all.mjs",
|
||||
"bundle": "tauri build --ci --no-sign"
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.16",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.3"
|
||||
"@tauri-apps/cli": "^2.9.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 731 B |
@@ -1,44 +0,0 @@
|
||||
import { copyFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const desktopDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const repoDir = join(desktopDir, '..', '..')
|
||||
const isWindows = process.platform === 'win32'
|
||||
const npmCommand = isWindows ? process.env.ComSpec || 'cmd.exe' : 'npm'
|
||||
|
||||
function run(script) {
|
||||
const args = isWindows ? ['/d', '/s', '/c', `npm run ${script}`] : ['run', script]
|
||||
const result = spawnSync(npmCommand, args, {
|
||||
cwd: desktopDir,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
run('full:build')
|
||||
run('mini:build')
|
||||
|
||||
const releaseDir = join(desktopDir, 'release')
|
||||
mkdirSync(releaseDir, { recursive: true })
|
||||
|
||||
const artifacts = [
|
||||
{
|
||||
source: join(desktopDir, 'src-tauri', 'target', 'release', 'senlinai-full.exe'),
|
||||
target: join(releaseDir, 'senlinai-full.exe'),
|
||||
},
|
||||
{
|
||||
source: join(repoDir, 'apps', 'mini', 'src-tauri', 'target', 'release', 'senlinai-mini.exe'),
|
||||
target: join(releaseDir, 'senlinai-mini.exe'),
|
||||
},
|
||||
]
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
copyFileSync(artifact.source, artifact.target)
|
||||
console.log(`Created ${artifact.target}`)
|
||||
}
|
||||
115
apps/desktop/scripts/visual-check.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createServer } from 'vite'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
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 } })
|
||||
await server.listen()
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 420, height: 820 }, deviceScaleFactor: 1 })
|
||||
const errors = []
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) })
|
||||
|
||||
const projectId = '019b0000-0000-7000-8000-000000000101'
|
||||
const user = { email: 'demo@senlin.ai', displayName: '演示用户' }
|
||||
const workspace = {
|
||||
project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '项目协作空间', initials: '森林', unreadCount: 0 },
|
||||
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' }],
|
||||
tasks: [
|
||||
{ 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: true, owner: '张明', due: null, createdAt: '2026-07-21T10:00:00Z', completedAt: '2026-07-22T10:00:00Z', tagId: 'tag-design', tag: '设计' },
|
||||
],
|
||||
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: '张明' }],
|
||||
}
|
||||
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) => {
|
||||
const request = route.request(); 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/auth/me') return route.fulfill({ json: user })
|
||||
if (path === '/api/v1/projects') return route.fulfill({ json: [workspace.project] })
|
||||
if (path === `/api/v1/projects/${projectId}/workspace`) return route.fulfill({ json: workspace })
|
||||
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/ai-experts') return route.fulfill({ json: [expert] })
|
||||
if (path === '/api/v1/dataset-sources') return route.fulfill({ json: [datasetSource] })
|
||||
if (path === '/api/v1/dataset-items') return route.fulfill({ json: { items: [datasetItem], total: 1, limit: 50, offset: 0 } })
|
||||
if (path.includes('/tasks/') && request.method() === 'PATCH') return route.fulfill({ 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.locator('.login-card input').nth(2).press('Enter')
|
||||
await page.waitForSelector('.app-shell')
|
||||
await page.waitForSelector('.task-row')
|
||||
await page.screenshot({ path: 'test-results/app-home.png', fullPage: true })
|
||||
const home = await page.evaluate(() => ({
|
||||
width: document.querySelector('.app-shell')?.getBoundingClientRect().width,
|
||||
selectedAll: document.querySelector('.project-current span')?.textContent,
|
||||
navCount: document.querySelectorAll('.channel-rail button').length,
|
||||
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.waitForSelector('.arco-modal')
|
||||
const taskModalWidth = (await page.locator('.arco-modal').boundingBox())?.width ?? 0
|
||||
const taskModal = await page.getByText('编辑任务').count()
|
||||
await page.getByRole('button', { name: '取消' }).click()
|
||||
await page.getByRole('button', { name: '探索' }).click()
|
||||
await page.waitForSelector('.explore-detail')
|
||||
const exploreSources = await page.locator('.explore-source-strip button').count()
|
||||
const exploreDetail = await page.locator('.explore-detail').count()
|
||||
await page.screenshot({ path: 'test-results/app-explore.png', fullPage: true })
|
||||
await page.locator('.project-current').click()
|
||||
await page.getByRole('option', { name: '森林项目' }).click()
|
||||
await page.waitForSelector('.channel-rail')
|
||||
await page.locator('.channel-rail button[title="资料"]').click()
|
||||
const documents = await page.locator('.detail-row').count()
|
||||
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 browser.close(); await server.close()
|
||||
|
||||
const failures = []
|
||||
if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
|
||||
if (home.selectedAll !== '全部项目') failures.push(`default project scope must be 全部项目, got ${home.selectedAll}`)
|
||||
if (home.navCount !== 0) failures.push(`all-projects view must hide the right rail, got ${home.navCount}`)
|
||||
if (home.statCount !== 4) failures.push(`expected four summary cards, got ${home.statCount}`)
|
||||
if (home.pending !== 1 || home.completed !== 0) failures.push(`all-project task sections incorrect: ${JSON.stringify(home)}`)
|
||||
if (taskModal !== 1) failures.push('task editor did not open')
|
||||
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 })}`)
|
||||
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'))
|
||||
670
apps/desktop/src-tauri/Cargo.lock
generated
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "senlinai-full"
|
||||
name = "senlinai-app"
|
||||
version = "1.0.0"
|
||||
description = "森林AI 完整版桌面客户端"
|
||||
description = "森林AI 迷你停靠客户端"
|
||||
authors = ["森林AI"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "senlinai_workbench_lib"
|
||||
name = "senlinai_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
@@ -14,6 +14,13 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
#[cfg(windows)]
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_appbar;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("启动森林AI时发生错误");
|
||||
.setup(|app| {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.expect("main window must be configured");
|
||||
windows_appbar::register(window.hwnd()?)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build SenlinAI App")
|
||||
.run(|_, event| {
|
||||
#[cfg(windows)]
|
||||
if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
|
||||
windows_appbar::unregister();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
senlinai_workbench_lib::run()
|
||||
senlinai_app_lib::run();
|
||||
}
|
||||
|
||||
162
apps/desktop/src-tauri/src/windows_appbar.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! Windows system AppBar integration for the App side panel.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
|
||||
|
||||
use windows::{
|
||||
core::{w, Error, Result},
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
|
||||
Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
},
|
||||
UI::{
|
||||
Shell::{
|
||||
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
||||
ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
||||
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
GetWindowRect, RegisterWindowMessageW, SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER,
|
||||
WM_ACTIVATE, WM_WINDOWPOSCHANGED,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SUBCLASS_ID: usize = 0x5341_4142; // "SAAB" (SenlinAI AppBar)
|
||||
|
||||
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
||||
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
||||
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Registers the main App window as a right-edge AppBar.
|
||||
pub fn register(hwnd: HWND) -> Result<()> {
|
||||
if REGISTERED_HWND.load(Ordering::Acquire) != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let callback_message = unsafe { RegisterWindowMessageW(w!("SenlinAI.App.AppBar.Callback")) };
|
||||
if callback_message == 0 {
|
||||
return Err(Error::from_win32());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
SetWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID, 0).ok()?;
|
||||
}
|
||||
|
||||
let mut data = appbar_data(hwnd);
|
||||
data.uCallbackMessage = callback_message;
|
||||
if unsafe { SHAppBarMessage(ABM_NEW, &mut data) } == 0 {
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID);
|
||||
}
|
||||
return Err(Error::from_win32());
|
||||
}
|
||||
|
||||
CALLBACK_MESSAGE.store(callback_message, Ordering::Release);
|
||||
REGISTERED_HWND.store(hwnd.0 as isize, Ordering::Release);
|
||||
|
||||
if let Err(error) = layout(hwnd) {
|
||||
unregister();
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
||||
pub fn unregister() {
|
||||
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
||||
CALLBACK_MESSAGE.store(0, Ordering::Release);
|
||||
|
||||
if raw_hwnd == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let hwnd = HWND(raw_hwnd as *mut _);
|
||||
let mut data = appbar_data(hwnd);
|
||||
unsafe {
|
||||
SHAppBarMessage(ABM_REMOVE, &mut data);
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn appbar_subclass_proc(
|
||||
hwnd: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_subclass_id: usize,
|
||||
_reference_data: usize,
|
||||
) -> LRESULT {
|
||||
let callback_message = CALLBACK_MESSAGE.load(Ordering::Acquire);
|
||||
|
||||
if message == callback_message && wparam.0 as u32 == ABN_POSCHANGED {
|
||||
let _ = layout(hwnd);
|
||||
} else if message == WM_ACTIVATE {
|
||||
let mut data = appbar_data(hwnd);
|
||||
SHAppBarMessage(ABM_ACTIVATE, &mut data);
|
||||
} else if message == WM_WINDOWPOSCHANGED {
|
||||
let mut data = appbar_data(hwnd);
|
||||
SHAppBarMessage(ABM_WINDOWPOSCHANGED, &mut data);
|
||||
|
||||
if !LAYOUT_IN_PROGRESS.load(Ordering::Acquire) {
|
||||
let _ = layout(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
DefSubclassProc(hwnd, message, wparam, lparam)
|
||||
}
|
||||
|
||||
fn layout(hwnd: HWND) -> Result<()> {
|
||||
if LAYOUT_IN_PROGRESS.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = layout_inner(hwnd);
|
||||
LAYOUT_IN_PROGRESS.store(false, Ordering::Release);
|
||||
result
|
||||
}
|
||||
|
||||
fn layout_inner(hwnd: HWND) -> Result<()> {
|
||||
let mut window_rect = RECT::default();
|
||||
unsafe { GetWindowRect(hwnd, &mut window_rect)? };
|
||||
let width = window_rect.right - window_rect.left;
|
||||
|
||||
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||
let mut monitor_info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
||||
|
||||
let mut data = appbar_data(hwnd);
|
||||
data.uEdge = ABE_RIGHT;
|
||||
data.rc = monitor_info.rcMonitor;
|
||||
data.rc.left = data.rc.right - width;
|
||||
|
||||
unsafe {
|
||||
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
||||
data.rc.left = data.rc.right - width;
|
||||
SHAppBarMessage(ABM_SETPOS, &mut data);
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
data.rc.left,
|
||||
data.rc.top,
|
||||
data.rc.right - data.rc.left,
|
||||
data.rc.bottom - data.rc.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn appbar_data(hwnd: HWND) -> APPBARDATA {
|
||||
APPBARDATA {
|
||||
cbSize: std::mem::size_of::<APPBARDATA>() as u32,
|
||||
hWnd: hwnd,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,31 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "森林AI Full",
|
||||
"productName": "森林AI App",
|
||||
"version": "1.0.0",
|
||||
"identifier": "ai.senlin.workbench",
|
||||
"identifier": "ai.senlin.client",
|
||||
"build": {
|
||||
"frontendDist": "../../web_v1/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd ../web_v1 && npm run dev",
|
||||
"beforeBuildCommand": "cd ../web_v1 && npm run build"
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5180",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "森林AI Full",
|
||||
"width": 1280,
|
||||
"height": 820
|
||||
"label": "main",
|
||||
"title": "森林AI App",
|
||||
"width": 420,
|
||||
"height": 820,
|
||||
"minWidth": 360,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"maximizable": false,
|
||||
"alwaysOnTop": true,
|
||||
"center": false
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
|
||||
316
apps/desktop/src/App.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
|
||||
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot,
|
||||
IconSun, IconUpload, IconUser,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
ApiError, createAISession, createMarkdownDocument, createProject, fetchDocumentBlob, fetchProjectWorkspace, fetchProjects, getCurrentUser, getDocument,
|
||||
listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateDocument, updateTask,
|
||||
uploadDocument,
|
||||
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type DocumentDetail, type Expert, type Project, type Workspace, type WorkspaceDocument, type WorkspaceTask,
|
||||
} from './api'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
const SESSION_KEY = 'senlin-app-session'
|
||||
type View = 'tasks' | 'documents' | 'ai' | 'cron'
|
||||
type WorkspaceMode = 'workbench' | 'explore'
|
||||
type SavedSession = ApiSession & { user?: CurrentUser }
|
||||
type TaskWithProject = WorkspaceTask & { projectName: string }
|
||||
|
||||
const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'tasks', label: '计划', icon: <IconList /> },
|
||||
{ id: 'documents', label: '资料', icon: <IconFile /> },
|
||||
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
|
||||
{ id: 'cron', label: '任务', icon: <IconClockCircle /> },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const stored = useMemo(readStoredSession, [])
|
||||
const [session, setSession] = useState<ApiSession | null>(stored)
|
||||
const [user, setUser] = useState<CurrentUser | null>(stored?.user ?? null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [workspaces, setWorkspaces] = useState<Record<string, Workspace>>({})
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('workbench')
|
||||
const [view, setView] = useState<View>('tasks')
|
||||
const [dark, setDark] = useState(false)
|
||||
const [loading, setLoading] = useState(Boolean(stored))
|
||||
const [error, setError] = useState('')
|
||||
const [taskEditor, setTaskEditor] = useState<TaskWithProject | null>(null)
|
||||
const [projectModalOpen, setProjectModalOpen] = useState(false)
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const workspaceRequestRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
if (!user) void getCurrentUser(session).then(setUser).catch((requestError) => setError(errorMessage(requestError)))
|
||||
void loadWorkspaces(session)
|
||||
}, [session])
|
||||
|
||||
async function loadWorkspaces(activeSession = session) {
|
||||
if (!activeSession) return
|
||||
const requestId = ++workspaceRequestRef.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const nextProjects = await fetchProjects(activeSession)
|
||||
const items = await Promise.all(nextProjects.map(async (project) => [project.id, await fetchProjectWorkspace(activeSession, project.id)] as const))
|
||||
if (requestId !== workspaceRequestRef.current) return
|
||||
setProjects(nextProjects)
|
||||
setWorkspaces(Object.fromEntries(items))
|
||||
setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '')
|
||||
} catch (requestError) {
|
||||
if (requestId === workspaceRequestRef.current) setError(errorMessage(requestError))
|
||||
} finally {
|
||||
if (requestId === workspaceRequestRef.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProjectWorkspace(targetProjectId: string, activeSession = session) {
|
||||
if (!activeSession || !targetProjectId) return
|
||||
const workspace = await fetchProjectWorkspace(activeSession, targetProjectId)
|
||||
setWorkspaces((current) => ({ ...current, [targetProjectId]: workspace }))
|
||||
}
|
||||
|
||||
async function loadAI() {
|
||||
if (!session || experts.length) return
|
||||
try { setExperts(await listAIExperts(session)) } catch (requestError) { setError(errorMessage(requestError)) }
|
||||
}
|
||||
|
||||
const visibleWorkspaces = projectId ? [workspaces[projectId]].filter(Boolean) : Object.values(workspaces)
|
||||
const activeProject = projectId ? projects.find((project) => project.id === projectId) : undefined
|
||||
const tasks = visibleWorkspaces.flatMap((workspace) => workspace.tasks.map((task) => ({ ...task, projectName: workspace.project.name })))
|
||||
const title = activeProject?.name ?? '全部项目'
|
||||
|
||||
function cycleProject(direction: number) {
|
||||
const index = projects.findIndex((project) => project.id === projectId)
|
||||
const next = projectId ? (index + direction + projects.length) % projects.length : direction > 0 ? 0 : projects.length - 1
|
||||
setProjectId(projects[next]?.id ?? '')
|
||||
setWorkspaceMode('workbench')
|
||||
setView('tasks')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
workspaceRequestRef.current += 1
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
localStorage.removeItem('senlin-mini-session')
|
||||
setSession(null); setUser(null); setProjects([]); setWorkspaces({}); setProjectId(''); setView('tasks')
|
||||
}
|
||||
|
||||
if (!session) return <Login onLogin={(next) => { workspaceRequestRef.current += 1; setProjects([]); setWorkspaces({}); setProjectId(''); setSession(next); setUser(next.user); persistSession(next) }} />
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${dark ? 'theme-dark' : ''} ${!projectId || workspaceMode === 'explore' ? 'no-rail' : ''}`}>
|
||||
<header className="app-header">
|
||||
<div className="app-brand" data-tauri-drag-region><img src="/senlinai-icon.svg" alt="" /><span>森林AI</span></div>
|
||||
<div className="header-actions">
|
||||
<Button type="text" shape="circle" aria-label={dark ? '切换浅色模式' : '切换深色模式'} icon={dark ? <IconSun /> : <IconMoon />} onClick={() => setDark((value) => !value)} />
|
||||
<ProfileMenu user={user} onProfile={() => setProfileModalOpen(true)} onLogout={() => logout()} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="project-strip" aria-label="项目列表">
|
||||
<button className={workspaceMode === 'workbench' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('workbench')}><IconApps /><span>工作台</span></button>
|
||||
<button className={workspaceMode === 'explore' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('explore')}><IconCompass /><span>探索</span></button>
|
||||
<Select className="project-select" value={projectId || 'all'} onChange={(value) => { setProjectId(value === 'all' ? '' : value); setWorkspaceMode('workbench'); setView('tasks') }} triggerElement={() => <button className={`project-pill project-current ${projectId ? 'active' : ''}`} aria-label={`切换项目,当前为${title}`}><i style={{ background: activeProject?.background || '#165DFF' }}>{activeProject ? projectLabel(activeProject) : <IconFolder />}</i><span>{title}</span><IconDown className="project-dropdown-icon" /></button>}>
|
||||
<Select.Option value="all">全部项目</Select.Option>
|
||||
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
|
||||
</Select>
|
||||
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
|
||||
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
|
||||
<Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
|
||||
</section>
|
||||
|
||||
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||||
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel session={session} projectName={activeProject?.name ?? ''} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} onUploadDocument={async (file) => { await uploadDocument(session, projectId, file); await refreshProjectWorkspace(projectId) }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await refreshProjectWorkspace(projectId) }} onLoadMarkdown={(documentId) => getDocument(session, projectId, documentId)} onUpdateMarkdown={async (input) => { await updateDocument(session, projectId, input.id, input); await refreshProjectWorkspace(projectId) }} onOpenExternalDocument={async (documentId) => { const preview = window.open('', '_blank'); try { const blob = await fetchDocumentBlob(session, projectId, documentId); const url = URL.createObjectURL(blob); if (preview) { preview.opener = null; preview.location.href = url } else { const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.click() } window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } catch (requestError) { preview?.close(); throw requestError } }} onCreateAISession={async (input) => { await createAISession(session, projectId, input); await refreshProjectWorkspace(projectId) }} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
|
||||
|
||||
{projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
|
||||
{navItems.map((item) => <button key={item.id} title={item.label} className={view === item.id ? 'active' : ''} onClick={() => { setView(item.id); if (item.id === 'ai') void loadAI() }}>{item.icon}<span>{item.label}</span></button>)}
|
||||
</aside> : null}
|
||||
|
||||
<TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
|
||||
if (!taskEditor) return
|
||||
await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed })
|
||||
await refreshProjectWorkspace(taskEditor.projectId)
|
||||
setTaskEditor(null)
|
||||
}} />
|
||||
<ProjectModal visible={projectModalOpen} onClose={() => setProjectModalOpen(false)} onSave={async (name) => {
|
||||
const project = await createProject(session, { name, identifier: slugify(name) || `project-${Date.now().toString(36)}`, icon: 'folder', background: '#165DFF', description: '' })
|
||||
setProjectModalOpen(false)
|
||||
await loadWorkspaces()
|
||||
setProjectId(project.id)
|
||||
}} />
|
||||
<ProfileModal user={user} visible={profileModalOpen} onClose={() => setProfileModalOpen(false)} onSave={async (input) => {
|
||||
const nextUser = await updateCurrentUser(session, input)
|
||||
setUser(nextUser)
|
||||
persistSession({ ...session, user: nextUser })
|
||||
setProfileModalOpen(false)
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts, onUploadDocument, onCreateMarkdown, onLoadMarkdown, onUpdateMarkdown, onOpenExternalDocument, onCreateAISession }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[]; onUploadDocument: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void>; onLoadMarkdown: (documentId: string) => Promise<DocumentDetail>; onUpdateMarkdown: (input: { id: string; name: string; markdown: string; revision: number }) => Promise<void>; onOpenExternalDocument: (documentId: string) => Promise<void>; onCreateAISession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
|
||||
let panel: React.ReactNode
|
||||
if (view === 'tasks') panel = <TaskBoard title="工作计划" subtitle="" tasks={tasks} onEditTask={onEditTask} />
|
||||
else if (view === 'documents') panel = <DocumentPanel workspaces={workspaces} onUpload={onUploadDocument} onCreateMarkdown={onCreateMarkdown} onLoadMarkdown={onLoadMarkdown} onUpdateMarkdown={onUpdateMarkdown} onOpenExternalDocument={onOpenExternalDocument} />
|
||||
else if (view === 'ai') panel = <AIPanel workspaces={workspaces} experts={experts} onLoad={onLoadAI} onCreateSession={onCreateAISession} />
|
||||
else panel = <CronPanel title="" workspaces={workspaces} />
|
||||
return <section className="project-content"><header className="project-page-header"><Title heading={3}>{title}</Title></header>{panel}</section>
|
||||
}
|
||||
|
||||
function TaskBoard({ title, subtitle, tasks, onEditTask, showCompleted = true }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; showCompleted?: boolean }) {
|
||||
const [tag, setTag] = useState('全部')
|
||||
const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))]
|
||||
const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag)
|
||||
const pending = visible.filter((task) => !task.completed)
|
||||
const completed = visible.filter((task) => task.completed)
|
||||
return <section className="content-page task-page">
|
||||
<div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div><Tag color="arcoblue">{pending.length} 项待办</Tag></div>
|
||||
<div className="tag-filter"><button className={tag === '全部' ? 'active' : ''} onClick={() => setTag('全部')}>全部</button>{tags.map((item) => <button key={item} className={tag === item ? 'active' : ''} onClick={() => setTag(item)}>{item}</button>)}</div>
|
||||
<section className="task-section"><div className="section-label"><strong>待办任务</strong><span>{pending.length}</span></div><div className="task-list">{pending.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!pending.length && <Empty description="当前没有待办任务" />}</div></section>
|
||||
{showCompleted ? <section className="task-section completed-section"><div className="section-label"><strong>已完成</strong><span>{completed.length}</span></div><div className="task-list">{completed.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!completed.length && <Empty description="尚未完成任何任务" />}</div></section> : null}
|
||||
</section>
|
||||
}
|
||||
|
||||
function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) {
|
||||
return <button className={`task-row ${task.completed ? 'done' : ''}`} onClick={onClick}><span className="task-check">{task.completed ? <IconCheckCircleFill /> : <IconCheckCircle />}</span><span className="task-copy"><strong>{task.title}</strong><small>{task.summary || '暂无任务说明'}</small></span><span className="task-meta"><Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || task.projectName}</Tag><small>{formatDate(task.completedAt || task.createdAt)}</small></span></button>
|
||||
}
|
||||
|
||||
|
||||
function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void }) {
|
||||
const pending = tasks.filter((task) => !task.completed)
|
||||
const latestFiles = workspaces.flatMap((workspace) => workspace.documents.map((document) => ({ ...document, projectName: workspace.project.name }))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
|
||||
const statItems = [
|
||||
{ label: '项目', value: projects.length, color: 'blue' }, { label: '待办', value: pending.length, color: 'orange' },
|
||||
{ label: '文件', value: latestFiles.length, color: 'green' }, { label: '计划任务', value: workspaces.reduce((total, workspace) => total + workspace.cronPlans.length, 0), color: 'purple' },
|
||||
]
|
||||
return <section className="content-page all-projects-home"><div className="content-heading"><div><Title heading={4}>全部项目</Title></div><Button type="primary" icon={<IconPlus />} onClick={onCreateProject}>创建项目</Button></div><div className="stat-grid">{statItems.map((item) => <div className={`stat-card ${item.color}`} key={item.label}><strong>{item.value}</strong><span>{item.label}</span></div>)}</div><TaskBoard title="待办任务" subtitle="" tasks={tasks} onEditTask={onEditTask} showCompleted={false} /><section className="latest-files"><div className="section-label"><strong>最新文件</strong><span>{latestFiles.length}</span></div><div className="detail-list">{latestFiles.map((file) => <article className="detail-row" key={file.id}><IconFile /><div><strong>{file.name}</strong><p>{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}</p></div><Tag>{file.projectName}</Tag></article>)}{!latestFiles.length && <Empty description="暂无文件" />}</div></section></section>
|
||||
}
|
||||
|
||||
function ExplorePanel({ session, projectName }: { session: ApiSession; projectName: string }) {
|
||||
const [sources, setSources] = useState<DatasetSource[]>([])
|
||||
const [items, setItems] = useState<DatasetItem[]>([])
|
||||
const [sourceId, setSourceId] = useState('all')
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => { void refreshSources() }, [session])
|
||||
useEffect(() => { void refreshItems() }, [session, sourceId])
|
||||
|
||||
async function refreshSources() {
|
||||
setLoading(true); setError('')
|
||||
try { setSources(await listDatasetSources(session)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
|
||||
}
|
||||
async function refreshItems() {
|
||||
setLoading(true); setError('')
|
||||
try { const page = await listDatasetItems(session, sourceId === 'all' ? undefined : sourceId); setItems(page.items); setSelectedId((current) => page.items.some((item) => item.id === current) ? current : page.items[0]?.id ?? '') } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const allCount = sources.reduce((total, source) => total + source.itemCount, 0)
|
||||
const selected = items.find((item) => item.id === selectedId) ?? items[0]
|
||||
const selectedSource = sources.find((source) => source.id === selected?.sourceId)
|
||||
return <section className="content-page explore-page"><div className="content-heading"><div><Title heading={4}>探索</Title><Text type="secondary">集中阅读数据源内容,筛选后沉淀到项目资料。</Text></div><Button type="text" shape="circle" aria-label="刷新探索内容" loading={loading} icon={<IconRefresh />} onClick={() => { void refreshSources(); void refreshItems() }} /></div>{error ? <Alert type="error" content={error} /> : null}<div className="explore-source-strip"><button className={sourceId === 'all' ? 'active' : ''} onClick={() => setSourceId('all')}><IconCompass /><span>全部</span><small>{allCount}</small></button>{sources.map((source) => <button className={sourceId === source.id ? 'active' : ''} key={source.id} onClick={() => setSourceId(source.id)}>{source.iconUrl ? <img src={source.iconUrl} alt="" /> : <IconBook />}<span>{source.name}</span><small>{source.itemCount}</small></button>)}</div><div className="explore-reader">{items.length ? <><div className="explore-item-list">{items.map((item) => <button className={item.id === selected?.id ? 'active' : ''} key={item.id} onClick={() => setSelectedId(item.id)}><span><b>{item.title}</b><small>{item.summary || '暂无摘要'}</small></span><i>{item.starred ? '★' : formatDate(item.publishedAt || item.createdAt)}</i></button>)}</div><article className="explore-detail"><div className="explore-detail-meta"><Tag color="arcoblue">{selectedSource?.name || '全部'}</Tag><small>{formatDate(selected?.publishedAt || selected?.createdAt || '')}</small></div><Title heading={5}>{selected?.title}</Title>{selected?.imageUrl ? <img src={selected.imageUrl} alt="" /> : null}<p>{selected?.content || selected?.summary || '暂无正文'}</p><div className="explore-detail-actions"><Button type="text" icon={<IconBook />} disabled={!projectName}>沉淀到{projectName || '项目'}</Button>{selected?.url ? <Button type="text" href={selected.url} target="_blank">打开原文</Button> : null}</div></article></> : <Empty description="暂无探索内容,请先在 Web 工作台添加或同步数据源" />}</div></section>
|
||||
}
|
||||
|
||||
function DocumentPanel({ workspaces, onUpload, onCreateMarkdown, onLoadMarkdown, onUpdateMarkdown, onOpenExternalDocument }: { workspaces: Workspace[]; onUpload: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void>; onLoadMarkdown: (documentId: string) => Promise<DocumentDetail>; onUpdateMarkdown: (input: { id: string; name: string; markdown: string; revision: number }) => Promise<void>; onOpenExternalDocument: (documentId: string) => Promise<void> }) {
|
||||
const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<DocumentDetail | null>(null)
|
||||
const [name, setName] = useState('未命名笔记.md')
|
||||
const [markdown, setMarkdown] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function saveMarkdown() {
|
||||
if (!name.trim()) return
|
||||
setSaving(true); setError('')
|
||||
try { if (editing) await onUpdateMarkdown({ id: editing.id, name: name.trim(), markdown, revision: editing.revision }); else await onCreateMarkdown({ name: name.trim(), markdown }); setCreating(false); setEditing(null); setName('未命名笔记.md'); setMarkdown('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function uploadFiles(files: FileList | null) {
|
||||
if (!files?.length) return
|
||||
setUploading(true); setError('')
|
||||
try { for (const file of Array.from(files)) await onUpload(file) } catch (requestError) { setError(errorMessage(requestError)) } finally { setUploading(false); if (inputRef.current) inputRef.current.value = '' }
|
||||
}
|
||||
|
||||
async function openDocument(document: WorkspaceDocument) {
|
||||
setError('')
|
||||
try {
|
||||
if (document.extension.toLowerCase() === '.md' || document.mimeType.includes('markdown')) { const detail = await onLoadMarkdown(document.id); setEditing(detail); setName(detail.name); setMarkdown(detail.markdown ?? '') }
|
||||
else await onOpenExternalDocument(document.id)
|
||||
} catch (requestError) { setError(errorMessage(requestError)) }
|
||||
}
|
||||
|
||||
if (creating || editing) return <section className="content-page markdown-editor-page"><div className="content-heading"><div><Text type="secondary">项目资料</Text><Title heading={4}>{editing ? '编辑 Markdown' : '新建 Markdown'}</Title></div><Button type="text" onClick={() => { setCreating(false); setEditing(null); setError('') }}>返回资料</Button></div>{error ? <Alert type="error" content={error} /> : null}<Form layout="vertical"><Form.Item label="文件名称" required><Input value={name} onChange={setName} placeholder="例如:会议记录.md" /></Form.Item><Form.Item label="Markdown 内容"><Input.TextArea value={markdown} onChange={setMarkdown} placeholder={'# 标题\n\n开始记录…'} autoSize={{ minRows: 14, maxRows: 24 }} /></Form.Item><div className="markdown-editor-actions"><Button onClick={() => { setCreating(false); setEditing(null) }}>取消</Button><Button type="primary" loading={saving} onClick={() => { void saveMarkdown() }}>{editing ? '保存修改' : '创建文件'}</Button></div></Form></section>
|
||||
|
||||
return <section className="content-page documents-page"><div className="content-heading"><div><Title heading={4}>笔记资料</Title></div><div className="document-actions"><input ref={inputRef} className="file-picker" type="file" multiple onChange={(event) => { void uploadFiles(event.target.files) }} /><Button icon={<IconUpload />} loading={uploading} onClick={() => inputRef.current?.click()}>上传文件</Button><Button type="primary" icon={<IconPlus />} onClick={() => setCreating(true)}>新建 Markdown</Button></div></div>{error ? <Alert type="error" content={error} /> : null}<div className="detail-list">{documents.map((item) => <button className="detail-row document-row" key={item.id} onClick={() => { void openDocument(item) }}><IconFile /><div><strong>{item.name}</strong><p>{item.extension || item.mimeType || item.kind} · 更新于 {formatDate(item.updatedAt)}</p></div><Tag>{item.extension.toLowerCase() === '.md' ? '编辑' : '浏览器打开'}</Tag></button>)}{!documents.length && <Empty description="暂无笔记或资料" />}</div></section>
|
||||
}
|
||||
|
||||
function AIPanel({ workspaces, experts, onLoad, onCreateSession }: { workspaces: Workspace[]; experts: Expert[]; onLoad: () => void; onCreateSession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
|
||||
useEffect(() => { onLoad() }, [onLoad])
|
||||
const [page, setPage] = useState<'new' | 'history'>('new')
|
||||
const [title, setTitle] = useState('')
|
||||
const [context, setContext] = useState('')
|
||||
const [expertId, setExpertId] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const sessions = workspaces.flatMap((workspace) => workspace.aiSessions.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
useEffect(() => { setExpertId((current) => experts.some((expert) => expert.id === current) ? current : experts[0]?.id ?? '') }, [experts])
|
||||
|
||||
async function createSession() {
|
||||
setSaving(true); setError('')
|
||||
try { await onCreateSession({ title: title.trim() || '新会话', context: context.trim(), expertId: expertId || undefined }); setTitle(''); setContext(''); setPage('history') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return <section className="content-page ai-session-page"><div className="content-heading"><div><Title heading={4}>{page === 'new' ? '新建会话' : '历史会话'}</Title></div><div className="ai-page-switch"><Button type={page === 'new' ? 'primary' : 'text'} icon={<IconPlus />} onClick={() => setPage('new')}>新建会话</Button><Button type={page === 'history' ? 'primary' : 'text'} icon={<IconClockCircle />} onClick={() => setPage('history')}>历史会话</Button></div></div>{error ? <Alert type="error" content={error} /> : null}{page === 'new' ? <Form className="ai-session-form" layout="vertical"><Form.Item label="会话标题"><Input value={title} onChange={setTitle} placeholder="例如:梳理本周项目计划" /></Form.Item><Form.Item label="选择专家"><div className="expert-chips">{experts.map((expert) => <button className={expert.id === expertId ? 'active' : ''} key={expert.id} type="button" onClick={() => setExpertId(expert.id)}><i style={{ background: expert.color }}>{expert.emoji}</i>{expert.name}</button>)}{!experts.length && <Text type="secondary">暂无可用专家</Text>}</div></Form.Item><Form.Item label="上下文或问题"><Input.TextArea value={context} onChange={setContext} placeholder="描述你希望 AI 协助完成的内容…" autoSize={{ minRows: 8, maxRows: 16 }} /></Form.Item><div className="ai-session-actions"><Button type="primary" loading={saving} icon={<IconRobot />} onClick={() => { void createSession() }}>创建会话</Button></div></Form> : <div className="ai-history-page detail-list">{sessions.map((item) => <article className="detail-row" key={item.id}><IconRobot /><div><strong>{item.title}</strong><p>{item.summary || 'AI 会话'} · {formatDate(item.updatedAt)}</p></div><Tag>{item.projectName}</Tag></article>)}{!sessions.length && <Empty description="暂无历史会话,先新建一个会话吧" />}</div>}</section>
|
||||
}
|
||||
|
||||
function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
|
||||
const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
return <Panel title="计划任务" subtitle={title}>{plans.map((item) => <article className="detail-row" key={item.id}><IconClockCircle /><div><strong>{item.title}</strong><p>{item.schedule} · {item.enabled ? '已启用' : '已停用'}</p></div><Tag>{item.projectName}</Tag></article>)}{!plans.length && <Empty description="暂无计划任务" />}</Panel>
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <section className="content-page"><div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div></div><div className="detail-list">{children}</div></section> }
|
||||
|
||||
function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) {
|
||||
const menu = <Menu><Menu.Item key="profile" onClick={onProfile}>修改资料</Menu.Item><Menu.Item key="logout" onClick={onLogout}>退出登录</Menu.Item></Menu>
|
||||
return <Dropdown droplist={menu} position="br" trigger="click"><button className="profile-trigger" aria-label={`用户菜单,${user?.displayName || '用户'}`}><Avatar size={28}>{user?.displayName.slice(0, 1) || <IconUser />}</Avatar><span>{user?.displayName || '用户'}</span><IconDown /></button></Dropdown>
|
||||
}
|
||||
|
||||
function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise<void> }) {
|
||||
const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||||
useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed); setError('') } }, [task])
|
||||
return <Modal title="编辑任务" visible={Boolean(task)} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!title.trim()) return; setSaving(true); setError(''); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="标题" required><Input value={title} onChange={setTitle} /></Form.Item><Form.Item label="说明"><Input.TextArea value={summary} onChange={setSummary} autoSize={{ minRows: 3, maxRows: 6 }} /></Form.Item><Form.Item label="标签"><Select value={tag || undefined} allowClear onChange={(value) => setTag(value ?? '')}>{[...new Set(tags.filter(Boolean))].map((item) => <Select.Option key={item} value={item}>{item}</Select.Option>)}</Select></Form.Item><Form.Item label="完成状态"><Switch checked={completed} checkedText="已完成" uncheckedText="待办" onChange={completed => setCompleted(completed)} /></Form.Item></Form></Modal>
|
||||
}
|
||||
|
||||
function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
|
||||
const [name, setName] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||||
useEffect(() => { if (visible) setError('') }, [visible])
|
||||
return <Modal title="新建项目" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!name.trim()) return; setSaving(true); setError(''); try { await onSave(name.trim()); setName('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}>{error ? <Alert type="error" content={error} /> : null}<Input autoFocus placeholder="项目名称" value={name} onChange={setName} /></Modal>
|
||||
}
|
||||
|
||||
function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void> }) {
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||||
useEffect(() => { setDisplayName(user?.displayName ?? ''); if (visible) setError('') }, [user, visible])
|
||||
return <Modal title="修改资料" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!displayName.trim()) return; setSaving(true); setError(''); try { await onSave({ displayName: displayName.trim(), currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="名称" required><Input value={displayName} onChange={setDisplayName} /></Form.Item><Form.Item label="邮箱"><Input disabled value={user?.email} /></Form.Item><Form.Item label="当前密码"><Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" /></Form.Item><Form.Item label="新密码"><Input.Password value={newPassword} onChange={setNewPassword} placeholder="不修改可留空" /></Form.Item></Form></Modal>
|
||||
}
|
||||
|
||||
function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) {
|
||||
const [server, setServer] = useState('http://agent.apinb.com'); const [email, setEmail] = useState('root'); const [password, setPassword] = useState('123456'); const [loading, setLoading] = useState(false); const [error, setError] = useState('')
|
||||
return <div className="login-page"><form className="login-card" onSubmit={(event) => { event.preventDefault(); void submit() }}><Title heading={3} align="center"><img src="/senlinai-icon.svg" alt="" width="50%" /> <br/> 森林AI Agent</Title><Text type="secondary">连接你的私有工作台</Text><label>服务器地址<Input value={server} onChange={setServer} /></label><label>邮箱<Input value={email} onChange={setEmail} /></label><label>密码<Input.Password value={password} onChange={setPassword} /></label>{error && <Alert type="error" content={error} />}<Button type="primary" htmlType="submit" long loading={loading}>登录工作台</Button></form></div>
|
||||
async function submit() { setLoading(true); setError(''); try { onLogin(await login(server, email.trim(), password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } }
|
||||
}
|
||||
|
||||
function readStoredSession(): SavedSession | null { try { const value = JSON.parse(localStorage.getItem(SESSION_KEY) || localStorage.getItem('senlin-mini-session') || 'null') as SavedSession | null; if (!value?.baseUrl || !value.token) return null; setApiBaseUrl(value.baseUrl); return value } catch { return null } }
|
||||
function persistSession(session: SavedSession) { localStorage.setItem(SESSION_KEY, JSON.stringify(session)) }
|
||||
function projectLabel(project: Project) { if (project.icon && !['folder', 'project'].includes(project.icon)) return Array.from(project.icon).slice(0, 2).join(''); return Array.from(project.name.trim()).slice(0, 2).join('').toUpperCase() || <IconFolder /> }
|
||||
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `${date.getMonth() + 1}/${date.getDate()}` }
|
||||
function slugify(value: string) { return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') }
|
||||
function errorMessage(error: unknown) { return error instanceof ApiError || error instanceof Error ? error.message : '操作失败,请稍后重试' }
|
||||
213
apps/desktop/src/api.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
export type ApiSession = { baseUrl: string; token: string }
|
||||
|
||||
export type CurrentUser = { email: string; displayName: string }
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: 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 = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
summary: string
|
||||
completed: boolean
|
||||
owner: string
|
||||
due: string | null
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
tagId: string | null
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type WorkspaceDocument = { id: string; projectId: string; kind: string; name: string; extension: string; mimeType: string; updatedAt: string }
|
||||
export type DocumentDetail = WorkspaceDocument & { revision: number; markdown?: string; hasBlob: boolean; size: number; createdAt: string }
|
||||
export type WorkspaceAISession = { id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }
|
||||
export type WorkspaceInbox = { id: string; projectId: string; source: string; title: string; summary: string; status: string; tag: string; time: string }
|
||||
export type WorkspaceCronPlan = { id: string; projectId: string; title: string; schedule: string; nextRun: string | null; enabled: boolean; lastResult: string; owner: string }
|
||||
|
||||
export type Workspace = {
|
||||
project: Project & { initials: string; unreadCount: number }
|
||||
channels: WorkspaceChannel[]
|
||||
tags: Array<{ id: string; name: string }>
|
||||
recentSessions: WorkspaceAISession[]
|
||||
inbox: WorkspaceInbox[]
|
||||
tasks: WorkspaceTask[]
|
||||
aiSessions: WorkspaceAISession[]
|
||||
documents: WorkspaceDocument[]
|
||||
cronPlans: WorkspaceCronPlan[]
|
||||
}
|
||||
|
||||
export type Expert = { id: string; slug: string; category: string; categoryName: string; name: string; description: string; 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 type CreateTaskInput = { title: string; description?: string; status?: string; dueAt?: string; tag?: string }
|
||||
export type UpdateTaskInput = { title: string; description?: string; status?: string; completed: boolean; nextProjectId?: string; tag?: 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 }
|
||||
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 }
|
||||
export type DatasetItemPage = { items: DatasetItem[]; total: number; limit: number; offset: number }
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
|
||||
|
||||
export function setApiBaseUrl(value: string) { 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 function getCurrentUser(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<CurrentUser>('/api/v1/auth/me', { token: session.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) {
|
||||
try {
|
||||
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = { method?: string; token?: string; body?: unknown; responseType?: 'void'; signal?: AbortSignal }
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: { ...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) },
|
||||
body: options.body === undefined ? undefined : isFormData ? options.body as FormData : JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null
|
||||
throw new ApiError(response.status, payload?.error?.code ?? fallbackCode(response.status), payload?.error?.message ?? fallbackMessage(response.status))
|
||||
}
|
||||
if (response.status === 204 || options.responseType === 'void') return undefined as 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) { 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 ? '服务器暂时无法处理请求,请稍后重试' : '请求失败,请稍后重试' }
|
||||
38
apps/desktop/src/main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
type ErrorBoundaryState = { error: Error | null }
|
||||
|
||||
class AppErrorBoundary extends React.Component<React.PropsWithChildren, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: 24, color: '#1d2129', background: '#f2f3f5' }}>
|
||||
<h2>页面加载失败</h2>
|
||||
<p>请重新登录后再试;如果问题仍出现,请将此信息反馈给管理员。</p>
|
||||
<p style={{ color: '#86909c', wordBreak: 'break-word' }}>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => { localStorage.removeItem('senlin-mini-session'); window.location.reload() }}>退出并重新登录</button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider>
|
||||
<AppErrorBoundary><App /></AppErrorBoundary>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
97
apps/desktop/src/styles.css
Normal file
@@ -0,0 +1,97 @@
|
||||
:root { font-family: Inter, "Microsoft YaHei", sans-serif; color: #1d2129; background: #f4f5f7; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; }
|
||||
button { font: inherit; }
|
||||
.arco-modal-wrapper { padding: 12px; }
|
||||
.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); }
|
||||
|
||||
.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; }
|
||||
.app-shell.theme-dark { --canvas: #171a1f; --surface: #23272e; --line: #3a3f47; --text: #f2f3f5; --muted: #a9b0bb; --rail: #2b3038; color-scheme: dark; }
|
||||
.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); }
|
||||
.app-brand { display: flex; align-items: center; gap: 7px; min-width: 0; height: 100%; font-weight: 700; letter-spacing: .1px; }
|
||||
.app-brand img { width: 23px; height: 23px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 5px; }
|
||||
.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; }
|
||||
.profile-trigger:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
|
||||
.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; }
|
||||
|
||||
.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-scroller { display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; flex: 1; }
|
||||
.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; }
|
||||
.project-pill:hover, .project-pill.active { color: #165dff; background: #e8f3ff; border-color: #d3e7ff; }
|
||||
.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; }
|
||||
|
||||
.app-alert { grid-column: 1 / 2; z-index: 2; margin: 8px 12px -46px; align-self: start; }
|
||||
.app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; }
|
||||
.app-shell.no-rail .app-main { grid-column: 1 / -1; }
|
||||
.app-main > .arco-spin { min-height: 100%; }
|
||||
.content-page { width: 100%; max-width: 650px; min-width: 0; margin: 0 auto; }
|
||||
.project-content { min-width: 0; }
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.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; }
|
||||
.channel-rail button:hover { color: #165dff; background: #e8f3ff; }
|
||||
.channel-rail button.active { color: #165dff; background: #dbeeff; box-shadow: inset 0 0 0 1px #c9e2ff; }
|
||||
.channel-rail svg { font-size: 20px; }
|
||||
.channel-rail span { font-size: 9px; line-height: 1; }
|
||||
|
||||
.detail-list { display: grid; gap: 8px; }
|
||||
.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); }
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@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); } }
|
||||
@@ -1,13 +0,0 @@
|
||||
# 森林AI Mini
|
||||
|
||||
面向屏幕左右侧停靠场景的窄屏客户端。它使用与 `web_v1` 相同的后端 API,采用顶部导航和单列内容布局。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
- Web 开发端口:`5180`
|
||||
- 桌面窗口默认宽度:`420`;启动后高度自动适配当前屏幕可用工作区,不遮挡 Windows 任务栏,并停靠在屏幕右侧
|
||||
- 左右停靠状态保存在本地;在 Tauri 桌面环境中会移动实际窗口。
|
||||
2047
apps/mini/package-lock.json
generated
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "senlinai-mini",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"visual-check": "node scripts/visual-check.mjs",
|
||||
"tauri": "tauri",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.16",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { createServer } from 'vite'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
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 } })
|
||||
await server.listen()
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 420, height: 820 }, deviceScaleFactor: 1 })
|
||||
const errors = []
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) })
|
||||
|
||||
const projectId = '019b0000-0000-7000-8000-000000000101'
|
||||
const expert = {
|
||||
id: '019b0000-0000-7000-8000-000000000102', slug: 'product-manager', category: 'product', categoryName: '产品',
|
||||
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
|
||||
}
|
||||
const workspace = {
|
||||
project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '让项目计划、资料和 AI 协作保持在同一条线上。', initials: '森林', unreadCount: 0 },
|
||||
channels: [],
|
||||
tags: [{ id: 'tag-product', name: '产品' }, { id: 'tag-design', name: '设计' }],
|
||||
recentSessions: [],
|
||||
inbox: [],
|
||||
tasks: [
|
||||
{ id: 'task-1', projectId, title: '整理产品路线', summary: '确认下一阶段交付范围。', completed: false, owner: '张明', due: null, createdAt: '今天 09:00', 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: '设计' },
|
||||
],
|
||||
aiSessions: [{ id: 'old-session', projectId, title: '梳理版本计划', summary: '项目会话', updatedAt: '今天', references: [] }],
|
||||
notesSources: [
|
||||
{ id: 'note-1', projectId, kind: 'note', title: '版本规划', updatedAt: '今天', tag: '产品', source: '项目笔记' },
|
||||
{ id: 'source-1', projectId, kind: 'file', title: '需求说明.pdf', updatedAt: '昨天', tag: '资料', source: '上传文件' },
|
||||
],
|
||||
cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }],
|
||||
}
|
||||
|
||||
await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === '/api/v1/status') return route.fulfill({ json: { status: 'unavailable' } })
|
||||
if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'mini-token' } })
|
||||
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/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}/ai-sessions`) {
|
||||
if (request.method() === 'POST') {
|
||||
const input = request.postDataJSON()
|
||||
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.endsWith('/tasks') && request.method() === 'POST') return route.fulfill({ status: 201, json: {} })
|
||||
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.waitForSelector('.mini-connection.offline')
|
||||
const offlineConnection = await page.locator('.mini-connection').evaluate((element) => ({
|
||||
text: element.textContent,
|
||||
borderColor: getComputedStyle(element).borderColor,
|
||||
}))
|
||||
await page.screenshot({ path: 'test-results/mini-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/mini-home-right.png', fullPage: true })
|
||||
|
||||
const home = await page.evaluate(() => ({
|
||||
width: document.querySelector('.mini-shell')?.getBoundingClientRect().width,
|
||||
navCount: document.querySelectorAll('.mini-nav button').length,
|
||||
dock: document.documentElement.dataset.dock,
|
||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
title: document.querySelector('.page-intro h4')?.textContent ?? '',
|
||||
}))
|
||||
|
||||
await page.getByRole('button', { name: '停靠左侧' }).click()
|
||||
const leftDock = await page.evaluate(() => document.documentElement.dataset.dock)
|
||||
|
||||
await page.getByRole('button', { name: '计划', exact: true }).click()
|
||||
await page.getByRole('button', { name: '设计', exact: true }).click()
|
||||
const tasks = await page.evaluate(() => ({
|
||||
count: document.querySelectorAll('.task-note').length,
|
||||
title: document.querySelector('.task-note strong')?.textContent ?? '',
|
||||
}))
|
||||
await page.screenshot({ path: 'test-results/mini-tasks.png', fullPage: true })
|
||||
|
||||
await page.getByRole('button', { name: '资料', exact: true }).click()
|
||||
const notes = await page.locator('.source-note').count()
|
||||
|
||||
await page.getByRole('button', { name: 'AI', exact: true }).click()
|
||||
await page.getByRole('button', { name: /产品经理/ }).click()
|
||||
await page.locator('.mini-composer textarea').fill('请给出下一步计划')
|
||||
const sendEnabled = await page.getByRole('button', { name: '开始会话' }).isEnabled()
|
||||
await page.screenshot({ path: 'test-results/mini-ai.png', fullPage: true })
|
||||
|
||||
await browser.close()
|
||||
await server.close()
|
||||
|
||||
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.navCount !== 5) failures.push(`expected five top navigation items, got ${home.navCount}`)
|
||||
if (home.dock !== 'right' || leftDock !== 'left') failures.push(`dock state failed: right=${home.dock}, left=${leftDock}`)
|
||||
if (home.overflowX) failures.push('mini client has horizontal overflow')
|
||||
if (home.title !== '森林项目') failures.push(`project overview missing, got ${home.title}`)
|
||||
if (tasks.count !== 1 || tasks.title !== '检查迷你布局') failures.push(`task tag filtering failed: ${JSON.stringify(tasks)}`)
|
||||
if (notes !== 2) failures.push(`expected two note cards, got ${notes}`)
|
||||
if (!sendEnabled) failures.push('AI composer must enable after selecting an expert and entering a prompt')
|
||||
|
||||
console.log(JSON.stringify({ home, leftDock, tasks, notes, sendEnabled, errors }, null, 2))
|
||||
if (failures.length) throw new Error(failures.join('\n'))
|
||||
4394
apps/mini/src-tauri/Cargo.lock
generated
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "senlinai-mini"
|
||||
version = "1.0.0"
|
||||
description = "森林AI 迷你停靠客户端"
|
||||
authors = ["森林AI"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "senlinai_mini_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,36 +0,0 @@
|
||||
use tauri::{PhysicalPosition, PhysicalSize, Window};
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_window(window: Window, side: String) -> Result<(), String> {
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "无法识别当前屏幕".to_string())?;
|
||||
let window_size = window.outer_size().map_err(|error| error.to_string())?;
|
||||
let work_area = monitor.work_area();
|
||||
let dock_size = PhysicalSize::new(window_size.width, work_area.size.height);
|
||||
let x = if side == "left" {
|
||||
work_area.position.x
|
||||
} else {
|
||||
work_area.position.x + work_area.size.width as i32 - dock_size.width as i32
|
||||
};
|
||||
|
||||
window
|
||||
.set_size(dock_size)
|
||||
.map_err(|error| error.to_string())?;
|
||||
window
|
||||
.set_position(PhysicalPosition::new(x, work_area.position.y))
|
||||
.map_err(|error| error.to_string())?;
|
||||
window
|
||||
.set_always_on_top(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![dock_window])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run 森林AI Mini");
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
senlinai_mini_lib::run();
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "森林AI Mini",
|
||||
"version": "1.0.0",
|
||||
"identifier": "ai.senlin.mini",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5180",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "森林AI Mini",
|
||||
"width": 420,
|
||||
"height": 820,
|
||||
"minWidth": 360,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"alwaysOnTop": true,
|
||||
"center": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http: https:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconApps, IconArrowLeft, IconArrowRight, IconCheckCircle, IconClockCircle,
|
||||
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
ApiError, checkServerConnection, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts,
|
||||
fetchProjects, fetchWorkspace, login, updateTask, uploadSource,
|
||||
type AISession, type ApiSession, type Expert, type Project, type Workspace,
|
||||
} from './api'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more'
|
||||
type DockSide = 'left' | 'right'
|
||||
type Action = 'project' | 'task' | 'cron' | null
|
||||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||
|
||||
const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'home', label: '项目', icon: <IconApps /> },
|
||||
{ id: 'tasks', label: '计划', icon: <IconCheckCircle /> },
|
||||
{ id: 'notes', label: '资料', icon: <IconFile /> },
|
||||
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
|
||||
{ id: 'more', label: '更多', icon: <IconSettings /> },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const [session, setSession] = useState<ApiSession | null>(() => readStoredSession())
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null)
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const [aiSessions, setAISessions] = useState<AISession[]>([])
|
||||
const [view, setView] = useState<View>('home')
|
||||
const [dockSide, setDockSide] = useState<DockSide>(() => {
|
||||
if ('__TAURI_INTERNALS__' in window) return 'right'
|
||||
return localStorage.getItem('senlin-mini-dock') === 'left' ? 'left' : 'right'
|
||||
})
|
||||
const [loading, setLoading] = useState(Boolean(session))
|
||||
const [error, setError] = useState('')
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const refreshGeneration = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.dock = dockSide
|
||||
localStorage.setItem('senlin-mini-dock', dockSide)
|
||||
void moveDesktopWindow(dockSide)
|
||||
}, [dockSide])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
void fetchProjects(session)
|
||||
.then((items) => {
|
||||
if (cancelled) return
|
||||
setProjects(items)
|
||||
setProjectId((current) => items.some((item) => item.id === current) ? current : items[0]?.id ?? '')
|
||||
})
|
||||
.catch((requestError) => !cancelled && setError(errorMessage(requestError)))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !projectId) return
|
||||
void refreshProject(session, projectId)
|
||||
}, [projectId, session])
|
||||
|
||||
async function refreshProject(activeSession = session, activeProject = projectId) {
|
||||
if (!activeSession || !activeProject) return
|
||||
const generation = ++refreshGeneration.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [nextWorkspace, nextExperts, nextSessions] = await Promise.all([
|
||||
fetchWorkspace(activeSession, activeProject),
|
||||
experts.length ? Promise.resolve(experts) : fetchExperts(activeSession),
|
||||
fetchAISessions(activeSession, activeProject),
|
||||
])
|
||||
if (generation !== refreshGeneration.current) return
|
||||
setWorkspace(nextWorkspace)
|
||||
setExperts(nextExperts)
|
||||
setAISessions(nextSessions)
|
||||
} catch (requestError) {
|
||||
if (generation === refreshGeneration.current) setError(errorMessage(requestError))
|
||||
} finally {
|
||||
if (generation === refreshGeneration.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function dock(side: DockSide) {
|
||||
setDockSide(side)
|
||||
}
|
||||
|
||||
if (!session) return <Login onLogin={(next) => { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} />
|
||||
|
||||
const activeProject = projects.find((project) => project.id === projectId) ?? projects[0]
|
||||
const content = !workspace || !activeProject
|
||||
? <Empty description="还没有项目,点击右上角新建" />
|
||||
: renderView(view, {
|
||||
session, workspace, experts, aiSessions,
|
||||
refresh: () => refreshProject(),
|
||||
openAction: setAction,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mini-shell">
|
||||
<header className="mini-header" data-tauri-drag-region>
|
||||
<div className="mini-brand" data-tauri-drag-region>
|
||||
<img src="/senlinai-icon.svg" alt="" />
|
||||
<span>森林AI</span>
|
||||
<Tag size="small" color="arcoblue">Mini 1.0</Tag>
|
||||
</div>
|
||||
<div className="window-actions">
|
||||
<Button aria-label="停靠左侧" className={dockSide === 'left' ? 'active' : ''} type="text" size="mini" icon={<IconArrowLeft />} onClick={() => void dock('left')} />
|
||||
<Button aria-label="停靠右侧" className={dockSide === 'right' ? 'active' : ''} type="text" size="mini" icon={<IconArrowRight />} onClick={() => void dock('right')} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="project-switcher">
|
||||
<Select
|
||||
value={projectId || undefined}
|
||||
placeholder="选择项目"
|
||||
onChange={setProjectId}
|
||||
triggerElement={() => (
|
||||
<button className="project-trigger">
|
||||
<Avatar size={26} style={{ background: activeProject?.background || '#165DFF' }}>{projectMark(activeProject)}</Avatar>
|
||||
<span>{activeProject?.name || '选择项目'}</span>
|
||||
<small>{activeProject?.identifier || ''}</small>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
|
||||
</Select>
|
||||
<Button aria-label="刷新" type="text" shape="circle" icon={<IconRefresh />} loading={loading} onClick={() => void refreshProject()} />
|
||||
<Button aria-label="新建项目" type="text" shape="circle" icon={<IconPlus />} onClick={() => setAction('project')} />
|
||||
</div>
|
||||
|
||||
<nav className="mini-nav" aria-label="主要导航">
|
||||
{navItems.map((item) => (
|
||||
<button className={view === item.id ? 'active' : ''} key={item.id} onClick={() => setView(item.id)}>
|
||||
{item.icon}<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{error ? <Alert className="mini-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||||
<main className="mini-content"><Spin loading={loading} block>{content}</Spin></main>
|
||||
|
||||
<footer className="mini-footer">
|
||||
<span><i /> 服务已连接</span>
|
||||
<button onClick={() => { localStorage.removeItem('senlin-mini-session'); setSession(null); setWorkspace(null) }}>退出登录</button>
|
||||
</footer>
|
||||
|
||||
<ActionModal
|
||||
action={action}
|
||||
workspace={workspace}
|
||||
onClose={() => setAction(null)}
|
||||
onProject={async (name) => {
|
||||
const project = await createProject(session, name)
|
||||
const next = await fetchProjects(session)
|
||||
setProjects(next)
|
||||
setProjectId(project.id)
|
||||
}}
|
||||
onTask={async (title, tag) => {
|
||||
if (!projectId) return
|
||||
await createTask(session, projectId, title, tag)
|
||||
await refreshProject()
|
||||
}}
|
||||
onCron={async (title, schedule) => {
|
||||
if (!projectId) return
|
||||
await createCron(session, projectId, title, schedule)
|
||||
await refreshProject()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderView(view: View, props: ViewProps) {
|
||||
if (view === 'tasks') return <TasksView {...props} />
|
||||
if (view === 'notes') return <NotesView {...props} />
|
||||
if (view === 'ai') return <AIView {...props} />
|
||||
if (view === 'more') return <MoreView {...props} />
|
||||
return <HomeView {...props} />
|
||||
}
|
||||
|
||||
type ViewProps = {
|
||||
session: ApiSession
|
||||
workspace: Workspace
|
||||
experts: Expert[]
|
||||
aiSessions: AISession[]
|
||||
refresh: () => void | Promise<void>
|
||||
openAction: (action: Action) => void
|
||||
}
|
||||
|
||||
function HomeView({ workspace, openAction }: ViewProps) {
|
||||
const pending = workspace.tasks.filter((task) => !task.completed)
|
||||
return (
|
||||
<section className="mini-page home-view">
|
||||
<div className="page-intro">
|
||||
<Text type="secondary">当前项目</Text>
|
||||
<Title heading={4}>{workspace.project.name}</Title>
|
||||
<p>{workspace.project.description || '把计划、资料与 AI 会话放进同一个项目上下文。'}</p>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<Metric value={pending.length} label="待办计划" color="blue" />
|
||||
<Metric value={workspace.notesSources.length} label="笔记资料" color="green" />
|
||||
<Metric value={workspace.aiSessions.length} label="AI 会话" color="purple" />
|
||||
<Metric value={workspace.cronPlans.length} label="计划任务" color="orange" />
|
||||
</div>
|
||||
<div className="section-title"><strong>快速操作</strong></div>
|
||||
<div className="quick-actions">
|
||||
<button onClick={() => openAction('task')}><IconPlus /><span><b>新建计划</b><small>快速记录下一步</small></span></button>
|
||||
<button onClick={() => openAction('cron')}><IconClockCircle /><span><b>计划任务</b><small>添加周期安排</small></span></button>
|
||||
</div>
|
||||
<div className="section-title"><strong>接下来</strong><Tag color="arcoblue">{pending.length}</Tag></div>
|
||||
<div className="compact-list">
|
||||
{pending.slice(0, 4).map((task) => <div key={task.id}><IconCheckCircle /><span><b>{task.title}</b><small>{task.tag || '未分类'}</small></span></div>)}
|
||||
{!pending.length && <Empty description="当前没有未完成计划" />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return <div className={`metric ${color}`}><strong>{value}</strong><span>{label}</span></div>
|
||||
}
|
||||
|
||||
function TasksView({ session, workspace, refresh, openAction }: ViewProps) {
|
||||
const [tag, setTag] = useState('全部')
|
||||
const tags = workspace.tags.filter((item) => item.name !== '全部' && item.name !== 'all')
|
||||
const tasks = tag === '全部' ? workspace.tasks : workspace.tasks.filter((task) => task.tag === tag)
|
||||
return (
|
||||
<section className="mini-page">
|
||||
<div className="page-title"><div><Title heading={5}>工作计划</Title><Text type="secondary">轻点圆圈即可更新状态</Text></div><Button type="primary" size="small" icon={<IconPlus />} onClick={() => openAction('task')}>新建</Button></div>
|
||||
<div className="filter-strip">
|
||||
{['全部', ...tags.map((item) => item.name)].map((item) => <button className={tag === item ? 'active' : ''} key={item} onClick={() => setTag(item)}>{item}</button>)}
|
||||
</div>
|
||||
<div className="sticky-list">
|
||||
{tasks.map((task) => (
|
||||
<article className={task.completed ? 'task-note completed' : 'task-note'} key={task.id}>
|
||||
<button className="task-check" aria-label={task.completed ? '恢复计划' : '完成计划'} onClick={async () => { await updateTask(session, workspace.project.id, task); await refresh() }}><IconCheckCircle /></button>
|
||||
<div><strong>{task.title}</strong><p>{task.summary || '暂无说明'}</p><span>{task.tag || '未分类'} · {task.createdAt}</span></div>
|
||||
</article>
|
||||
))}
|
||||
{!tasks.length && <Empty description="当前筛选下没有计划" />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function NotesView({ session, workspace, refresh }: ViewProps) {
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<section className="mini-page">
|
||||
<div className="page-title"><div><Title heading={5}>笔记资料</Title><Text type="secondary">项目资料随手可取</Text></div><Button type="primary" size="small" icon={<IconPlus />} onClick={() => fileInput.current?.click()}>上传</Button></div>
|
||||
<input className="hidden-file" ref={fileInput} type="file" onChange={async (event) => { const file = event.target.files?.[0]; if (!file) return; await uploadSource(session, workspace.project.id, file); await refresh(); event.target.value = '' }} />
|
||||
<div className="note-grid">
|
||||
{workspace.notesSources.map((note, index) => <article className={`source-note tone-${index % 4}`} key={note.id}><IconFile /><strong>{note.title}</strong><p>{note.source || note.kind}</p><span>{note.tag || '资料'} · {note.updatedAt}</span></article>)}
|
||||
{!workspace.notesSources.length && <Empty description="还没有笔记或资料" />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AIView({ session, workspace, experts, aiSessions, refresh }: ViewProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState('全部')
|
||||
const [expertId, setExpertId] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const categories = [...new Set(experts.map((expert) => expert.categoryName))]
|
||||
const visible = experts.filter((expert) => (category === '全部' || expert.categoryName === category) && `${expert.name}${expert.description}`.toLowerCase().includes(query.toLowerCase()))
|
||||
const selected = experts.find((expert) => expert.id === expertId)
|
||||
return (
|
||||
<section className="mini-page ai-view">
|
||||
<div className="page-title"><div><Title heading={5}>AI 会话</Title><Text type="secondary">先选择专家,再开始会话</Text></div><Tag color="purple">{experts.length} 位</Tag></div>
|
||||
<Input prefix={<IconSearch />} value={query} onChange={setQuery} allowClear placeholder="搜索专家" />
|
||||
<div className="filter-strip">
|
||||
{['全部', ...categories].map((item) => <button className={category === item ? 'active' : ''} key={item} onClick={() => setCategory(item)}>{item}</button>)}
|
||||
</div>
|
||||
<div className="expert-strip">
|
||||
{visible.slice(0, 30).map((expert) => <button className={expertId === expert.id ? 'active' : ''} key={expert.id} onClick={() => setExpertId(expert.id)}><i style={{ background: expert.color }}>{expert.emoji}</i><span><b>{expert.name}</b><small>{expert.categoryName}</small></span></button>)}
|
||||
</div>
|
||||
<div className="mini-composer">
|
||||
<div className="selected-expert">{selected ? <><i style={{ background: selected.color }}>{selected.emoji}</i><span><b>{selected.name}</b><small>{selected.description}</small></span></> : <Text type="secondary">选择一位专家进行会话</Text>}</div>
|
||||
<Input.TextArea value={prompt} onChange={setPrompt} autoSize={{ minRows: 3, maxRows: 6 }} placeholder={selected ? `向${selected.name}发送消息` : '请先选择专家'} />
|
||||
<Button type="primary" long loading={sending} disabled={!selected || !prompt.trim()} onClick={async () => { if (!selected) return; setSending(true); try { await createAISession(session, workspace.project.id, selected.id, prompt.trim()); setPrompt(''); await refresh() } finally { setSending(false) } }}>开始会话</Button>
|
||||
</div>
|
||||
<div className="section-title"><strong>最近会话</strong></div>
|
||||
<div className="session-list">{aiSessions.slice(0, 5).map((item) => <div key={item.id}><i style={{ background: item.expert?.color }}>{item.expert?.emoji || '🤖'}</i><span><b>{item.title}</b><small>{item.expert?.name || '森林AI'}</small></span></div>)}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreView({ workspace, openAction }: ViewProps) {
|
||||
return (
|
||||
<section className="mini-page">
|
||||
<div className="page-title"><div><Title heading={5}>更多</Title><Text type="secondary">项目周期安排与客户端信息</Text></div><Button size="small" icon={<IconPlus />} onClick={() => openAction('cron')}>计划任务</Button></div>
|
||||
<div className="settings-card"><strong>计划任务</strong>{workspace.cronPlans.map((plan) => <div key={plan.id}><IconClockCircle /><span><b>{plan.title}</b><small>{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}</small></span></div>)}{!workspace.cronPlans.length && <Text type="secondary">暂无周期计划</Text>}</div>
|
||||
<div className="settings-card"><strong>停靠说明</strong><p>顶部箭头可将桌面窗口停靠至当前屏幕左侧或右侧。Web 模式会记住布局偏好,桌面模式会同时移动实际窗口。</p></div>
|
||||
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>森林AI Mini</b><small>v1.0 完整版 · 窄屏停靠客户端</small></span></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) {
|
||||
const [server, setServer] = useState('http://localhost:9150')
|
||||
const [email, setEmail] = useState('demo@senlin.ai')
|
||||
const [password, setPassword] = useState('password123')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [connection, setConnection] = useState<ConnectionStatus>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => void checkConnection(controller.signal), 300)
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [server])
|
||||
|
||||
async function checkConnection(signal?: AbortSignal) {
|
||||
setConnection('checking')
|
||||
try {
|
||||
const online = await checkServerConnection(server, signal)
|
||||
if (!signal?.aborted) setConnection(online ? 'online' : 'offline')
|
||||
} catch (requestError) {
|
||||
if (!(requestError instanceof DOMException && requestError.name === 'AbortError')) setConnection('offline')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
onLogin(await login(server, email.trim(), password))
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mini-login">
|
||||
<form className="login-note" onSubmit={(event) => { event.preventDefault(); void submitLogin() }}>
|
||||
<img src="/senlinai-icon.svg" alt="" />
|
||||
<Title heading={3}>森林AI Mini</Title>
|
||||
<Text type="secondary">停靠在屏幕一侧,随时记录与推进项目</Text>
|
||||
<label>服务器地址<Input value={server} onChange={setServer} /></label>
|
||||
<div className={`mini-connection ${connection}`} role="status">
|
||||
<i />
|
||||
<strong>{connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'}</strong>
|
||||
<button type="button" onClick={() => void checkConnection()}>{connection === 'checking' ? '检查中' : '重新检查'}</button>
|
||||
</div>
|
||||
<label>邮箱<Input value={email} onChange={setEmail} /></label>
|
||||
<label>密码<Input.Password value={password} onChange={setPassword} /></label>
|
||||
{error ? <Alert type="error" content={error} /> : null}
|
||||
<Button type="primary" htmlType="submit" long loading={loading}>连接工作台</Button>
|
||||
<small>v1.0 完整版</small>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionModal({ action, workspace, onClose, onProject, onTask, onCron }: {
|
||||
action: Action
|
||||
workspace: Workspace | null
|
||||
onClose: () => void
|
||||
onProject: (name: string) => Promise<void>
|
||||
onTask: (title: string, tag: string) => Promise<void>
|
||||
onCron: (title: string, schedule: string) => Promise<void>
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [tag, setTag] = useState('')
|
||||
const [schedule, setSchedule] = useState('0 9 * * 1-5')
|
||||
const [loading, setLoading] = useState(false)
|
||||
useEffect(() => { if (action) { setTitle(''); setTag(''); setSchedule('0 9 * * 1-5') } }, [action])
|
||||
const labels = action === 'project' ? ['新建项目', '项目名称'] : action === 'task' ? ['新建计划', '计划标题'] : ['新建计划任务', '任务名称']
|
||||
return (
|
||||
<Modal className="mini-modal" visible={Boolean(action)} title={labels[0]} onCancel={onClose} footer={null} unmountOnExit>
|
||||
<div className="mini-form">
|
||||
<label>{labels[1]}<Input autoFocus value={title} onChange={setTitle} /></label>
|
||||
{action === 'task' ? <label>标签<Select value={tag || undefined} placeholder="可选" onChange={setTag}>{workspace?.tags.filter((item) => item.name !== 'all' && item.name !== '全部').map((item) => <Select.Option key={item.id} value={item.name}>{item.name}</Select.Option>)}</Select></label> : null}
|
||||
{action === 'cron' ? <label>Cron 表达式<Input value={schedule} onChange={setSchedule} /></label> : null}
|
||||
<Button type="primary" long loading={loading} disabled={!title.trim()} onClick={async () => { setLoading(true); try { if (action === 'project') await onProject(title.trim()); if (action === 'task') await onTask(title.trim(), tag); if (action === 'cron') await onCron(title.trim(), schedule.trim()); onClose() } catch (requestError) { Message.error(errorMessage(requestError)) } finally { setLoading(false) } }}>保存</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function readStoredSession(): ApiSession | null {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem('senlin-mini-session') || 'null') as ApiSession | null
|
||||
return value?.baseUrl && value?.token ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function projectMark(project?: Project) {
|
||||
if (!project) return <IconFolder />
|
||||
if (project.icon && !['folder', 'project'].includes(project.icon)) return project.icon
|
||||
const chars = Array.from(project.name.trim())
|
||||
const chinese = chars.some((char) => /[\u3400-\u9fff]/.test(char))
|
||||
return chars.slice(0, chinese ? 2 : 4).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof ApiError || error instanceof Error) return error.message
|
||||
return '操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
async function moveDesktopWindow(side: DockSide) {
|
||||
if (!('__TAURI_INTERNALS__' in window)) return
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('dock_window', { side })
|
||||
} catch (requestError) {
|
||||
Message.warning(errorMessage(requestError))
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
export type ApiSession = { baseUrl: string; token: string }
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkspaceTask = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
summary: string
|
||||
completed: boolean
|
||||
owner: string
|
||||
due: string | null
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
tagId: string | null
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type NoteSource = {
|
||||
id: string
|
||||
projectId: string
|
||||
kind: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
tag: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type CronPlan = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
schedule: string
|
||||
nextRun: string | null
|
||||
enabled: boolean
|
||||
lastResult: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export type Workspace = {
|
||||
project: Project & { initials: string; unreadCount: number }
|
||||
tags: Array<{ id: string; name: string }>
|
||||
tasks: WorkspaceTask[]
|
||||
notesSources: NoteSource[]
|
||||
aiSessions: Array<{ id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }>
|
||||
cronPlans: CronPlan[]
|
||||
}
|
||||
|
||||
export type Expert = {
|
||||
id: string
|
||||
slug: string
|
||||
category: string
|
||||
categoryName: string
|
||||
name: string
|
||||
description: string
|
||||
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 {
|
||||
constructor(public status: number, public code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
|
||||
|
||||
export function configureApi(value: string) {
|
||||
baseUrl = normalizeBaseUrl(value)
|
||||
}
|
||||
|
||||
export async function login(server: string, email: string, password: string): Promise<ApiSession> {
|
||||
configureApi(server)
|
||||
const response = await request<{ token: string }>('/api/v1/auth/login', { method: 'POST', body: { email, password } })
|
||||
return { baseUrl, token: response.token }
|
||||
}
|
||||
|
||||
export async function checkServerConnection(server: string, signal?: AbortSignal) {
|
||||
try {
|
||||
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })
|
||||
if (!response.ok) return false
|
||||
const payload = await response.json() as { timestamp?: unknown }
|
||||
return typeof payload.timestamp === 'string'
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProjects(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return request<Project[]>('/api/v1/projects', { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchWorkspace(session: ApiSession, projectId: string) {
|
||||
useSessionBase(session)
|
||||
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
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(!isForm && options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
},
|
||||
body: options.body === undefined ? undefined : isForm ? options.body as FormData : JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络')
|
||||
}
|
||||
if (!response.ok) {
|
||||
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 ?? '请求失败,请稍后重试')
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function useSessionBase(session: ApiSession) {
|
||||
baseUrl = normalizeBaseUrl(session.baseUrl)
|
||||
}
|
||||
|
||||
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, '')
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,249 +0,0 @@
|
||||
:root {
|
||||
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; }
|
||||
html, body, #root { width: 100%; min-width: 320px; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: #e8edf4; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
|
||||
.mini-shell {
|
||||
width: min(100%, 440px);
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px;
|
||||
overflow: hidden;
|
||||
background: var(--canvas);
|
||||
border: 1px solid rgba(78, 89, 105, 0.18);
|
||||
box-shadow: 0 20px 60px rgba(29, 33, 41, 0.18);
|
||||
}
|
||||
|
||||
html[data-dock="right"] .mini-shell { margin-left: auto; }
|
||||
html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
|
||||
.mini-header,
|
||||
.project-switcher,
|
||||
.mini-nav,
|
||||
.mini-footer {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px 0 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mini-brand,
|
||||
.window-actions,
|
||||
.project-trigger,
|
||||
.mini-footer,
|
||||
.section-title,
|
||||
.page-title,
|
||||
.selected-expert,
|
||||
.session-list > div,
|
||||
.settings-card > div,
|
||||
.version-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mini-brand { gap: 7px; font-weight: 800; }
|
||||
.mini-brand img { width: 25px; height: 25px; }
|
||||
.mini-brand .arco-tag { margin-left: 2px; }
|
||||
.window-actions { gap: 2px; }
|
||||
.window-actions .arco-btn { color: #6b778c; }
|
||||
.window-actions .active { color: var(--blue); background: #edf3ff; }
|
||||
|
||||
.project-switcher {
|
||||
display: grid;
|
||||
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%; }
|
||||
.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 (min-width: 700px) {
|
||||
body { display: flex; }
|
||||
html[data-dock="right"] body { justify-content: flex-end; }
|
||||
html[data-dock="left"] body { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.mini-shell { border: 0; }
|
||||
.mini-brand .arco-tag { display: none; }
|
||||
.note-grid, .expert-strip { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -23,7 +23,9 @@
|
||||
<section class="hero section-shell">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">私有部署 · 团队协作 · 智能决策</p>
|
||||
<h1>把工作收进项目,<br />用 AI 给出建议,<br />由人来确认结果</h1>
|
||||
<h1>灵感变成项目,</h1>
|
||||
<h2>森林AI 帮你:</h2>
|
||||
<h3>制定计划,收集数据,专家库帮你决策</h3>
|
||||
<p class="hero-description">森林AI 面向知识工作者与内部团队,将计划、探索数据、资料和专家对话沉淀在同一个项目空间。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button button-primary" href="#download">下载客户端</a>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/crontab"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/initdb"
|
||||
"senlinai-agent/backend/internal/logic/ai"
|
||||
@@ -33,7 +31,6 @@ func main() {
|
||||
authService := auth.NewService(cfg.AuthSecret)
|
||||
datasetService := dataset.NewService(models.DBService)
|
||||
datasetHandler := dataset.NewHandler(datasetService)
|
||||
datasetScheduler := crontab.NewDatasetScheduler(datasetService, log.Default())
|
||||
projectService := projects.NewService()
|
||||
documentService := documents.NewService(models.DBService, cfg.StorageDir)
|
||||
taskService := tasks.NewService(models.DBService)
|
||||
@@ -63,9 +60,6 @@ func main() {
|
||||
searchHandler,
|
||||
aiHandler,
|
||||
)
|
||||
schedulerContext, stopScheduler := context.WithCancel(context.Background())
|
||||
defer stopScheduler()
|
||||
go datasetScheduler.Run(schedulerContext)
|
||||
|
||||
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
25
backend/cmd/scheduler/main.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/crontab"
|
||||
"senlinai-agent/backend/internal/logic/dataset"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
err := models.New(cfg.DSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
datasetService := dataset.NewService(models.DBService)
|
||||
datasetScheduler := crontab.NewDatasetScheduler(datasetService, log.Default())
|
||||
schedulerContext, stopScheduler := context.WithCancel(context.Background())
|
||||
defer stopScheduler()
|
||||
datasetScheduler.Run(schedulerContext)
|
||||
}
|
||||
22
backend/etc/agent.prod.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
env: development
|
||||
port: "9150"
|
||||
dsn: "postgres://postgres:Weidong2023~!@8.137.107.29:19432/agent_dev?sslmode=disable"
|
||||
storage_dir: "./data/files"
|
||||
max_upload_bytes: 52428800
|
||||
auth_secret: "development-auth-secret-change-me"
|
||||
system_ai_key: ""
|
||||
ai_key_encryption_secret: "development-ai-key-secret-change-me"
|
||||
allowed_origins:
|
||||
- "http://localhost:5173"
|
||||
- "http://127.0.0.1:5173"
|
||||
- "http://localhost:5180"
|
||||
- "http://127.0.0.1:5180"
|
||||
- "http://localhost:4173"
|
||||
- "http://127.0.0.1:4173"
|
||||
- "http://localhost:4174"
|
||||
- "http://127.0.0.1:4174"
|
||||
- "http://localhost:4175"
|
||||
- "http://127.0.0.1:4175"
|
||||
- "http://localhost:4176"
|
||||
- "http://127.0.0.1:4176"
|
||||
- "http://tauri.localhost"
|
||||
20
deploy/agent.senlin.ai.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name agent.senlin.ai;
|
||||
root /data/senlin/web_v1;
|
||||
index index.html;
|
||||
|
||||
location ^~ /api {
|
||||
include cors.conf;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
add_header X-Backend-Header $upstream_http_custom_header;
|
||||
proxy_pass http://localhost:9150;
|
||||
}
|
||||
}
|
||||
|
||||
6
deploy/www.senlin.ai.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.senlin.ai senlin.ai;
|
||||
root /data/senlin/site;
|
||||
index index.html;
|
||||
}
|
||||
37
design-qa.md
@@ -1,37 +0,0 @@
|
||||
**Source Visual Truth**
|
||||
- Source: `C:\Users\david\.codex\generated_images\019f7b30-8171-7f12-8ef6-ac2571bdf8ae\call_g6DhOvUFyNza9HnLRRBIwk5l.png`
|
||||
|
||||
**Implementation Evidence**
|
||||
- Login screenshot: `D:\work\senlinai\agent\apps\web\test-results\login-acro.png`
|
||||
- Light workbench screenshot: `D:\work\senlinai\agent\apps\web\test-results\workbench-light-acro.png`
|
||||
- Dark workbench screenshot: `D:\work\senlinai\agent\apps\web\test-results\workbench-dark-acro.png`
|
||||
- Full-view comparison: `D:\work\senlinai\agent\design-qa-comparison.png`
|
||||
- Viewport: 1440 x 1024
|
||||
- State: login page, authenticated overview page, dark-mode overview page
|
||||
|
||||
**Findings**
|
||||
- No actionable P0/P1/P2 findings remain.
|
||||
|
||||
**Required Fidelity Surfaces**
|
||||
- Fonts and typography: implementation uses the existing system UI stack and compact product sizing. Hierarchy matches the design intent for form labels, topbar controls, sidebar rows, overview sections, and inspector tabs.
|
||||
- Spacing and layout rhythm: the app preserves the selected layout structure: topbar, project rail, channel sidebar, central overview, right inspector, and full-width bottom status bar. The status bar measured 32px high, matching the requested compact state.
|
||||
- Colors and visual tokens: implementation uses an Arco-inspired palette with `#165DFF`, light blue selected surfaces, `#E5E6EB` borders, `#F7F8FA` background, and a darker blue-gray status bar. Dark mode uses VS Code-like workbench tokens and `#007acc` accent/status color.
|
||||
- Image quality and asset fidelity: the design relies on app chrome, badges, and simple brand marks rather than content imagery. The implementation uses lightweight code-native marks for the MVP shell; replacing them with a formal icon library or brand asset would be a P3 polish pass.
|
||||
- Copy and content: login follows the selected Chinese private-deployment structure. The workbench preserves existing English mock object names where current tests and prototype data already depend on them, while key UI refinements from the final design are present.
|
||||
|
||||
**Interaction Checks**
|
||||
- Login flow from server/account/password to workbench was exercised.
|
||||
- Theme toggle was exercised from light to dark.
|
||||
- Console errors checked: none.
|
||||
- Layout check: no horizontal overflow at 1440 x 1024.
|
||||
|
||||
**Comparison History**
|
||||
- Earlier finding: overview center area only showed metric cards, leaving a large blank region compared with the selected design.
|
||||
- Fix made: added Inbox, active task, AI session, and recent notes/materials overview sections with compact row separators.
|
||||
- Post-fix evidence: `D:\work\senlinai\agent\apps\web\test-results\workbench-light-acro.png` and `D:\work\senlinai\agent\design-qa-comparison.png`.
|
||||
|
||||
**Follow-up Polish**
|
||||
- Replace code-native icon glyphs with a dedicated Svelte-compatible icon set or approved brand/icon assets.
|
||||
- Translate remaining prototype mock data to Chinese if the product direction requires a fully localized MVP.
|
||||
|
||||
final result: passed
|
||||
227
design.md
@@ -1,227 +0,0 @@
|
||||
# 森林AI UI Design Guide
|
||||
|
||||
## Purpose
|
||||
|
||||
This file defines the shared visual direction for 森林AI. It keeps future UI work consistent across the login page, project shell, channel pages, object inspector, and desktop wrapper.
|
||||
|
||||
The visual reference is shadcn/ui: quiet, structured, component-driven, and practical. This is a visual reference only. The web app must remain Svelte and TypeScript, and must not add React or shadcn/ui as runtime dependencies.
|
||||
|
||||
## Product Feel
|
||||
|
||||
森林AI is a private project workbench for focused knowledge work. The UI should feel calm, precise, and durable rather than decorative. It should support long sessions, dense information, fast scanning, and frequent switching between projects and channels.
|
||||
|
||||
Use a restrained desktop-app tone:
|
||||
|
||||
- Clear hierarchy.
|
||||
- Compact but breathable spacing.
|
||||
- Low-noise borders and surfaces.
|
||||
- Strong focus states.
|
||||
- Predictable controls.
|
||||
- Minimal decoration.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Marketing-style hero sections inside the app.
|
||||
- Large decorative gradients.
|
||||
- Floating page sections styled as cards.
|
||||
- Nested cards.
|
||||
- One-color themes dominated by a single hue.
|
||||
- Purely visual icon buttons without labels for assistive technology.
|
||||
|
||||
## Layout Principles
|
||||
|
||||
The main workbench uses a Discord-like project model:
|
||||
|
||||
- Project rail: narrow, persistent, dynamic project list.
|
||||
- Channel sidebar: current project navigation and recent sessions.
|
||||
- Main content: channel-specific page.
|
||||
- Object inspector: discussion, properties, and more actions.
|
||||
- Topbar: search and global navigation.
|
||||
|
||||
Desktop is the primary layout. Mobile should remain usable by collapsing the project rail, channel sidebar, and inspector into drawers or full-screen panels.
|
||||
|
||||
Use stable dimensions for fixed UI elements:
|
||||
|
||||
- Project icons: fixed square or circle size.
|
||||
- Channel rows: fixed minimum height.
|
||||
- Icon buttons: fixed square hit area.
|
||||
- Task cards: stable padding and content rhythm.
|
||||
- Inspector tabs: consistent height.
|
||||
|
||||
## Color
|
||||
|
||||
Use neutral surfaces first:
|
||||
|
||||
- App background: soft gray.
|
||||
- Panels and toolbars: white or near-white.
|
||||
- Borders: light neutral gray.
|
||||
- Primary text: near-black neutral.
|
||||
- Secondary text: muted gray.
|
||||
|
||||
Use one clear brand/action blue for:
|
||||
|
||||
- Primary buttons.
|
||||
- Active channel indicator.
|
||||
- Selected tabs.
|
||||
- Focus-visible outlines when appropriate.
|
||||
|
||||
Use semantic colors sparingly:
|
||||
|
||||
- Success: completed tasks and successful sync states.
|
||||
- Warning: delayed tasks or partial states.
|
||||
- Danger: destructive actions and errors.
|
||||
|
||||
Do not use color as the only state indicator. Pair color with text, icon shape, or position.
|
||||
|
||||
## Typography
|
||||
|
||||
Use the existing system font stack unless a design-system font is explicitly added later.
|
||||
|
||||
Typography should be compact and readable:
|
||||
|
||||
- Page title: clear but not oversized.
|
||||
- Panel title: medium weight.
|
||||
- Row/card title: strong enough for scanning.
|
||||
- Metadata: smaller and muted.
|
||||
- Button text: short and action-oriented.
|
||||
|
||||
Do not scale font size with viewport width. Letter spacing should stay at `0`.
|
||||
|
||||
## Spacing
|
||||
|
||||
Use a small spacing scale:
|
||||
|
||||
- 4px for tight internal gaps.
|
||||
- 8px for standard control gaps.
|
||||
- 12px for row and card content spacing.
|
||||
- 16px for panel padding.
|
||||
- 24px for major page spacing.
|
||||
|
||||
Dense work surfaces can use tighter spacing, but text and controls must not feel cramped.
|
||||
|
||||
## Radius And Borders
|
||||
|
||||
Use shadcn-like restrained shapes:
|
||||
|
||||
- Inputs, buttons, tabs, cards, menus: 6px to 8px radius.
|
||||
- Project avatars can be circular when matching the Discord-like rail.
|
||||
- Avoid large pill shapes except for count badges or compact status labels.
|
||||
|
||||
Use borders to define structure before shadows. Shadows should be subtle and reserved for menus, dialogs, popovers, and floating inspectors.
|
||||
|
||||
## Components
|
||||
|
||||
Buttons:
|
||||
|
||||
- Primary button for the main action on a surface.
|
||||
- Secondary/outline button for supporting actions.
|
||||
- Ghost button for toolbar and icon actions.
|
||||
- Destructive button only for irreversible actions.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Labels must be explicit.
|
||||
- Errors must appear near the field.
|
||||
- Focus state must be visible.
|
||||
|
||||
Tabs:
|
||||
|
||||
- Use tabs for Discussion, Properties, and More in the inspector.
|
||||
- Active tab must be visually clear and keyboard accessible.
|
||||
|
||||
Cards:
|
||||
|
||||
- Use cards for tasks, repeated records, and object summaries.
|
||||
- Do not place cards inside cards.
|
||||
- Keep task cards compact, with complete/incomplete state visible.
|
||||
|
||||
Badges:
|
||||
|
||||
- Use badges for counts, status, tags, and source type.
|
||||
- Count badges in the channel sidebar should be compact and aligned.
|
||||
|
||||
Menus:
|
||||
|
||||
- Use menus for secondary actions.
|
||||
- Destructive actions should be separated or clearly styled.
|
||||
|
||||
## Channel Templates
|
||||
|
||||
Each system channel has its own page template.
|
||||
|
||||
Message Flow:
|
||||
|
||||
- Similar to email.
|
||||
- Use a list of messages with sender/source, subject, summary, timestamp, status, and tags.
|
||||
- Selected message opens details in the inspector.
|
||||
|
||||
Work Plan:
|
||||
|
||||
- Similar to Todo software.
|
||||
- Use task cards with complete/incomplete controls.
|
||||
- Show owner, due date, tags, and status.
|
||||
|
||||
AI Sessions:
|
||||
|
||||
- Use a session list plus selected conversation detail.
|
||||
- Show context references and save/convert actions clearly.
|
||||
|
||||
Notes And Sources:
|
||||
|
||||
- Similar to file and attachment management.
|
||||
- Support list and metadata-heavy rows.
|
||||
- Show file type, note/source type, updated time, tags, and source.
|
||||
|
||||
Cron Plans:
|
||||
|
||||
- Similar to scheduled task management.
|
||||
- Show enabled state, schedule, next run, last result, and owner.
|
||||
- Keep this as plan/reminder management unless a later spec expands backend execution.
|
||||
|
||||
Custom Channel:
|
||||
|
||||
- Use title, icon, and URL.
|
||||
- Show open and copy actions.
|
||||
- Do not make it a separate content system in MVP.
|
||||
|
||||
## Login Page
|
||||
|
||||
The login page should feel like a private deployment entry point:
|
||||
|
||||
- Server address first.
|
||||
- Email or username.
|
||||
- Password.
|
||||
- Clear login button.
|
||||
- Connection and error state.
|
||||
- Remembered server address.
|
||||
|
||||
Avoid a marketing landing page. The first screen should let the user log in.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Every icon button needs an accessible name.
|
||||
|
||||
Keyboard users must be able to:
|
||||
|
||||
- Move through project rail items.
|
||||
- Move through channel rows.
|
||||
- Search.
|
||||
- Switch inspector tabs.
|
||||
- Toggle task completion.
|
||||
- Open menus.
|
||||
- Submit login.
|
||||
|
||||
Focus-visible states must be clear. Error states must use text and not color alone.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Use Svelte components and local CSS. A future design token file may be introduced, but until then, keep colors, spacing, radius, and typography consistent with this document.
|
||||
|
||||
Before finishing UI work, check:
|
||||
|
||||
- Text does not overflow buttons, cards, sidebars, or tabs.
|
||||
- Mobile layout remains usable.
|
||||
- Active project and active channel are obvious.
|
||||
- Counts and statuses are readable.
|
||||
- Empty states explain what the user can do next.
|
||||
- The UI does not depend on placeholder boxes or decorative fake assets.
|
||||
BIN
docs/screen/export.png
Normal file
|
After Width: | Height: | Size: 478 KiB |
BIN
docs/screen/login.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/screen/p1.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
docs/screen/p2.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/screen/p3.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
docs/screen/p4.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/screen/p5.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/screen/workspace.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$ROOT_DIR/apps/desktop"
|
||||
npm run build
|
||||
npm run desktop:build
|
||||
|
||||
echo "Desktop build completed."
|
||||
|
||||
|
||||