Compare commits
7 Commits
10790b233e
...
7534fadfca
| Author | SHA1 | Date | |
|---|---|---|---|
| 7534fadfca | |||
| b2c22901ba | |||
| 563e0fe115 | |||
| 81ffe64446 | |||
| 1e1718caee | |||
| 8e15c06148 | |||
| 794ec74c3f |
@@ -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 会为面板预留工作区;最大化的其他应用不会遮挡它
|
||||
|
||||
@@ -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 App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
1810
apps/desktop/package-lock.json
generated
1810
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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}`)
|
||||
}
|
||||
@@ -26,9 +26,9 @@ const workspace = {
|
||||
{ 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: '上传文件' },
|
||||
documents: [
|
||||
{ id: 'note-1', projectId, kind: 'markdown', name: '版本规划', extension: '.md', mimeType: 'text/markdown', updatedAt: '今天' },
|
||||
{ id: 'source-1', projectId, kind: 'file', name: '需求说明.pdf', extension: '.pdf', mimeType: 'application/pdf', updatedAt: '昨天' },
|
||||
],
|
||||
cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }],
|
||||
}
|
||||
@@ -63,31 +63,27 @@ const offlineConnection = await page.locator('.mini-connection').evaluate((eleme
|
||||
text: element.textContent,
|
||||
borderColor: getComputedStyle(element).borderColor,
|
||||
}))
|
||||
await page.screenshot({ path: 'test-results/mini-login.png', fullPage: true })
|
||||
await page.screenshot({ path: 'test-results/app-login.png', fullPage: true })
|
||||
const loginDefaults = await page.locator('.login-note input').evaluateAll((inputs) => inputs.map((input) => input.value))
|
||||
await page.locator('.login-note input').nth(2).press('Enter')
|
||||
await page.waitForSelector('.mini-shell')
|
||||
await page.waitForTimeout(400)
|
||||
await page.screenshot({ path: 'test-results/mini-home-right.png', fullPage: true })
|
||||
await page.screenshot({ path: 'test-results/app-home.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.screenshot({ path: 'test-results/app-tasks.png', fullPage: true })
|
||||
|
||||
await page.getByRole('button', { name: '资料', exact: true }).click()
|
||||
const notes = await page.locator('.source-note').count()
|
||||
@@ -96,7 +92,7 @@ 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 page.screenshot({ path: 'test-results/app-ai.png', fullPage: true })
|
||||
|
||||
await browser.close()
|
||||
await server.close()
|
||||
@@ -107,12 +103,11 @@ if (loginDefaults[1] !== 'demo@senlin.ai' || loginDefaults[2] !== 'password123')
|
||||
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))
|
||||
console.log(JSON.stringify({ home, tasks, notes, sendEnabled, errors }, null, 2))
|
||||
if (failures.length) throw new Error(failures.join('\n'))
|
||||
670
apps/desktop/src-tauri/Cargo.lock
generated
670
apps/desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
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,20 +1,27 @@
|
||||
{
|
||||
"$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": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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,
|
||||
IconApps, IconCheckCircle, IconClockCircle,
|
||||
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
|
||||
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'
|
||||
|
||||
@@ -32,21 +31,11 @@ export function App() {
|
||||
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
|
||||
@@ -89,10 +78,6 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -110,11 +95,7 @@ export function App() {
|
||||
<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')} />
|
||||
<Tag size="small" color="arcoblue">App 1.0</Tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -206,7 +187,7 @@ function HomeView({ workspace, openAction }: ViewProps) {
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<Metric value={pending.length} label="待办计划" color="blue" />
|
||||
<Metric value={workspace.notesSources.length} label="笔记资料" color="green" />
|
||||
<Metric value={workspace.documents.length} label="笔记资料" color="green" />
|
||||
<Metric value={workspace.aiSessions.length} label="AI 会话" color="purple" />
|
||||
<Metric value={workspace.cronPlans.length} label="计划任务" color="orange" />
|
||||
</div>
|
||||
@@ -258,8 +239,8 @@ function NotesView({ session, workspace, refresh }: ViewProps) {
|
||||
<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="还没有笔记或资料" />}
|
||||
{workspace.documents.map((document, index) => <article className={`source-note tone-${index % 4}`} key={document.id}><IconFile /><strong>{document.name}</strong><p>{document.extension || document.mimeType || document.kind}</p><span>{document.kind || '资料'} · {document.updatedAt}</span></article>)}
|
||||
{!workspace.documents.length && <Empty description="还没有笔记或资料" />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -301,7 +282,7 @@ function MoreView({ workspace, openAction }: ViewProps) {
|
||||
<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>
|
||||
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>森林AI App</b><small>v1.0 · 停靠客户端</small></span></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -350,7 +331,7 @@ function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) {
|
||||
<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>
|
||||
<Title heading={3}>森林AI App</Title>
|
||||
<Text type="secondary">停靠在屏幕一侧,随时记录与推进项目</Text>
|
||||
<label>服务器地址<Input value={server} onChange={setServer} /></label>
|
||||
<div className={`mini-connection ${connection}`} role="status">
|
||||
@@ -415,13 +396,3 @@ 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))
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,14 @@ export type WorkspaceTask = {
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type NoteSource = {
|
||||
export type WorkspaceDocument = {
|
||||
id: string
|
||||
projectId: string
|
||||
kind: string
|
||||
title: string
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
updatedAt: string
|
||||
tag: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type CronPlan = {
|
||||
@@ -48,7 +48,7 @@ export type Workspace = {
|
||||
project: Project & { initials: string; unreadCount: number }
|
||||
tags: Array<{ id: string; name: string }>
|
||||
tasks: WorkspaceTask[]
|
||||
notesSources: NoteSource[]
|
||||
documents: WorkspaceDocument[]
|
||||
aiSessions: Array<{ id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }>
|
||||
cronPlans: CronPlan[]
|
||||
}
|
||||
38
apps/desktop/src/main.tsx
Normal file
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>,
|
||||
)
|
||||
@@ -17,19 +17,16 @@ button, input, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
|
||||
.mini-shell {
|
||||
width: min(100%, 440px);
|
||||
width: 100%;
|
||||
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);
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html[data-dock="right"] .mini-shell { margin-left: auto; }
|
||||
html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
|
||||
.mini-header,
|
||||
.project-switcher,
|
||||
.mini-nav,
|
||||
@@ -47,7 +44,6 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
}
|
||||
|
||||
.mini-brand,
|
||||
.window-actions,
|
||||
.project-trigger,
|
||||
.mini-footer,
|
||||
.section-title,
|
||||
@@ -63,9 +59,6 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
.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;
|
||||
@@ -236,14 +229,7 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
.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; }
|
||||
}
|
||||
@@ -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
2047
apps/mini/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
4394
apps/mini/src-tauri/Cargo.lock
generated
4394
apps/mini/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 926 B |
Binary file not shown.
Binary file not shown.
|
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,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,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
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()
|
||||
go datasetScheduler.Run(schedulerContext)
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user