Compare commits

...

148 Commits

Author SHA1 Message Date
d98f65ca8d fix bug 2026-07-28 17:10:05 +08:00
076be30146 fix(desktop): harden workspace interactions 2026-07-26 00:57:39 +08:00
93b671c746 fix(desktop): make overlays responsive 2026-07-26 00:16:58 +08:00
139e0d59ed feat(desktop): open project documents by type 2026-07-26 00:09:33 +08:00
f67660f3e5 fix(desktop): rename task scheduler navigation 2026-07-25 23:59:40 +08:00
28e0ea10e5 feat(desktop): add AI session creation and history 2026-07-25 23:56:49 +08:00
562c6b1bdb feat(desktop): add document upload and markdown editor 2026-07-25 23:46:24 +08:00
dbf5c33c99 feat(desktop): refine project workspace navigation 2026-07-25 23:23:55 +08:00
0cbf6d1311 fix(desktop): show project dropdown indicator 2026-07-25 23:04:28 +08:00
4ffd51a831 feat(desktop): align explore view with web reader 2026-07-25 22:59:35 +08:00
ac674d71e7 refactor(desktop): simplify all-projects dashboard 2026-07-25 22:40:29 +08:00
b01f14ac00 feat(desktop): add all-projects workbench home 2026-07-25 22:23:34 +08:00
4b3f0d9167 feat(desktop): align docked workspace with web client 2026-07-25 21:52:19 +08:00
b2afa4edf8 fix(scheduler): keep scheduler process running 2026-07-25 21:01:18 +08:00
7534fadfca refactor(backend): run dataset scheduler separately 2026-07-25 20:59:24 +08:00
b2c22901ba fix(desktop): render workspace documents after login 2026-07-25 20:56:21 +08:00
563e0fe115 fix(desktop): hide release console window 2026-07-25 20:29:16 +08:00
81ffe64446 fix(desktop): lock appbar to edge 2026-07-25 20:14:58 +08:00
1e1718caee refactor(desktop): consolidate app on docked client 2026-07-25 19:51:12 +08:00
8e15c06148 feat(mini): reserve screen edge with Windows appbar 2026-07-25 19:34:33 +08:00
794ec74c3f feat(desktop): reserve right edge with Windows appbar 2026-07-25 18:56:37 +08:00
10790b233e feat 2026-07-24 21:40:45 +08:00
5cd779c22c feat site 2026-07-24 18:50:09 +08:00
68f4149d34 style(site): enlarge showcase previews 2026-07-24 18:48:06 +08:00
3a76e787d6 fix(site): close showcase column gaps 2026-07-24 18:42:49 +08:00
7ea97349d3 style(site): align data preview sizing 2026-07-24 18:35:58 +08:00
9eb3586981 style(site): tighten landing page spacing 2026-07-24 18:21:25 +08:00
75f4071800 fix(site): tighten desktop hero typography 2026-07-24 18:18:54 +08:00
141b8650eb feat(site): add official product landing page 2026-07-24 18:12:14 +08:00
127dc04795 build: add Linux web and backend bundle script 2026-07-24 15:08:06 +08:00
f430566976 fix(workbench): complete profile and channel flows 2026-07-24 14:46:38 +08:00
d7eb20a85f fix dark text color 2026-07-24 13:42:40 +08:00
f2d1fef2d6 refactor(documents): rename document owner fields 2026-07-24 12:29:16 +08:00
ab8ba3c03f refactor(documents): rename document tree model 2026-07-24 12:23:18 +08:00
1578c81a89 refactor(documents): split document models 2026-07-24 12:20:39 +08:00
77beaebb30 feat(documents): rebuild project document workbench 2026-07-24 12:16:02 +08:00
366136cee9 feat(explore): improve article timestamps 2026-07-24 10:45:01 +08:00
d6e4998d87 fix(explore): stabilize article list hover 2026-07-24 10:41:51 +08:00
7c9d5e4560 feat(explore): allow deleting source categories 2026-07-24 10:37:46 +08:00
fd8a2110a1 fix(web): allow workbench without projects 2026-07-24 10:33:46 +08:00
9900235eaa fix bug 2026-07-24 10:13:50 +08:00
bfcfd8d32e fix: harden exploration data flow 2026-07-23 23:29:14 +08:00
400d768ecf feat: show dataset item thumbnails 2026-07-23 23:17:56 +08:00
56d96f23dd feat: add dataset item images 2026-07-23 23:15:43 +08:00
fbd142803a refactor: remove dataset cron records 2026-07-23 23:08:25 +08:00
3705d75e59 refactor: remove dataset item creator fields 2026-07-23 22:55:53 +08:00
af8de3eff6 refactor: simplify dataset initialization 2026-07-23 22:44:04 +08:00
bc7fa40995 refactor: simplify dataset source ownership 2026-07-23 22:39:12 +08:00
5a1b04c4ed fix: complete exploration data flow 2026-07-23 22:25:12 +08:00
55ff320cec feat: associate dataset sources with owners 2026-07-23 21:44:47 +08:00
50d8246915 fix: harden explore dataset collection 2026-07-23 21:34:13 +08:00
3749cf4543 feat: schedule dataset collection 2026-07-23 20:55:38 +08:00
5da343cc71 feat: collect JSON and XML feeds 2026-07-23 20:47:54 +08:00
4b38a42607 feat: add Chinese finance dataset feeds 2026-07-23 20:36:11 +08:00
b053c3874a feat: seed default dataset sources 2026-07-23 15:14:31 +08:00
26dd3004a1 feat: connect explore datasets to backend 2026-07-23 15:07:35 +08:00
0ca2898ac0 refactor: shorten model and table prefixes 2026-07-23 14:30:17 +08:00
6e7be2f3a4 refactor: initialize expert catalog from initdb 2026-07-23 14:24:01 +08:00
bb90ed1d38 refactor: use singular AI expert item filename 2026-07-23 14:12:49 +08:00
f248cfd79e refactor: rename AI expert item model 2026-07-23 14:11:17 +08:00
6844af321e feat: model expert catalog categories 2026-07-23 14:07:13 +08:00
51ee510112 refactor: add database initialization entrypoint 2026-07-23 13:55:49 +08:00
50274184dc feat: initialize default root user 2026-07-23 13:54:39 +08:00
c3a7c3a2d4 chore(web): pin vite development port 2026-07-22 23:27:02 +08:00
5ee346e222 fix(web): normalize server addresses before connecting 2026-07-22 23:25:49 +08:00
f5e6db0c35 fix(desktop): allow API connections on custom ports 2026-07-22 23:20:49 +08:00
a564d48154 fix(auth): highlight failed server connections 2026-07-22 23:13:55 +08:00
b01928a5e4 fix(mini): preserve taskbar and restore login 2026-07-22 19:03:24 +08:00
aeb5276a73 feat(desktop): ship full and mini clients 2026-07-22 18:52:38 +08:00
a23a110758 feat(mini): add dockable compact client 2026-07-22 18:34:20 +08:00
4a82efc984 chore(web): mark frontend as v1.0 complete 2026-07-22 17:23:28 +08:00
42dd0dce47 feat(ai): add local expert library 2026-07-22 17:09:54 +08:00
b28a5d635c feat(web): refine AI sessions and task filtering 2026-07-22 16:26:05 +08:00
5d2f45ad83 feat(web): add explore source configuration modals 2026-07-22 15:56:04 +08:00
74775ed64f fix(web): use dashboard icon for workspace entry 2026-07-22 15:50:16 +08:00
e391d9dc2f feat(web): restore legacy channel editor 2026-07-22 15:48:43 +08:00
43ac9388ac fix(web): rename AI channel to AI 对话 2026-07-22 15:46:50 +08:00
fecff65975 fix(web): keep Inbox out of project channels 2026-07-22 15:44:41 +08:00
d576ad02c1 feat(web): restore legacy status bar 2026-07-22 15:42:58 +08:00
2e60f273fa feat(web): restore legacy explore workspace 2026-07-22 15:39:57 +08:00
1ed01a2059 chore(docs): remove obsolete SDD review artifacts 2026-07-22 15:30:12 +08:00
ab23a2d720 docs: rewrite README as product overview 2026-07-22 15:29:39 +08:00
47c3ac60d5 fix(web): show project icons beside project names 2026-07-22 15:27:41 +08:00
17dd9de401 fix(api): finalize frontend contract integration 2026-07-22 15:25:22 +08:00
217f82c57c fix(models): isolate versioned migration sessions 2026-07-22 15:22:26 +08:00
78d4845688 fix(backend): harden final MVP invariants 2026-07-22 15:22:06 +08:00
251b212e06 fix: harden controlled AI session consistency 2026-07-22 15:16:37 +08:00
122f5d8c52 feat: align controlled AI sessions and MVP controls 2026-07-22 15:16:01 +08:00
8767446b78 fix: stabilize inbox confirmation retries 2026-07-22 15:15:10 +08:00
7c363bb473 fix: make inbox confirmation retry safe 2026-07-22 15:15:03 +08:00
85a25ca017 feat: connect inbox confirmation workflow 2026-07-22 15:14:55 +08:00
8cc130244b fix(search): harden authorized results and ordering 2026-07-22 15:14:01 +08:00
000de4bcdb feat: connect search and project settings 2026-07-22 15:13:53 +08:00
2275d388a7 fix(api): harden response contracts 2026-07-22 15:12:37 +08:00
c56ada3708 refactor(web): consume api v1 contracts 2026-07-22 15:11:38 +08:00
80bec26839 fix(backend): harden write registrar boundaries 2026-07-22 15:10:41 +08:00
1fcbb31301 refactor(backend): split project write responsibilities 2026-07-22 15:10:41 +08:00
9e862f2a7f fix(backend): keep workspace completion metadata honest 2026-07-22 15:10:40 +08:00
a53f047efe refactor(backend): isolate workspace aggregation 2026-07-22 15:10:40 +08:00
14a140985c fix(api): enforce project identifier uniqueness 2026-07-22 15:10:40 +08:00
5341b44cc5 refactor(api): expose project identity DTOs 2026-07-22 15:10:40 +08:00
4bfeec8d2b fix(api): reject invalid UUID variants 2026-07-22 15:10:40 +08:00
7803b2faf0 refactor(api): introduce v1 response contract 2026-07-22 15:10:40 +08:00
1d35fcf4a1 fix(web): derive project rail labels from names 2026-07-22 15:08:57 +08:00
98573cf4ca feat(brand): apply 森林AI identity 2026-07-22 15:06:51 +08:00
bbc3a752dc docs: plan forest AI MVP unification 2026-07-21 12:25:25 +08:00
ea69d509a6 docs: define forest AI MVP unification 2026-07-21 12:19:56 +08:00
f781823cab docs: define SenlinAI icon design 2026-07-21 11:56:37 +08:00
e3a2c18ad9 fix: simplify footer and AI agent typography 2026-07-21 11:43:20 +08:00
bb4d26185b chore: update local dev backend config 2026-07-21 11:41:21 +08:00
ad05902563 fix: rename footer health label 2026-07-21 11:40:08 +08:00
82af483780 fix: remove AI agent page header 2026-07-21 11:37:21 +08:00
8b29c2c090 fix: move upgrade action beside user name 2026-07-21 11:36:10 +08:00
b00cacd460 feat: add footer profile and upgrade dialogs 2026-07-21 11:32:31 +08:00
29dbaeddf0 feat: redesign project AI agent page 2026-07-21 11:29:36 +08:00
ad6dcd6de4 chore: rename web client to web_v1 2026-07-21 11:24:53 +08:00
465e7bea9b feat: refine workspace explore sources 2026-07-21 11:16:34 +08:00
7a1ee3f17f feat: redesign workspace explore page 2026-07-21 11:05:20 +08:00
1c58825a7b feat: add project task edit modal 2026-07-21 01:18:06 +08:00
d6395d3c64 feat: reorganize project task planning UI 2026-07-21 01:00:27 +08:00
c613d4c997 feat: complete project workspace tags and notes UI 2026-07-21 00:46:09 +08:00
54958382f1 fix: align inbox project routes 2026-07-20 22:35:45 +08:00
c1a144cb44 feat: add project action modals and APIs 2026-07-20 21:45:36 +08:00
df12201caa feat: update workspace pages and global inbox 2026-07-20 18:25:04 +08:00
c64daa8657 Add backend status timestamp endpoint 2026-07-20 17:13:39 +08:00
875b326ddc Connect React workspace to backend data 2026-07-20 13:02:26 +08:00
215268e836 Remove persistent project inspector 2026-07-20 12:45:13 +08:00
a299da63e7 Add project channel pages 2026-07-20 12:26:21 +08:00
a65012c1b3 Apply hover scrollbars to project internals 2026-07-20 12:13:57 +08:00
d15ca2eb5f Show project scrollbars on hover 2026-07-20 12:08:52 +08:00
fd8d188f29 Refine project column scrolling 2026-07-20 12:04:36 +08:00
443a9feaac fix flex 2026-07-20 12:00:32 +08:00
2c7fd6b7f9 Remove workspace header actions 2026-07-20 11:57:32 +08:00
5abc9b634b Hide workspace side panels 2026-07-20 11:49:01 +08:00
22128f0171 Show online status as inline dot 2026-07-20 11:43:02 +08:00
da76abf2d0 Add workspace landing page 2026-07-20 11:40:45 +08:00
9c4ad986c0 Move project overview under project pages 2026-07-20 11:32:22 +08:00
f4f9ce1c12 Fix React project layout regression 2026-07-20 11:03:27 +08:00
7a279efa8f Move project page modules under pages 2026-07-20 10:53:26 +08:00
2660bc65bd Split React client pages into modules 2026-07-20 10:50:42 +08:00
bbd6c62029 Promote React Arco client into apps 2026-07-20 10:37:40 +08:00
aa2bf9b88f Hide React prototype project rail scrollbars 2026-07-20 10:13:05 +08:00
5411f56beb Polish React prototype project rail spacing 2026-07-20 10:09:09 +08:00
b4b533db4e Adjust React prototype project rail sizing 2026-07-20 10:00:47 +08:00
9e76986879 Add React Arco workbench prototype 2026-07-20 09:50:55 +08:00
3669e23085 superpowers 2026-07-20 08:02:20 +08:00
0f2cbcb9ee Implement Acro workbench UI refresh 2026-07-20 01:21:49 +08:00
03a5c52b72 Align backend workbench API with prototype 2026-07-19 21:46:43 +08:00
283 changed files with 33438 additions and 6375 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ coverage/
*.log
test-results/
playwright-report/
apps/desktop/release/
.builds/

View File

@@ -2,14 +2,14 @@
## 项目
本仓库包含 SenlinAI 项目工作台 MVP。
本仓库包含森林AI项目工作台 MVP。
该产品是一个以项目为中心、面向私有化部署的工作台服务于个人知识工作者、团队负责人和可信的内部用户。它用于组织项目任务、inbox 条目、Markdown 笔记、文件、AI 会话、项目内标签,以及轻量级任务分发。
## 仓库结构
- `backend`: Go Gin + Gorm + PostgreSQL API。
- `apps/web`: 响应式 Svelte + TypeScript Web 客户端
- `apps/web_v1`: 当前主 Web 客户端,使用 React + TypeScript + Arco Design
- `apps/desktop`: Web 客户端的 Tauri 桌面壳。
- `infra`: 本地开发基础设施,例如 Docker Compose。
- `docs/superpowers/specs`: 产品和技术规格文档。
@@ -22,7 +22,7 @@
- 使用模块路径 `senlinai-agent/backend`
- 业务逻辑包统一放在 `backend/internal/logic` 下,例如 `ai``auth``files``inbox``notes``projects``search``tasks`
- Gorm 模型和数据库连接入口统一放在 `backend/internal/models`,不要恢复独立的 `domain``db` 包。
- 模型结构体使用 `SenlinAgent` 前缀,数据库表名使用 `senlin_agent_` 前缀。
- 模型结构体使用 `Sa` 前缀,数据库表名使用 `sa_` 前缀。
- 所有服务端本地文件路径构造必须集中在文件服务中。
- 不允许 HTTP handler 直接构造存储路径。
- 项目标签必须限定在项目内MVP 阶段不要引入全局标签体系。
@@ -37,9 +37,8 @@
## 前端规则
- 前端使用 Svelte。不要添加 React 依赖或 React 组件
- 当前主前端使用 React、TypeScript、Vite 和 Arco Design位于 `apps/web_v1`
- Web 应用优先面向桌面端构建,同时保持移动浏览器可用。
- 功能代码放在 `apps/web/src/features` 下。
- 登录界面必须允许用户输入服务器 IP 地址或域名。
- 页面级工作台状态使用浏览器式标签页。
- 编辑器草稿和 AI 输入草稿要与标签页状态分开保存。
@@ -47,7 +46,7 @@
## Tauri 规则
- Tauri 用于包装 Web 客户端。
- Tauri 用于包装当前主 Web 客户端。
- MVP 桌面端功能包括登录持久化、文件拖拽上传、通知,以及全局快捷键快速收集。
- MVP 阶段不要添加离线编辑、本地优先同步、后台剪贴板监控或文件系统索引。
@@ -67,6 +66,6 @@
- 后端变更应在 `backend` 目录运行 `go test ./...`
- PostgreSQL 集成检查应从 `DATABASE_URL` 读取实时测试数据库 URL不要提交真实凭据。
- Web 变更应在 `apps/web` 目录运行测试和构建
- Web 变更应在 `apps/web_v1` 目录运行 `node scripts/visual-check.mjs``npm run build``npm run lint`
- Tauri 变更应先构建 Web 应用,然后在 `apps/desktop` 目录运行 Tauri 验证命令。
- 每个实施任务结束时都应创建一个聚焦的提交。

137
README.md
View File

@@ -1,101 +1,100 @@
# SenlinAI Agent Workbench
<p align="center">
<img src="docs/senlinai-icon.svg" width="88" alt="森林AI Logo" />
</p>
SenlinAI Agent Workbench 是一个以项目为中心的私有化工作台 MVP。
<h1 align="center">森林AI</h1>
## 本地开发
<p align="center">以项目为中心的私有化智能工作台,让任务、资料、信息收集和 AI 协作在同一个空间自然生长。</p>
启动本地 PostgreSQL
## 产品简介
```powershell
docker compose -f infra/docker-compose.yml up -d
```
森林AI 面向个人知识工作者、团队负责人和可信的内部协作成员,帮助用户把分散的事项、资料与沟通内容整理到清晰的项目空间中。
启动后端 API
每个项目都有独立的概况、工作计划、AI 会话、笔记资料和计划任务。用户既可以在工作台查看所有项目的整体进展,也可以进入具体项目,围绕目标持续收集信息、安排工作、沉淀知识并复盘成果。
```powershell
Set-Location backend
go run ./cmd/api
```
## 适用场景
启动 Web 客户端:
- 个人同时推进多个长期项目,需要统一查看待办和知识资料。
- 团队负责人希望快速了解各项目状态、未完成计划和重点事项。
- 内部团队需要在可控环境中使用 AI并保留人工确认环节。
- 研究、咨询、产品、运营等工作需要把零散输入逐步转化为任务、笔记和资料。
- 项目文件、讨论背景和执行计划分散在不同工具中,希望集中管理。
```powershell
Set-Location apps/web
npm run dev
```
## 核心功能
## 后端配置
### 统一工作台
后端配置不再从多个环境变量直接读取,而是从 `backend/etc/agent.<mode>.yaml` 读取
工作台汇总所有项目的数量、未完成计划、AI 会话和知识资料,并集中展示跨项目待办。用户可以直接查看和编辑任务,也可以调整任务所属项目、标签与完成状态
配置模式由环境变量 `SENLIN_APP_MODE` 决定:
### 独立项目空间
- 未设置 `SENLIN_APP_MODE` 时,默认读取 `backend/etc/agent.dev.yaml`
- 设置 `SENLIN_APP_MODE=prod` 时,读取 `backend/etc/agent.prod.yaml`
- 设置其他值时,按同样规则读取 `backend/etc/agent.<value>.yaml`
每个项目拥有自己的名称、图标、背景、简介和访问标识。项目概况页集中呈现进行中任务、已完成任务、笔记资料和计划任务,让项目当前状态一目了然
默认开发配置文件:
### Inbox 信息收集与整理
```text
backend/etc/agent.dev.yaml
```
Inbox 用于承接尚未归类的想法、消息和材料。用户可以查看原始内容,让 AI 提供任务、笔记或资料建议,再自行勾选并确认创建。分析结果只是候选草稿,不会未经确认直接写入项目。
配置字段:
### 工作计划与项目标签
- `env`: 运行环境,例如 `development`
- `port`: API 监听端口,默认开发值为 `8080`
- `dsn`: PostgreSQL 连接串。不要提交真实生产或测试凭据。
- `storage_dir`: 服务端本地文件存储目录。
- `auth_secret`: 邀请 token 和 session token 的 HMAC 密钥。
- `system_ai_key`: 可选的系统级 AI provider fallback key。
- `ai_key_encryption_secret`: 用户 AI key 静态加密密钥。
任务按项目组织,可记录标题、说明、标签、负责人、创建时间和完成状态。项目内标签帮助用户按主题或优先级整理计划,同时保留进行中与已完成事项,方便跟进和复盘
## 前端
### AI 会话
Web 客户端只使用 Svelte 和 TypeScript。登录界面允许用户输入服务器 IP 地址或域名,并将其保存为 API base URL
用户可以从本地专家库中选择适合的专业角色,再创建带有项目背景的 AI 会话。专家库支持按分类浏览和关键词搜索每个会话都会保留所选专家与项目上下文。AI 输出与正式项目内容相互区分,需要用户确认后才能转化为任务、笔记或资料
## 桌面端
内置专家资料基于 [agency-agents-zh](https://github.com/jnMetaCode/agency-agents-zh) 本地化,许可说明见 [docs/licenses/agency-agents-zh-MIT.txt](docs/licenses/agency-agents-zh-MIT.txt)。运行时不会依赖外部专家站点。
桌面端使用 Tauri 包装 Web 客户端。
### 笔记与资料管理
构建桌面可执行文件:
项目中的笔记、文档、图片、表格、PDF 和其他附件集中展示,并按更新时间浏览。资料与项目保持关联,减少文件散落和反复查找。
```powershell
Set-Location apps/desktop
npm run build
```
### 计划任务
生成安装包:
用户可以为项目登记周期性计划和提醒安排,查看启用状态、最近时间与下次时间。计划任务用于帮助团队建立稳定节奏,不会自行替用户执行 AI 工作。
```powershell
Set-Location apps/desktop
npm run bundle
```
### 项目内搜索
## 验证
通过顶部搜索框快速查找项目、任务和笔记,并在结果中确认内容及所属项目,缩短在多个项目之间定位信息的时间。
后端:
## 典型使用流程
```powershell
Set-Location backend
go test ./... -v
```
1. 创建项目,补充项目名称、图标、背景和目标说明。
2. 将临时想法、会议内容或待整理材料放入 Inbox。
3. 使用 AI 分析内容,选择合适的任务、笔记或资料建议。
4. 人工核对并确认,将候选内容转为正式项目对象。
5. 在工作计划中安排和更新任务,在笔记资料中持续沉淀成果。
6. 通过项目概况和统一工作台掌握进展,及时处理跨项目待办。
Web
## 产品亮点
```powershell
Set-Location apps/web
npx tsc --noEmit -p tsconfig.app.json
npm test -- --run
npm run build
npx playwright test
```
### 项目即上下文
桌面端:
任务、资料、AI 会话与收集内容都围绕项目组织。用户无需在多个孤立工具之间重复解释背景,项目本身就是持续积累的工作上下文。
```powershell
Set-Location apps/desktop
npm run build
```
### 从零散输入到正式成果
更多验证说明见 `docs/mvp-verification.md`
森林AI 不只保存信息,还提供从 Inbox 收集、AI 分析、人工筛选到正式创建的完整整理路径,让模糊想法逐步变成可执行计划和可复用知识
### AI 辅助,用户决策
AI 负责提供建议,用户保留最终决定权。任何正式任务、笔记或资料都需要确认后创建,既获得智能辅助,也避免自动化越权和错误沉淀。
### 全局视角与项目专注并存
工作台适合掌握全局,项目空间适合深入执行。用户可以在跨项目统筹和单项目专注之间快速切换,不会失去上下文。
### 数据边界清晰
森林AI 面向私有化使用场景,强调数据自有、访问可控和项目范围内的信息组织,适合重视资料安全与内部协作边界的用户和团队。
### 轻量但完整的工作闭环
从收集、整理、计划、执行到知识沉淀,核心能力集中在一个清晰的工作台中。产品保持轻量,不以复杂流程增加使用负担。
## 产品原则
- 以项目为中心,让信息始终有明确归属。
- AI 未经用户确认,不创建正式内容。
- 项目标签和资料保持在各自项目范围内。
- 优先支持可信内部协作,不追求开放式公开分享。
- 保持清晰、克制和可掌控,让用户专注于真正的项目成果。

19
apps/desktop/README.md Normal file
View File

@@ -0,0 +1,19 @@
# 森林AI App
森林AI唯一的桌面客户端采用窄屏停靠布局并在 Windows 上通过系统 AppBar 预留屏幕左侧或右侧工作区。
```bash
npm install
npm run dev
npm run desktop:dev
```
生产构建:
```bash
npm run desktop:build
```
- Web 开发端口:`5180`
- 默认窗口宽度:`420px`,可调整
- Windows 会为面板预留工作区;最大化的其他应用不会遮挡它

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

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

View File

@@ -3,10 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>项目工作台</title>
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
<title>森林AI Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,32 @@
{
"name": "desktop",
"name": "senlinai-app",
"private": true,
"version": "0.1.0",
"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",
"dev": "tauri dev",
"build": "tauri build --no-bundle",
"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"
}
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<title>森林AI 图标</title>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#165DFF"/>
<path d="M64 23C43 23 27 40 27 61c0 13 7 24 17 30h40c10-6 17-17 17-30 0-21-16-38-37-38Z" fill="#F4F8FF"/>
<path d="M64 99V65M64 76 47 61M64 82l18-16M64 68l12-13" fill="none" stroke="#F4F8FF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<path d="M42 58c6-15 29-22 45-7M39 69c12-11 38-13 50 2M48 78c9-5 24-6 33-1" fill="none" stroke="#165DFF" stroke-linecap="round" stroke-width="5"/>
<circle cx="47" cy="61" r="6" fill="#14B8A6"/>
<circle cx="76" cy="55" r="6" fill="#60A5FA"/>
<circle cx="82" cy="66" r="6" fill="#A7F3D0"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

View 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'))

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
[package]
name = "senlinai_workbench"
version = "0.1.0"
description = "SenlinAI project workbench desktop shell"
authors = ["SenlinAI"]
name = "senlinai-app"
version = "1.0.0"
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",
] }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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("error while running SenlinAI Workbench");
.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();
}
});
}

View File

@@ -1,3 +1,5 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
fn main() {
senlinai_workbench_lib::run()
senlinai_app_lib::run();
}

View 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()
}
}

View File

@@ -1,29 +1,42 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "SenlinAI Workbench",
"version": "0.1.0",
"identifier": "ai.senlin.workbench",
"productName": "森林AI App",
"version": "1.0.0",
"identifier": "ai.senlin.client",
"build": {
"frontendDist": "../../web/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../web && npm run dev",
"beforeBuildCommand": "cd ../web && npm run build"
"frontendDist": "../dist",
"devUrl": "http://localhost:5180",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "项目工作台",
"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": {
"active": true,
"targets": "all",
"icon": ["icons/icon.ico"]
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

316
apps/desktop/src/App.tsx Normal file
View 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
View 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
View 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>,
)

View 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); } }

View File

@@ -1,2 +1 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
host: '127.0.0.1',
port: 5180,
strictPort: true,
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<title>森林AI 图标</title>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#165DFF"/>
<path d="M64 23C43 23 27 40 27 61c0 13 7 24 17 30h40c10-6 17-17 17-30 0-21-16-38-37-38Z" fill="#F4F8FF"/>
<path d="M64 99V65M64 76 47 61M64 82l18-16M64 68l12-13" fill="none" stroke="#F4F8FF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<path d="M42 58c6-15 29-22 45-7M39 69c12-11 38-13 50 2M48 78c9-5 24-6 33-1" fill="none" stroke="#165DFF" stroke-linecap="round" stroke-width="5"/>
<circle cx="47" cy="61" r="6" fill="#14B8A6"/>
<circle cx="76" cy="55" r="6" fill="#60A5FA"/>
<circle cx="82" cy="66" r="6" fill="#A7F3D0"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

39
apps/site/design-qa.md Normal file
View File

@@ -0,0 +1,39 @@
# Design QA
## Comparison target
- Source visual truth: `C:\Users\david\.codex\generated_images\019f92c1-c086-7b62-9e3e-8942418c08bb\exec-92a2158c-b865-4c30-a1af-bd75a8410bd2.png` (the selected final design, option 1).
- Supporting product references: `docs/screen/login.png`, `docs/screen/workspace.png`, `docs/screen/export.png`, `docs/screen/p3.png`, and `docs/screen/p4.png`.
- Intended implementation: `apps/site/index.html` at a desktop viewport matching the selected long-form landing page.
- Implementation screenshot: unavailable.
## Evidence and state
- Source assets were opened and inspected before implementation.
- The page uses the supplied official logo and product screenshots rather than recreated logo or product-preview artwork.
- Primary interactions in code: “下载” scrolls to the download section; “WEB 登录” links to `https://agent.senlin.ai`.
- Static resource validation passed: all six referenced assets exist and the WEB login URL is present.
- Backend tests and the existing `web_v1` production build passed.
## Required fidelity surfaces
- Fonts and typography: implemented with the existing Chinese system-font stack, a high-contrast display hierarchy, and compact utility text aligned with the source system.
- Spacing and layout rhythm: implemented as an 1440px-centered desktop grid with a left text / right product-preview hero and responsive single-column fallback.
- Colors and visual tokens: uses the source products cobalt `#165DFF`, pale gray workspace surfaces, thin blue-gray separators, and navy footer/status treatment.
- Image quality and asset fidelity: uses supplied SVG logo and supplied product captures for workbench, exploration, documents, and AI conversation.
- Copy and content: includes project context, exploration data, project documents, expert AI conversations, private deployment, and human confirmation.
## Findings
- [P1] Browser-rendered visual comparison is unavailable.
- Evidence: the in-app browser could not initialize in this environment because of a system path error, so no implementation screenshot or same-viewport composite comparison could be captured.
- Impact: typography, crop, spacing and responsive behavior have not been verified against the selected visual target in a browser.
- Fix: restore in-app browser access, capture the homepage at the selected desktop viewport, compare it side-by-side with the source visual, and resolve any P0/P1/P2 differences.
## Comparison history
- Initial pass: blocked before browser capture; no visual comparison iteration is available.
## Final result
final result: blocked

107
apps/site/index.html Normal file
View File

@@ -0,0 +1,107 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="森林AI以项目为中心的私有化智能工作台。" />
<title>森林AI · 项目驱动的智能工作台</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<header class="site-header">
<a class="brand" href="#top" aria-label="森林AI首页">
<img src="./assets/senlinai-icon.svg" alt="" width="34" height="34" />
<span>森林AI</span>
</a>
<nav aria-label="主导航">
<a class="download-link" href="#download">下载</a>
<a class="login-link" href="https://agent.senlin.ai">WEB 登录</a>
</nav>
</header>
<main id="top">
<section class="hero section-shell">
<div class="hero-copy">
<p class="eyebrow">私有部署 · 团队协作 · 智能决策</p>
<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>
<a class="text-action" href="https://agent.senlin.ai">WEB 登录 <span aria-hidden="true"></span></a>
</div>
<p class="hero-note">私有部署,数据自有,安全可控</p>
</div>
<div class="hero-product" aria-label="森林AI统一工作台预览">
<img src="./assets/workspace.png" alt="森林AI统一工作台展示项目、计划、AI 会话与知识资料" />
</div>
</section>
<section class="context-section section-shell">
<div class="section-heading">
<p class="section-index">01</p>
<h2>项目即上下文,<br />让所有信息回到同一处。</h2>
<p>计划、任务、笔记、资料和对话始终归属于项目。团队不必在分散工具间反复寻找背景,工作进展与知识沉淀清晰可追溯。</p>
</div>
<div class="context-points">
<article><span>项目空间</span><p>聚合目标、计划与工作动态。</p></article>
<article><span>统一工作台</span><p>跨项目掌握待办与关键进展。</p></article>
<article><span>资料可追溯</span><p>让每一份内容保留明确归属。</p></article>
</div>
</section>
<section class="showcase section-shell">
<div class="showcase-image data-preview-grid">
<figure>
<img src="./assets/explore.png" alt="森林AI探索页面集中浏览多来源数据并沉淀到项目" />
<figcaption>探索数据</figcaption>
</figure>
<figure>
<img src="./assets/documents.png" alt="森林AI项目笔记资料页面集中管理项目内文档" />
<figcaption>项目资料</figcaption>
</figure>
</div>
<div class="showcase-copy">
<p class="section-index">02</p>
<h2>探索信息,<br />沉淀为项目资料。</h2>
<p>从多个外部数据源集中浏览信息,将值得保留的内容沉淀到项目。探索不止是阅读,更是为下一步决策建立可靠的资料基础。</p>
<dl>
<div><dt>探索数据</dt><dd>按来源浏览与筛选,保留信息出处。</dd></div>
<div><dt>笔记资料</dt><dd>文档、附件和 Markdown 笔记随项目持续积累。</dd></div>
</dl>
</div>
</section>
<section class="showcase showcase-reversed section-shell">
<div class="showcase-copy">
<p class="section-index">03</p>
<h2>与专家 AI 对话,<br />建议由人确认。</h2>
<p>选择合适的专业角色在项目上下文中获得分析与建议。AI 输出先作为候选草稿呈现,只有人工确认后才会成为正式任务、笔记或资料。</p>
<div class="workflow" aria-label="受控的 AI 工作流">
<span>收集</span><i aria-hidden="true"></i><span>建议</span><i aria-hidden="true"></i><strong>确认</strong>
</div>
</div>
<div class="showcase-image">
<img src="./assets/ai-session.png" alt="森林AI专家选择与项目内 AI 会话界面" />
</div>
</section>
<section class="download-section section-shell" id="download">
<div>
<p class="eyebrow">在你的环境中,安全使用专业 AI</p>
<h2>私有部署,数据自有,<br />团队协作始终可控。</h2>
</div>
<div class="download-actions">
<a class="button button-primary" href="https://agent.senlin.ai">WEB 登录</a>
<p>客户端下载包由部署管理员提供;已有工作台可直接登录 WEB 版本。</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="brand"><img src="./assets/senlinai-icon.svg" alt="" width="24" height="24" /><span>森林AI</span></div>
<span>© 森林AI</span>
</footer>
</body>
</html>

109
apps/site/styles.css Normal file
View File

@@ -0,0 +1,109 @@
:root {
color: #17233a;
background: #ffffff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
font-synthesis: none;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; background: #fff; }
a { color: inherit; text-decoration: none; }
.site-header {
align-items: center;
background: rgba(255, 255, 255, .96);
border-bottom: 1px solid #edf0f5;
display: flex;
height: 76px;
justify-content: space-between;
padding: 0 clamp(24px, 5vw, 80px);
position: sticky;
top: 0;
z-index: 5;
}
.brand { align-items: center; display: inline-flex; font-size: 20px; font-weight: 750; gap: 9px; letter-spacing: -.5px; }
.brand img { border-radius: 9px; display: block; }
nav { align-items: center; display: flex; gap: 16px; font-size: 15px; font-weight: 650; }
.download-link { border: 1px solid #bad0ff; border-radius: 6px; color: #165dff; padding: 10px 18px; }
.login-link { background: #165dff; border-radius: 6px; color: white; padding: 11px 19px; }
.download-link:hover, .text-action:hover { background: #edf4ff; }
.login-link:hover, .button-primary:hover { background: #0e4cda; }
.section-shell { margin: 0 auto; max-width: 1440px; padding-left: clamp(24px, 5vw, 80px); padding-right: clamp(24px, 5vw, 80px); }
.hero { display: grid; gap: clamp(30px, 4vw, 60px); grid-template-columns: minmax(300px, .82fr) minmax(540px, 1.3fr); min-height: 610px; overflow: hidden; padding-bottom: 56px; padding-top: clamp(48px, 6vw, 88px); }
.hero-copy { align-self: center; max-width: 460px; }
.eyebrow, .section-index { color: #165dff; font-size: 14px; font-weight: 750; letter-spacing: .03em; margin: 0 0 22px; }
h1, h2 { color: #13203a; letter-spacing: -.065em; margin: 0; }
h1 { font-size: clamp(42px, 4.2vw, 62px); line-height: 1.2; }
h2 { font-size: clamp(32px, 3.2vw, 49px); line-height: 1.2; }
.hero-description, .section-heading > p:not(.section-index), .showcase-copy > p:not(.section-index) { color: #5f6d84; font-size: 17px; line-height: 1.9; margin: 28px 0; }
.hero-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 20px; margin-top: 28px; }
.button { border-radius: 6px; display: inline-flex; font-size: 16px; font-weight: 700; justify-content: center; padding: 15px 27px; transition: background .18s ease; }
.button-primary { background: #165dff; box-shadow: 0 10px 24px rgba(22, 93, 255, .18); color: #fff; }
.text-action { color: #165dff; font-size: 16px; font-weight: 700; padding: 12px 4px; }
.text-action span { font-size: 22px; margin-left: 4px; vertical-align: -1px; }
.hero-note { color: #68768d; font-size: 14px; margin: 31px 0 0; }
.hero-product { align-self: center; background: #f6f9ff; border: 1px solid #e7eefb; border-radius: 12px; box-shadow: 0 28px 55px rgba(39, 75, 130, .13); overflow: hidden; }
.hero-product img, .showcase-image img { display: block; height: auto; width: 100%; }
.context-section { border-top: 1px solid #edf0f5; display: grid; gap: 48px; grid-template-columns: minmax(300px, .9fr) minmax(450px, 1.1fr); padding-bottom: 80px; padding-top: 80px; }
.section-heading > p:not(.section-index) { max-width: 510px; }
.context-points { border-left: 1px solid #dce6f5; display: grid; grid-template-columns: repeat(3, 1fr); padding-left: 34px; }
.context-points article { padding: 7px 28px 10px; }
.context-points article + article { border-left: 1px solid #e8edf5; }
.context-points span { color: #1c2c49; font-size: 18px; font-weight: 750; }
.context-points p { color: #728098; font-size: 14px; line-height: 1.75; }
.showcase { align-items: center; background: #f7f9fd; display: grid; gap: 48px; grid-template-columns: minmax(420px, 1.15fr) minmax(300px, .85fr); justify-content: normal; margin-bottom: 0; max-width: 1440px; padding-bottom: 80px; padding-top: 80px; }
.showcase:not(.showcase-reversed) { padding-top: 96px; }
.showcase-image { background: #fff; border: 1px solid #e4eaf3; box-shadow: 0 22px 48px rgba(30, 55, 95, .09); overflow: hidden; }
.data-preview-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); justify-self: stretch; max-width: none; padding: 12px; width: 100%; }
.data-preview-grid figure { margin: 0; min-width: 0; }
.data-preview-grid figcaption { color: #61718a; font-size: 13px; font-weight: 700; padding: 9px 2px 1px; }
.showcase-copy { max-width: 460px; }
.showcase-copy dl { border-top: 1px solid #dce4ef; margin: 32px 0 0; }
.showcase-copy dl div { border-bottom: 1px solid #dce4ef; padding: 15px 0; }
dt { color: #1b2a47; font-size: 16px; font-weight: 750; }
dd { color: #718097; font-size: 14px; margin: 7px 0 0; }
.showcase-reversed { background: #fff; grid-template-columns: minmax(300px, .85fr) minmax(420px, 1.15fr); }
.workflow { align-items: center; display: flex; gap: 12px; margin-top: 34px; }
.workflow span, .workflow strong { border: 1px solid #dbe5f5; border-radius: 5px; color: #587093; font-size: 14px; padding: 9px 13px; }
.workflow strong { background: #165dff; border-color: #165dff; color: #fff; }
.workflow i { background: #9ebdff; height: 1px; width: 22px; }
.download-section { align-items: center; background: #eff5ff; display: flex; justify-content: space-between; max-width: none; padding-bottom: 64px; padding-top: 64px; }
.download-section h2 { font-size: clamp(31px, 3.2vw, 47px); }
.download-actions { max-width: 315px; }
.download-actions p { color: #6c7b92; font-size: 14px; line-height: 1.7; margin: 16px 0 0; }
.site-footer { align-items: center; background: #13213a; color: #fff; display: flex; font-size: 13px; justify-content: space-between; padding: 24px clamp(24px, 5vw, 80px); }
.site-footer .brand { font-size: 15px; }
@media (max-width: 980px) {
.hero, .context-section, .showcase, .showcase-reversed { grid-template-columns: 1fr; }
.hero { min-height: auto; padding-bottom: 74px; }
.hero-copy, .showcase-copy { max-width: 620px; }
.context-section { gap: 36px; }
.context-points { padding-left: 0; }
.showcase { gap: 46px; }
.showcase-reversed .showcase-copy { grid-row: 1; }
}
@media (max-width: 640px) {
.site-header { height: 66px; padding: 0 18px; }
.brand { font-size: 17px; gap: 7px; }
nav { gap: 8px; font-size: 13px; }
.download-link, .login-link { padding: 8px 10px; }
.section-shell { padding-left: 20px; padding-right: 20px; }
.hero { padding-top: 60px; }
h1 { font-size: 43px; }
.context-section, .showcase { padding-bottom: 72px; padding-top: 72px; }
.showcase:not(.showcase-reversed) { padding-top: 72px; }
.context-points { border-left: 0; grid-template-columns: 1fr; }
.context-points article { border-bottom: 1px solid #e8edf5; padding: 18px 0; }
.context-points article + article { border-left: 0; }
.download-section { align-items: flex-start; flex-direction: column; gap: 30px; padding-bottom: 64px; padding-top: 64px; }
.workflow { flex-wrap: wrap; }
.data-preview-grid { grid-template-columns: 1fr; }
}

View File

@@ -1,14 +0,0 @@
# SenlinAI Web
Svelte + TypeScript web client for the project workbench.
## Scripts
```powershell
npm run dev
npm test -- --run
npm run build
npx playwright test
```
The login shell lets users enter the server IP address or domain name used as the API base URL.

View File

@@ -1,79 +0,0 @@
import { expect, test } from '@playwright/test';
test('login and project channel workbench flow', async ({ page }) => {
await page.goto('/');
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
await page.getByLabel('Server IP or domain').fill('localhost:8080');
await page.getByLabel('Email or username').fill('david@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByLabel('Project list')).toBeVisible();
await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
await page.getByRole('button', { name: 'Work Plan 8' }).click();
await expect(page.getByText('Tasks')).toBeVisible();
await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
await page.getByRole('button', { name: 'Project A2' }).click();
await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByText('Ops Dashboard')).toBeVisible();
await page.getByRole('button', { name: 'Ops Dashboard' }).click();
await expect(page.getByText('Open external channel')).toBeVisible();
});
for (const server of [
'https://user:secret@example.com',
'http:////example.com',
'http//example.com',
'https//example.com',
'http:/example.com',
'https:/example.com',
'http://example.com\\path',
'http://example..com',
'http://-bad.com',
]) {
test(`rejects malformed server address: ${server}`, async ({ page }) => {
await page.goto('/');
await page.getByLabel('Server IP or domain').fill(server);
await page.getByLabel('Email or username').fill('david@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
});
}
test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByLabel('Server IP or domain').fill('localhost:8080');
await page.getByLabel('Email or username').fill('david@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
const rail = page.getByLabel('Project list');
const sidebar = page.getByLabel('Project channels');
const stage = page.getByLabel('Channel content');
const inspector = page.getByLabel('Object inspector');
await expect(rail).toHaveCSS('grid-auto-flow', 'column');
await expect(rail).toHaveCSS('max-height', '76px');
await expect(inspector).toHaveCSS('max-height', '288px');
const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
await expect(workPlan).toBeVisible();
await expect(workPlan).toBeInViewport();
await workPlan.click();
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"svelte": "^5.42.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.9",
"@tsconfig/svelte": "^5.0.5",
"jsdom": "^27.3.0",
"typescript": "~6.0.2",
"vite": "^7.2.7",
"vitest": "^4.0.16"
}
}

View File

@@ -1,20 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://127.0.0.1:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
import ServerLogin from '../features/auth/ServerLogin.svelte';
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
let currentUser: { account: string } | null = null;
function handleLogin(detail: { apiBase: string; account: string }) {
apiBase = detail.apiBase;
localStorage.setItem('apiBase', apiBase);
currentUser = { account: detail.account };
}
</script>
{#if currentUser}
<ProjectWorkbench {currentUser} />
{:else}
<ServerLogin {apiBase} onLogin={handleLogin} />
{/if}

View File

@@ -1,10 +0,0 @@
export type WorkspaceTab = {
key: string;
title: string;
route: string;
};
export function openTab(existing: WorkspaceTab[], next: WorkspaceTab): WorkspaceTab[] {
if (existing.some((tab) => tab.key === next.key)) return existing;
return [...existing, next];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,18 +0,0 @@
<script lang="ts">
export type AISessionCard = {
id: number;
title: string;
updatedAt: string;
};
export let sessions: AISessionCard[] = [];
</script>
<section aria-label="AI 会话" class="ai-session-grid">
{#each sessions as session}
<button class="ai-session-card" type="button">
<strong>{session.title}</strong>
<span>{session.updatedAt}</span>
</button>
{/each}
</section>

View File

@@ -1,134 +0,0 @@
<script lang="ts">
export let apiBase: string;
export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
let server = apiBase;
let account = '';
let password = '';
let error = '';
let connectionStatus = 'Not connected. Enter a server address to continue.';
function normalizeServer(value: string): { apiBase?: string; error?: string } {
if (!value.trim()) {
return { error: 'Enter a server address.' };
}
if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`;
if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
try {
const parsed = new URL(candidate);
if (
!parsed.hostname ||
parsed.username ||
parsed.password ||
!['http:', 'https:'].includes(parsed.protocol) ||
!isValidHostname(parsed.hostname)
) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
return { apiBase: candidate };
} catch {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
}
function hasWhitespace(value: string): boolean {
if (/\s/u.test(value)) return true;
try {
return /\s/u.test(decodeURIComponent(value));
} catch {
return true;
}
}
function hasUnsupportedProtocol(value: string): boolean {
const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i);
if (explicitProtocol) {
return !['http', 'https'].includes(explicitProtocol[1].toLowerCase());
}
return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
}
function hasMalformedHttpProtocol(value: string): boolean {
return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value);
}
function hasCredentials(value: string): boolean {
const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
return authority.includes('@');
}
function isValidHostname(hostname: string): boolean {
if (hostname === 'localhost') return true;
if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true;
if (/^\d+(?:\.\d+){3}$/.test(hostname)) {
return hostname.split('.').every((segment) => Number(segment) <= 255);
}
return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label));
}
function submitLogin() {
error = '';
const normalized = normalizeServer(server);
if (normalized.error) {
error = normalized.error;
connectionStatus = 'Check the server address before signing in.';
return;
}
if (!account.trim() || !password.trim()) {
error = 'Enter an account and password.';
return;
}
server = normalized.apiBase ?? server;
connectionStatus = 'Connection settings are ready.';
onLogin({ apiBase: server, account: account.trim() });
}
</script>
<main class="login-page">
<section class="login-panel" aria-labelledby="login-title">
<div class="login-brand" aria-hidden="true">SA</div>
<div class="login-heading">
<p>Private workbench</p>
<h1 id="login-title">SenlinAI Workbench</h1>
</div>
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
<label for="server-address">Server IP or domain</label>
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
<label for="login-account">Email or username</label>
<input id="login-account" name="account" bind:value={account} autocomplete="username" />
<label for="login-password">Password</label>
<input
id="login-password"
name="password"
type="password"
bind:value={password}
autocomplete="current-password"
/>
{#if error}
<p class="form-error" aria-live="polite">{error}</p>
{/if}
<p class="connection-status" role="status">{connectionStatus}</p>
<button type="submit">Log in</button>
<p class="login-note">The server address is remembered on this device.</p>
</form>
</section>
</main>

View File

@@ -1,102 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ServerLogin from './ServerLogin.svelte';
describe('ServerLogin', () => {
it('renders server, account, and password fields', () => {
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
});
it('normalizes server address and emits login details', async () => {
const onLogin = vi.fn();
render(ServerLogin, {
props: {
apiBase: 'localhost:8080',
onLogin,
},
});
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
target: { value: '10.0.0.12:8080' },
});
await fireEvent.input(screen.getByLabelText('Email or username'), {
target: { value: 'david@example.com' },
});
await fireEvent.input(screen.getByLabelText('Password'), {
target: { value: 'secret' },
});
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(onLogin).toHaveBeenCalledWith({
apiBase: 'http://10.0.0.12:8080',
account: 'david@example.com',
});
});
it.each([
['localhost', 'http://localhost'],
['localhost:8080', 'http://localhost:8080'],
['192.168.1.20:3000', 'http://192.168.1.20:3000'],
['example.com:8443', 'http://example.com:8443'],
['https://example.com', 'https://example.com'],
])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
const onLogin = vi.fn();
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' });
});
it('shows an error when account or password is missing', async () => {
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
});
it.each([
['', 'Enter a server address.'],
['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
[' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://', 'Enter a valid HTTP or HTTPS server address.'],
['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
])('rejects an invalid server value: %s', async (server, error) => {
const onLogin = vi.fn();
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText(error)).toBeInTheDocument();
expect(onLogin).not.toHaveBeenCalled();
});
});

View File

@@ -1,32 +0,0 @@
<script lang="ts">
export type ProjectOption = {
id: number;
name: string;
};
export let projects: ProjectOption[] = [];
export let activeProjectID: number | undefined = undefined;
export let onSubmit: (input: { projectID: number; body: string }) => void = () => {};
let projectID = activeProjectID ?? projects[0]?.id ?? 0;
let body = '';
$: if (activeProjectID && projectID !== activeProjectID) {
projectID = activeProjectID;
}
function submit() {
onSubmit({ projectID, body });
body = '';
}
</script>
<form aria-label="快速收集" class="quick-capture" on:submit|preventDefault={submit}>
<select name="projectID" aria-label="目标项目" bind:value={projectID}>
{#each projects as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
<textarea name="body" aria-label="收集内容" bind:value={body}></textarea>
<button type="submit">保存</button>
</form>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
export let onCapture: (input: { sourceType: string; body: string }) => void = () => {};
let body = '';
let sourceType = 'text';
function submit() {
onCapture({ sourceType, body });
body = '';
}
</script>
<section aria-label="项目 Inbox" class="inbox-panel">
<select aria-label="收集类型" bind:value={sourceType}>
<option value="text">文本</option>
<option value="link">链接</option>
<option value="file">文件</option>
</select>
<textarea aria-label="收集内容" bind:value={body}></textarea>
<div class="toolbar">
<button type="button" on:click={submit}>保存到 Inbox</button>
<button type="button">分析/整理</button>
</div>
</section>

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import type { Suggestion } from './types';
export let suggestions: Suggestion[] = [];
export let onConfirm: (selected: Suggestion[]) => void;
let selectedIndexes: number[] = [];
function toggle(index: number) {
selectedIndexes = selectedIndexes.includes(index)
? selectedIndexes.filter((item) => item !== index)
: [...selectedIndexes, index];
}
function confirm() {
onConfirm(selectedIndexes.map((index) => suggestions[index]));
}
</script>
<section aria-label="AI 整理建议" class="suggestions">
{#each suggestions as suggestion, index}
<label class="suggestion">
<input
aria-label={`选择 ${suggestion.title}`}
type="checkbox"
checked={selectedIndexes.includes(index)}
on:change={() => toggle(index)}
/>
<span class="suggestion-body">
<strong>{suggestion.title}</strong>
<small>{suggestion.kind}</small>
<span>{suggestion.body}</span>
</span>
</label>
{/each}
<button type="button" on:click={confirm}>创建选中项</button>
</section>

View File

@@ -1,25 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import SuggestionList from './SuggestionList.svelte';
describe('SuggestionList', () => {
it('requires explicit selection before confirmation', async () => {
const onConfirm = vi.fn();
render(SuggestionList, {
props: {
suggestions: [{ kind: 'task', title: '跟进报价', body: '联系客户' }],
onConfirm,
},
});
await fireEvent.click(screen.getByText('创建选中项'));
expect(onConfirm).toHaveBeenCalledWith([]);
await fireEvent.click(screen.getByLabelText('选择 跟进报价'));
await fireEvent.click(screen.getByText('创建选中项'));
expect(onConfirm).toHaveBeenLastCalledWith([
{ kind: 'task', title: '跟进报价', body: '联系客户' },
]);
});
});

View File

@@ -1,5 +0,0 @@
export type Suggestion = {
kind: 'task' | 'note' | 'source';
title: string;
body: string;
};

View File

@@ -1,12 +0,0 @@
<script lang="ts">
export let value = '';
export let onChange: (value: string) => void = () => {};
</script>
<textarea
aria-label="Markdown 编辑器"
class="markdown-editor"
spellcheck="false"
bind:value
on:input={() => onChange(value)}
></textarea>

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import type { ProjectDashboardSummary } from '../../lib/api';
export let summary: ProjectDashboardSummary;
$: metrics = [
['待处理 Inbox', summary.pending_inbox_count],
['当前任务', summary.open_task_count],
['笔记资料', summary.recent_note_count],
['AI 会话', summary.recent_session_count],
] as const;
</script>
<section aria-label="项目总览" class="dashboard-grid">
{#each metrics as [label, value]}
<article class="dashboard-metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
{/each}
</section>

View File

@@ -1,25 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import ProjectDashboard from './ProjectDashboard.svelte';
describe('ProjectDashboard', () => {
it('shows project counters', () => {
render(ProjectDashboard, {
props: {
summary: {
project_id: 1,
pending_inbox_count: 2,
open_task_count: 5,
recent_note_count: 3,
recent_session_count: 4,
},
},
});
expect(screen.getByText('待处理 Inbox')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('当前任务')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
});

View File

@@ -1,22 +0,0 @@
<script lang="ts">
export type TaskListItem = {
id: number;
title: string;
status: 'open' | 'doing' | 'done';
assignee?: string;
};
export let tasks: TaskListItem[] = [];
</script>
<section aria-label="任务列表" class="task-list">
{#each tasks as task}
<article class="task-row">
<strong>{task.title}</strong>
<span>{task.status}</span>
{#if task.assignee}
<span>{task.assignee}</span>
{/if}
</article>
{/each}
</section>

View File

@@ -1,32 +0,0 @@
<script lang="ts">
import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types';
import OverviewChannel from './channels/OverviewChannel.svelte';
import InboxChannel from './channels/InboxChannel.svelte';
import TasksChannel from './channels/TasksChannel.svelte';
import AISessionsChannel from './channels/AISessionsChannel.svelte';
import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
import CronChannel from './channels/CronChannel.svelte';
import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
export let workspace: ProjectWorkspace;
export let tasks: WorkTask[] = workspace.tasks;
export let channel: WorkbenchChannel;
export let onInspect: (item: InspectorItem) => void;
export let onToggleTask: (taskID: string) => void = () => {};
</script>
{#if channel.type === 'overview'}
<OverviewChannel {workspace} {onInspect} />
{:else if channel.type === 'inbox'}
<InboxChannel messages={workspace.inbox} {onInspect} />
{:else if channel.type === 'tasks'}
<TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} />
{:else if channel.type === 'ai_sessions'}
<AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
{:else if channel.type === 'notes_sources'}
<NotesSourcesChannel items={workspace.notesSources} {onInspect} />
{:else if channel.type === 'cron'}
<CronChannel plans={workspace.cronPlans} {onInspect} />
{:else}
<CustomLinkChannel {channel} />
{/if}

View File

@@ -1,64 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, within } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ChannelContent from './ChannelContent.svelte';
import { getProjectWorkspace } from './mockData';
describe('ChannelContent', () => {
const workspace = getProjectWorkspace(1);
it('renders a distinctive heading or control for every channel type', () => {
const expectedContent = {
overview: { role: 'heading', name: 'Overview' },
inbox: { role: 'heading', name: 'Inbox' },
tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
ai_sessions: { role: 'heading', name: 'AI Sessions' },
notes_sources: { role: 'heading', name: 'Notes & Sources' },
cron: { role: 'heading', name: 'Cron Plans' },
custom_link: { role: 'link', name: 'Open external channel' },
} as const;
for (const type of Object.keys(expectedContent) as Array<keyof typeof expectedContent>) {
const channel = workspace.channels.find((item) => item.type === type);
if (!channel) throw new Error(`missing ${type}`);
const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
unmount();
}
});
it('sends selected task details to inspector', async () => {
let inspectedTitle = '';
const channel = workspace.channels.find((item) => item.type === 'tasks');
if (!channel) throw new Error('missing task channel');
render(ChannelContent, {
props: {
workspace,
channel,
onInspect: (item) => {
inspectedTitle = item.title;
},
},
});
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(inspectedTitle).toBe('Confirm homepage information architecture');
});
it('reports task completion changes to its owner', async () => {
const channel = workspace.channels.find((item) => item.type === 'tasks');
if (!channel) throw new Error('missing task channel');
const onToggleTask = vi.fn();
render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
expect(completion).not.toBeChecked();
await fireEvent.click(completion);
expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
});
});

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import type { InspectorItem } from './types';
export let item: InspectorItem | null;
let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
let copyFeedback = '';
let copyFailed = false;
let currentItem: InspectorItem | null = null;
$: if (item !== currentItem) {
currentItem = item;
copyFeedback = '';
copyFailed = false;
}
async function copyLink() {
const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString();
if (!navigator.clipboard?.writeText) {
copyFailed = true;
copyFeedback = 'Clipboard access is unavailable.';
return;
}
try {
await navigator.clipboard.writeText(link);
copyFailed = false;
copyFeedback = 'Link copied to clipboard.';
} catch {
copyFailed = true;
copyFeedback = 'Unable to copy link.';
}
}
</script>
<aside class="object-inspector" aria-label="Object inspector">
<div class="inspector-tabs">
<button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
<button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
<button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
</div>
{#if item}
<h2>{item.title}</h2>
<p>{item.description}</p>
{#if activeTab === 'discussion'}
<p>No comments yet. Add project discussion here later.</p>
{:else if activeTab === 'properties'}
<dl>
{#each item.properties as property}
<div>
<dt>{property.label}</dt>
<dd>{property.value}</dd>
</div>
{/each}
</dl>
{:else}
<button type="button" on:click={copyLink}>Copy link</button>
{#if copyFeedback}
<p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p>
{/if}
<button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button>
{/if}
{:else}
<h2>Inspector</h2>
<p>Select an item to inspect discussion, properties, and actions.</p>
{/if}
</aside>

View File

@@ -1,73 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ObjectInspector from './ObjectInspector.svelte';
const item = {
title: 'Review navigation',
type: 'Task',
description: 'Check the project navigation flow.',
properties: [],
};
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
function setClipboard(writeText: Clipboard['writeText']) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText } as Clipboard,
});
}
afterEach(() => {
cleanup();
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
describe('ObjectInspector', () => {
async function openMoreActions() {
await fireEvent.click(screen.getByRole('button', { name: 'More' }));
}
it('shows copy success only after clipboard writing resolves', async () => {
let resolveWrite: (() => void) | undefined;
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve;
}),
);
setClipboard(writeText);
render(ObjectInspector, { props: { item } });
await openMoreActions();
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
resolveWrite?.();
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
});
it('reports a copy failure when clipboard writing rejects', async () => {
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
render(ObjectInspector, { props: { item } });
await openMoreActions();
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
});
it('reports unavailable clipboard access without showing success', async () => {
Reflect.deleteProperty(navigator, 'clipboard');
render(ObjectInspector, { props: { item } });
await openMoreActions();
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
});
});

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
export let project: WorkbenchProject;
export let channels: WorkbenchChannel[];
export let selectedChannelID: string;
export let tags: string[];
export let recentSessions: AISessionItem[];
export let onSelectChannel: (channelID: string) => void;
const channelIcons: Record<WorkbenchChannel['icon'], string> = {
home: '\u2302',
mail: '\u2709',
list: '\u2630',
sparkles: '\u2726',
file: '\u25A4',
clock: '\u25F7',
link: '\u21AA',
};
$: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder);
</script>
<aside class="channel-sidebar" aria-label="Project channels">
<header>
<div>
<p>Current project</p>
<h2>{project.name}</h2>
</div>
<button aria-label="Project settings" disabled>&#9881;</button>
</header>
<ul class="tag-row" aria-label="Project tags">
{#each tags as tag}
<li>#{tag}</li>
{/each}
</ul>
<section class="channel-group">
{#each sortedChannels as channel}
<button
class:active={channel.id === selectedChannelID}
aria-pressed={channel.id === selectedChannelID}
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
on:click={() => onSelectChannel(channel.id)}
>
<span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span>
<span>{channel.title}</span>
{#if channel.count !== undefined}
<small>{channel.count}</small>
{/if}
</button>
{/each}
</section>
<section class="recent-sessions" aria-label="Recent sessions">
<h3>Recent sessions</h3>
<ul>
{#each recentSessions as session}
<li>
<span>{session.title}</span>
<small>{session.updatedAt}</small>
</li>
{/each}
</ul>
</section>
</aside>

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import type { WorkbenchProject } from './types';
export let projects: WorkbenchProject[];
export let selectedProjectID: number;
export let onSelectProject: (projectID: number) => void;
</script>
<nav class="project-rail" aria-label="Project list">
<button class="rail-logo" aria-label="Dashboard" disabled>SA</button>
{#each projects as project}
<button
class:active={project.id === selectedProjectID}
aria-pressed={project.id === selectedProjectID}
aria-label={project.name}
title={project.name}
on:click={() => onSelectProject(project.id)}
>
<span>{project.initials}</span>
{#if project.unreadCount}
<small>{project.unreadCount}</small>
{/if}
</button>
{/each}
<button class="add-project" aria-label="Create project" disabled>+</button>
</nav>

View File

@@ -1,61 +0,0 @@
<script lang="ts">
import { getProjectWorkspace, workbenchProjects } from './mockData';
import ChannelContent from './ChannelContent.svelte';
import ObjectInspector from './ObjectInspector.svelte';
import ProjectRail from './ProjectRail.svelte';
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
import type { InspectorItem, WorkTask } from './types';
export let currentUser: { account: string };
let selectedProjectID = workbenchProjects[0].id;
let selectedItem: InspectorItem | null = null;
let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries(
workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]),
);
$: workspace = getProjectWorkspace(selectedProjectID);
$: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks;
$: selectedChannelID = workspace.channels[0].id;
$: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
function selectProject(projectID: number) {
selectedItem = null;
selectedProjectID = projectID;
}
function selectChannel(channelID: string) {
selectedItem = null;
selectedChannelID = channelID;
}
function inspectItem(item: InspectorItem) {
selectedItem = item;
}
function toggleTask(taskID: string) {
tasksByProjectID = {
...tasksByProjectID,
[selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
};
}
</script>
<main class="workbench-shell">
<WorkspaceTopbar account={currentUser.account} />
<div class="workbench-body">
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
<ProjectChannelSidebar
project={workspace.project}
channels={workspace.channels}
{selectedChannelID}
tags={workspace.tags}
recentSessions={workspace.recentSessions}
onSelectChannel={selectChannel}
/>
<section class="channel-stage" aria-label="Channel content">
<ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
</section>
<ObjectInspector item={selectedItem} />
</div>
</main>

View File

@@ -1,113 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ProjectWorkbench from './ProjectWorkbench.svelte';
describe('ProjectWorkbench', () => {
it('renders project rail, channel sidebar, topbar, and inspector', () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
expect(screen.getByLabelText('Global search')).toBeDisabled();
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
});
it('refreshes channels when switching projects', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
});
it('marks active channel with aria-pressed', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
});
it('shows inspected task details in the inspector', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
expect(within(inspector).getByText('Status')).toBeInTheDocument();
expect(within(inspector).getByText('Open')).toBeInTheDocument();
expect(within(inspector).getByText('Owner')).toBeInTheDocument();
expect(within(inspector).getByText('David')).toBeInTheDocument();
});
it('clears the inspector when the project or channel changes', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
});
it('preserves task completion when switching channels within a project', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
await fireEvent.click(task);
expect(task).toBeChecked();
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
});
it('provides Copy link feedback and disables unavailable archive actions', async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard,
});
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
});

View File

@@ -1,16 +0,0 @@
<script lang="ts">
export let account: string;
</script>
<header class="workspace-topbar">
<div class="brand-mark">SenlinAI</div>
<label class="search-box">
<span>Search</span>
<input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled />
</label>
<div class="topbar-actions">
<button aria-label="Back" disabled>&#8592;</button>
<button aria-label="Forward" disabled>&#8594;</button>
<button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
</div>
</header>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import type { AISessionItem, InspectorItem } from '../types';
export let sessions: AISessionItem[];
export let onInspect: (item: InspectorItem) => void;
let selectedSessionID = sessions[0]?.id;
$: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
function inspectSession(session: AISessionItem) {
onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
}
</script>
<section class="channel-page ai-sessions-channel">
<header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
<div class="session-layout">
<div class="session-list">
{#each sessions as session}
<button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
{/each}
</div>
{#if selectedSession}
<article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
{/if}
</div>
</section>

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import type { CronPlan, InspectorItem } from '../types';
export let plans: CronPlan[];
export let onInspect: (item: InspectorItem) => void;
function inspectPlan(plan: CronPlan) {
onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
}
</script>
<section class="channel-page cron-channel">
<header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
<div class="cron-list">
{#each plans as plan}
<article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
{/each}
</div>
</section>

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import type { WorkbenchChannel } from '../types';
export let channel: WorkbenchChannel;
let copyStatus = '';
let copyFailed = false;
async function copyUrl() {
if (!navigator.clipboard?.writeText) {
copyFailed = true;
copyStatus = 'Copy unavailable in this browser.';
return;
}
try {
await navigator.clipboard.writeText(channel.url ?? '');
copyFailed = false;
copyStatus = 'URL copied.';
} catch {
copyFailed = true;
copyStatus = 'Unable to copy URL.';
}
}
</script>
<section class="channel-page custom-link-channel">
<header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
<p>{channel.url}</p>
<a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
<button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button>
{#if copyStatus}
<p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p>
{/if}
</section>

View File

@@ -1,70 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import CustomLinkChannel from './CustomLinkChannel.svelte';
const channel = {
id: 'project-custom-link',
projectID: 1,
type: 'custom_link' as const,
title: 'Roadmap Board',
icon: 'link',
url: 'https://example.com/roadmap',
sortOrder: 1,
};
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
function setClipboard(writeText: Clipboard['writeText']) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText } as Clipboard,
});
}
afterEach(() => {
cleanup();
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
describe('CustomLinkChannel', () => {
it('shows copy success only after clipboard writing resolves', async () => {
let resolveWrite: (() => void) | undefined;
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve;
}),
);
setClipboard(writeText);
render(CustomLinkChannel, { props: { channel } });
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
resolveWrite?.();
await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
});
it('reports a copy failure when clipboard writing rejects', async () => {
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
render(CustomLinkChannel, { props: { channel } });
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
});
it('reports unavailable clipboard access without showing URL copied', async () => {
Reflect.deleteProperty(navigator, 'clipboard');
render(CustomLinkChannel, { props: { channel } });
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
});
});

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import type { InboxMessage, InspectorItem } from '../types';
export let messages: InboxMessage[];
export let onInspect: (item: InspectorItem) => void;
function inspectMessage(message: InboxMessage) {
onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
}
</script>
<section class="channel-page inbox-channel">
<header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
<div class="message-list">
{#each messages as message}
<article class="message-row">
<div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
<button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
</article>
{/each}
</div>
</section>

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import type { InspectorItem, NoteSourceItem } from '../types';
export let items: NoteSourceItem[];
export let onInspect: (item: InspectorItem) => void;
function inspectItem(item: NoteSourceItem) {
onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
}
</script>
<section class="channel-page notes-sources-channel">
<header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
<div class="file-list">
{#each items as item}
<article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
{/each}
</div>
</section>

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import type { InspectorItem, ProjectWorkspace } from '../types';
export let workspace: ProjectWorkspace;
export let onInspect: (item: InspectorItem) => void;
const metrics = (workspace: ProjectWorkspace) => [
{ label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
{ label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
{ label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
{ label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
{ label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
];
</script>
<section class="channel-page overview-channel">
<header class="channel-header">
<p>{workspace.project.name}</p>
<h1>Overview</h1>
</header>
<div class="metric-list">
{#each metrics(workspace) as metric}
<button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
<strong>{metric.value}</strong>
<span>{metric.label}</span>
</button>
{/each}
</div>
</section>

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import type { InspectorItem, WorkTask } from '../types';
export let tasks: WorkTask[];
export let onInspect: (item: InspectorItem) => void;
export let onToggleTask: (taskID: string) => void = () => {};
function inspectTask(task: WorkTask) {
onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
}
</script>
<section class="channel-page tasks-channel">
<header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
<div class="task-list">
{#each tasks as task}
<article class:completed={task.completed} class="task-card">
<input
class="task-check"
type="checkbox"
aria-label={`Mark ${task.title} complete`}
checked={task.completed}
on:change={() => onToggleTask(task.id)}
/>
<div>
<h2>{task.title}</h2>
<p>{task.summary}</p>
<small>{task.owner} / {task.due} / #{task.tag}</small>
<span class="task-status">{task.completed ? 'Completed' : 'Open'}</span>
</div>
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
</article>
{/each}
</div>
</section>

View File

@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getProjectWorkspace, workbenchProjects } from './mockData';
describe('workbench mock data', () => {
it('provides dynamic projects', () => {
expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
});
it('loads project-specific channels and counts', () => {
const first = getProjectWorkspace(workbenchProjects[0].id);
const second = getProjectWorkspace(workbenchProjects[1].id);
expect(first.project.id).not.toBe(second.project.id);
expect(first.channels.map((channel) => channel.type)).toEqual([
'overview',
'inbox',
'tasks',
'ai_sessions',
'notes_sources',
'cron',
'custom_link',
]);
expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
title: expect.any(String),
url: expect.stringMatching(/^https?:\/\//),
});
});
});

View File

@@ -1,61 +0,0 @@
import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
export const workbenchProjects: WorkbenchProject[] = [
{ id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
{ id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
];
function systemChannels(projectID: number): WorkbenchChannel[] {
return [
{ id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
{ id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
{ id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
{ id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
{ id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
{ id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
{
id: `${projectID}-custom-roadmap`,
projectID,
type: 'custom_link',
title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
icon: 'link',
url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
sortOrder: 7,
},
];
}
const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
project,
channels: systemChannels(project.id),
tags: ['all', 'tag1', 'UI', 'knowledge'],
recentSessions: [
{ id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
{ id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
],
inbox: [
{ id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
{ id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
],
tasks: [
{ id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
{ id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
],
aiSessions: [
{ id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
{ id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
],
notesSources: [
{ id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
{ id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
{ id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
],
cronPlans: [
{ id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
{ id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
],
}));
export function getProjectWorkspace(projectID: number): ProjectWorkspace {
return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
}

View File

@@ -1,93 +0,0 @@
export type ChannelType =
| 'overview'
| 'inbox'
| 'tasks'
| 'ai_sessions'
| 'notes_sources'
| 'cron'
| 'custom_link';
export type WorkbenchProject = {
id: number;
name: string;
description: string;
initials: string;
unreadCount: number;
};
export type WorkbenchChannel = {
id: string;
projectID: number;
type: ChannelType;
title: string;
icon: string;
count?: number;
url?: string;
sortOrder: number;
};
export type InboxMessage = {
id: string;
source: string;
title: string;
summary: string;
status: 'open' | 'processed' | 'archived';
tag: string;
time: string;
};
export type WorkTask = {
id: string;
title: string;
summary: string;
completed: boolean;
owner: string;
due: string;
tag: string;
};
export type AISessionItem = {
id: string;
title: string;
summary: string;
updatedAt: string;
references: string[];
};
export type NoteSourceItem = {
id: string;
kind: 'note' | 'file' | 'link';
title: string;
updatedAt: string;
tag: string;
source: string;
};
export type CronPlan = {
id: string;
title: string;
schedule: string;
nextRun: string;
enabled: boolean;
lastResult: string;
owner: string;
};
export type InspectorItem = {
title: string;
type: string;
description: string;
properties: Array<{ label: string; value: string }>;
};
export type ProjectWorkspace = {
project: WorkbenchProject;
channels: WorkbenchChannel[];
tags: string[];
recentSessions: AISessionItem[];
inbox: InboxMessage[];
tasks: WorkTask[];
aiSessions: AISessionItem[];
notesSources: NoteSourceItem[];
cronPlans: CronPlan[];
};

View File

@@ -1,357 +0,0 @@
:root {
color: #18181b;
background: #f4f4f5;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
--background: #f4f4f5;
--panel: #ffffff;
--panel-muted: #fafafa;
--border: #d4d4d8;
--text: #18181b;
--muted: #71717a;
--primary: #0f7ae5;
--primary-strong: #0969c8;
--success: #15803d;
--danger: #b91c1c;
--radius: 8px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: var(--background);
}
.login-panel {
width: min(420px, 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
padding: 24px;
}
.server-login,
.login-heading,
.channel-page,
.object-inspector,
.channel-sidebar {
display: grid;
gap: 12px;
}
.server-login input,
.search-box input {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 9px 10px;
background: white;
}
.server-login button,
.primary-action {
border: 1px solid var(--primary);
border-radius: 6px;
background: var(--primary);
color: white;
padding: 9px 12px;
}
.connection-status,
.login-note,
.form-error,
.channel-header p,
.channel-page small,
.channel-page > p,
.object-inspector > p {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.form-error { color: var(--danger); }
.workbench-shell {
min-height: 100vh;
display: grid;
grid-template-rows: 64px 1fr;
}
.workspace-topbar {
display: grid;
grid-template-columns: 160px minmax(240px, 1fr) auto;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border);
background: var(--panel);
padding: 0 16px;
}
.brand-mark { font-weight: 700; }
.search-box { display: grid; gap: 4px; min-width: 0; }
.search-box span { color: var(--muted); font-size: 0.75rem; }
.topbar-actions { display: flex; gap: 8px; }
.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
.workbench-body {
min-height: 0;
display: grid;
grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
}
.project-rail,
.channel-sidebar,
.object-inspector {
border-right: 1px solid var(--border);
background: var(--panel);
}
.project-rail {
display: grid;
align-content: start;
gap: 10px;
padding: 12px;
}
.project-rail button {
min-width: 48px;
min-height: 48px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-muted);
}
.project-rail button.active,
.channel-group button.active {
border-color: var(--primary);
color: var(--primary);
background: #eff6ff;
}
.project-rail small { color: var(--muted); font-size: 0.6875rem; }
.channel-sidebar,
.object-inspector,
.channel-stage {
padding: 16px;
overflow: auto;
}
.channel-sidebar {
align-content: start;
justify-items: start;
}
.channel-sidebar > header,
.channel-sidebar > .channel-group,
.channel-sidebar > .recent-sessions {
width: 100%;
}
.channel-group,
.task-list,
.record-list,
.message-list,
.file-list,
.cron-list {
display: grid;
gap: 8px;
}
.channel-group button,
.task-card,
.record-row,
.cron-row,
.message-row,
.file-row,
.conversation-summary,
.metric-card {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
padding: 10px 12px;
}
.task-card,
.record-row,
.cron-row,
.message-row,
.file-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
}
.task-card.completed {
color: var(--muted);
background: var(--panel-muted);
}
.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.channel-sidebar header p,
.channel-sidebar h2,
.channel-sidebar h3 { margin: 0; }
.channel-sidebar h2 { font-size: 1rem; }
.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
.tag-row,
.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
.channel-group small { color: var(--muted); }
.channel-icon { color: var(--muted); text-align: center; }
.recent-sessions { display: grid; gap: 8px; }
.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
.channel-stage { background: var(--background); }
.channel-page { align-content: start; }
.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.channel-header h1,
.channel-page h2 { margin: 0; }
.channel-header h1 { font-size: 1.25rem; }
.channel-page h2 { font-size: 0.9375rem; }
.channel-page h2 + p { margin: 4px 0; }
.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.metric-card { display: grid; gap: 4px; text-align: left; }
.metric-card strong { font-size: 1.5rem; }
.metric-card span { color: var(--muted); font-size: 0.8125rem; }
.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
.message-row p,
.cron-row p,
.conversation-summary p { margin: 4px 0; }
.message-row button,
.file-row button,
.task-card > button,
.cron-row button,
.conversation-summary button,
.copy-url-button,
.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
.session-list { display: grid; align-content: start; gap: 6px; }
.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
.session-list button.active { border-color: var(--primary); background: #eff6ff; }
.session-list small { color: var(--muted); }
.conversation-summary { align-content: start; }
.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
.copy-url-button { width: max-content; }
.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
.copy-feedback.copy-error { color: var(--danger); }
.object-inspector {
align-content: start;
grid-auto-rows: min-content;
}
.inspector-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: start;
grid-auto-rows: min-content;
gap: 4px;
}
.inspector-tabs button[aria-pressed='true'] {
border-color: var(--primary);
color: var(--primary);
}
@media (max-width: 920px) {
.workspace-topbar {
grid-template-columns: minmax(0, 1fr) auto;
height: auto;
padding: 12px;
}
.search-box { grid-column: 1 / -1; grid-row: 2; }
.topbar-actions { grid-column: 2; grid-row: 1; }
.workbench-shell {
grid-template-rows: auto 1fr;
}
.workbench-body {
grid-template-columns: 1fr;
align-content: start;
}
.project-rail,
.channel-sidebar,
.object-inspector {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.project-rail {
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: 48px;
align-items: center;
max-height: 76px;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 12px;
}
.project-rail button { min-width: 48px; min-height: 48px; }
.channel-sidebar {
align-content: start;
max-height: 220px;
padding: 12px;
}
.channel-group,
.tag-row,
.recent-sessions ul {
grid-auto-flow: column;
grid-auto-columns: minmax(132px, max-content);
grid-template-columns: none;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
}
.channel-group button { min-width: 160px; }
.channel-group {
grid-auto-flow: row;
grid-auto-columns: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: visible;
}
.channel-group button {
min-width: 0;
min-height: 48px;
grid-template-columns: 18px minmax(0, 1fr);
padding: 8px;
}
.channel-group button > span:last-of-type {
overflow: hidden;
text-overflow: ellipsis;
}
.channel-group small { display: none; }
.recent-sessions li { min-width: 160px; }
.channel-stage { max-height: 36vh; overflow: auto; }
.object-inspector { max-height: 288px; overflow: auto; }
.session-layout { grid-template-columns: 1fr; }
}

View File

@@ -1,25 +0,0 @@
export type Project = { id: number; name: string; description: string };
export type ProjectDashboardSummary = {
project_id: number;
pending_inbox_count: number;
open_task_count: number;
recent_note_count: number;
recent_session_count: number;
};
export function createApi(apiBase: string, token?: string) {
async function request<T>(path: string): Promise<T> {
const response = await fetch(`${apiBase}/api${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
return response.json() as Promise<T>;
}
return {
getProjects: () => request<Project[]>('/projects'),
getProjectDashboard: (projectID: number) =>
request<ProjectDashboardSummary>(`/projects/${projectID}/dashboard`),
};
}

View File

@@ -1,11 +0,0 @@
import './index.css';
import App from './app/App.svelte';
import { mount } from 'svelte';
const target = document.getElementById('app');
if (!target) {
throw new Error('Missing #app mount target');
}
mount(App, { target });

View File

@@ -1,17 +0,0 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"types": ["svelte", "vite/client", "vitest/globals"],
"noEmit": true,
"strict": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

View File

@@ -1,14 +0,0 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [svelte()],
resolve: {
conditions: ['browser'],
},
test: {
environment: 'jsdom',
exclude: ['node_modules/**', 'dist/**', 'e2e/**'],
globals: true,
},
});

View File

@@ -10,6 +10,8 @@ lerna-debug.log*
node_modules
dist
dist-ssr
test-results
audit-results
*.local
# Editor directories and files

17
apps/web_v1/README.md Normal file
View File

@@ -0,0 +1,17 @@
# 森林AI Web 客户端
森林AI主 Web 客户端,使用 React、TypeScript、Vite 和 Arco Design 构建。
This app is the active web client under `apps/web_v1`.
## Scripts
```bash
npm install
npm run dev
npm run build
npm run lint
node scripts/visual-check.mjs
```
Default local preview port: `4174`.

15
apps/web_v1/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="application-name" content="森林AI" />
<meta name="description" content="森林AI 私有化项目工作台" />
<title>森林AI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3878
apps/web_v1/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
apps/web_v1/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "web_v1",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@arco-design/web-react": "^2.66.16",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@types/dompurify": "^3.0.5",
"dompurify": "^3.4.12",
"mammoth": "^1.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"read-excel-file": "^9.3.4",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@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"
}
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<title>森林AI 图标</title>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#165DFF"/>
<path d="M64 23C43 23 27 40 27 61c0 13 7 24 17 30h40c10-6 17-17 17-30 0-21-16-38-37-38Z" fill="#F4F8FF"/>
<path d="M64 99V65M64 76 47 61M64 82l18-16M64 68l12-13" fill="none" stroke="#F4F8FF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<path d="M42 58c6-15 29-22 45-7M39 69c12-11 38-13 50 2M48 78c9-5 24-6 33-1" fill="none" stroke="#165DFF" stroke-linecap="round" stroke-width="5"/>
<circle cx="47" cy="61" r="6" fill="#14B8A6"/>
<circle cx="76" cy="55" r="6" fill="#60A5FA"/>
<circle cx="82" cy="66" r="6" fill="#A7F3D0"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<title>森林AI 图标</title>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#165DFF"/>
<path d="M64 23C43 23 27 40 27 61c0 13 7 24 17 30h40c10-6 17-17 17-30 0-21-16-38-37-38Z" fill="#F4F8FF"/>
<path d="M64 99V65M64 76 47 61M64 82l18-16M64 68l12-13" fill="none" stroke="#F4F8FF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<path d="M42 58c6-15 29-22 45-7M39 69c12-11 38-13 50 2M48 78c9-5 24-6 33-1" fill="none" stroke="#165DFF" stroke-linecap="round" stroke-width="5"/>
<circle cx="47" cy="61" r="6" fill="#14B8A6"/>
<circle cx="76" cy="55" r="6" fill="#60A5FA"/>
<circle cx="82" cy="66" r="6" fill="#A7F3D0"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

View File

@@ -0,0 +1,151 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import ts from 'typescript'
const clientPath = new URL('../src/api/client.ts', import.meta.url)
const source = (await readFile(clientPath, 'utf8')).replaceAll('import.meta.env', '{}')
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2023,
},
fileName: 'client.ts',
}).outputText
const client = await import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`)
const { ApiError, apiRequest, normalizeBaseUrl, setApiBaseUrl } = client
const originalFetch = globalThis.fetch
test.beforeEach(() => {
setApiBaseUrl('http://api.test')
})
test.after(() => {
globalThis.fetch = originalFetch
})
test('empty 200 response rejects with a stable invalid_response ApiError', async () => {
globalThis.fetch = async () => new Response('', { status: 200 })
await assert.rejects(apiRequest('/empty'), invalidResponseError)
})
test('non-JSON 200 response rejects with a stable invalid_response ApiError', async () => {
globalThis.fetch = async () => new Response('<html>bad gateway</html>', { status: 200 })
await assert.rejects(apiRequest('/html'), invalidResponseError)
})
test('interrupted successful response body rejects with a stable invalid_response ApiError', async () => {
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () => {
throw new Error('read interrupted')
},
})
await assert.rejects(apiRequest('/interrupted'), invalidResponseError)
})
test('204 response returns undefined without reading a body', async () => {
globalThis.fetch = async () => new Response(null, { status: 204 })
assert.equal(await apiRequest('/no-content'), undefined)
})
test('explicit void response returns undefined for a non-204 response', async () => {
globalThis.fetch = async () => new Response('ignored', { status: 200 })
assert.equal(await apiRequest('/accepted', { responseType: 'void' }), undefined)
})
test('network failure rejects with network_error ApiError', async () => {
globalThis.fetch = async () => {
throw new Error('socket closed')
}
await assert.rejects(apiRequest('/network'), (error) => {
assert.equal(error instanceof ApiError, true)
assert.equal(error.status, 0)
assert.equal(error.code, 'network_error')
return true
})
})
test('standard error envelope preserves status, code, and Chinese message', async () => {
globalThis.fetch = async () => Response.json(
{ error: { code: 'invalid_request', message: '请求参数无效' } },
{ status: 400 },
)
await assert.rejects(apiRequest('/standard-error'), (error) => {
assert.equal(error instanceof ApiError, true)
assert.equal(error.status, 400)
assert.equal(error.code, 'invalid_request')
assert.equal(error.message, '请求参数无效')
return true
})
})
test('GET without a body does not send Content-Type', async () => {
let requestInit
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/read')
assert.equal(requestInit.headers['Content-Type'], undefined)
})
test('JSON body sends application/json Content-Type', async () => {
let requestInit
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/write', { method: 'POST', body: { title: '测试' } })
assert.equal(requestInit.headers['Content-Type'], 'application/json')
})
test('FormData body leaves Content-Type unset so fetch provides the boundary', async () => {
let requestInit
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/upload', { method: 'POST', body: new FormData() })
assert.equal(requestInit.headers['Content-Type'], undefined)
})
test('request forwards its AbortSignal to fetch', async () => {
let requestInit
const controller = new AbortController()
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/search', { signal: controller.signal })
assert.equal(requestInit.signal, controller.signal)
})
test('base URL normalizer trims whitespace and all trailing slashes', () => {
assert.equal(normalizeBaseUrl(' https://host.example/// '), 'https://host.example')
})
function invalidResponseError(error) {
assert.equal(error instanceof ApiError, true)
assert.equal(error.status, 200)
assert.equal(error.code, 'invalid_response')
assert.equal(error.message, '服务器返回了无法识别的数据')
return true
}

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createSearchRequestGate } from '../src/app/search-request-gate.ts'
test('starting a new request aborts and invalidates the previous request', () => {
const gate = createSearchRequestGate()
const first = gate.begin()
const second = gate.begin()
assert.equal(first.signal.aborted, true)
assert.equal(first.isCurrent(), false)
assert.equal(second.signal.aborted, false)
assert.equal(second.isCurrent(), true)
})
test('session cleanup aborts the request and suppresses its stale error', async () => {
const gate = createSearchRequestGate()
const request = gate.begin()
const surfacedErrors = []
const oldSessionFailure = Promise.reject(new Error('旧会话搜索失败')).catch((error) => {
if (request.isCurrent()) surfacedErrors.push(error.message)
})
gate.invalidate()
await oldSessionFailure
assert.equal(request.signal.aborted, true)
assert.equal(request.isCurrent(), false)
assert.deepEqual(surfacedErrors, [])
})
test('a request after invalidation belongs to the new session generation', () => {
const gate = createSearchRequestGate()
const oldSessionRequest = gate.begin()
gate.invalidate()
const newSessionRequest = gate.begin()
assert.equal(oldSessionRequest.isCurrent(), false)
assert.equal(newSessionRequest.isCurrent(), true)
assert.equal(newSessionRequest.signal.aborted, false)
})

View File

@@ -0,0 +1,195 @@
import { existsSync, readFileSync } from 'node:fs'
const requiredFiles = [
'src/app/App.tsx',
'src/pages/login.tsx',
'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx',
'src/pages/workspace-explore.tsx',
'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-inbox.tsx',
'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-documents.tsx',
'src/pages/projects/document-markdown-editor.tsx',
'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-new-channel.tsx',
'src/pages/projects/project-rail.tsx',
'src/pages/projects/project-sidebar.tsx',
'src/pages/projects/project-topbar.tsx',
'src/pages/projects/project-statusbar.tsx',
'src/pages/projects/project-types.ts',
'src/api/client.ts',
'src/api/projects.ts',
'src/api/mappers.tsx',
'src/api/search.ts',
'src/api/inbox.ts',
'src/api/ai.ts',
'src/api/documents.ts',
'scripts/api-client.test.mjs',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
const html = readFileSync('index.html', 'utf8')
if (!html.includes('<html lang="zh-CN">')) failures.push('index language must be zh-CN')
if (!html.includes('<title>森林AI</title>')) failures.push('document title must be 森林AI')
if (!html.includes('href="/senlinai-icon.svg"')) failures.push('favicon must use /senlinai-icon.svg')
if (!existsSync('public/senlinai-icon.svg')) failures.push('missing public/senlinai-icon.svg')
for (const file of ['src/pages/login.tsx', 'src/pages/projects/project-topbar.tsx']) {
const source = readFileSync(file, 'utf8')
if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`)
}
for (const forbidden of ['森林Agent', 'SenlinAI Workbench']) {
for (const file of requiredFiles.filter((name) => name.endsWith('.tsx'))) {
if (readFileSync(file, 'utf8').includes(forbidden)) failures.push(`${file} contains legacy product name ${forbidden}`)
}
}
const projectRailSource = readFileSync('src/pages/projects/project-rail.tsx', 'utf8')
if (!projectRailSource.includes('projectNameLabel(project.name)')) {
failures.push('project rail labels must derive from the project name')
}
if (!readFileSync('src/pages/login.tsx', 'utf8').includes('v1.0 完整版')) {
failures.push('login must identify the frontend as v1.0 完整版')
}
if (!projectRailSource.includes('icon={<IconDashboard />}') || projectRailSource.includes('rail-brand-icon')) {
failures.push('workspace rail entry must use the dashboard icon instead of the brand logo')
}
const apiFiles = ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts', 'ai.ts'].map((name) => `src/api/${name}`)
const apiSource = apiFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
for (const forbidden of [
{ pattern: /\bID\s*\?\s*:/, label: 'optional PascalCase ID compatibility field' },
{ pattern: /\bName\s*\?\s*:/, label: 'optional PascalCase Name compatibility field' },
{ pattern: /['"`]\/api\/projects/, label: 'legacy /api/projects path' },
{
pattern: /\b(?:project|task|inbox)Id\s*\??\s*:\s*(?:string\s*\|\s*)?number\b/i,
label: 'numeric project/task/inbox identity',
},
]) {
if (forbidden.pattern.test(apiSource)) failures.push(`src/api contains ${forbidden.label}`)
}
const clientSource = readFileSync('src/api/client.ts', 'utf8')
if (!clientSource.includes("'/api/v1/auth/login'")) failures.push('login must use /api/v1/auth/login')
if (!clientSource.includes('export class ApiError extends Error')) failures.push('client must export ApiError')
const projectsSource = readFileSync('src/api/projects.ts', 'utf8')
if (!projectsSource.includes("'/api/v1/projects'")) failures.push('project API must use /api/v1/projects')
if (/['"`]\/api\/(?!v1\/)/.test(apiSource)) failures.push('src/api paths must use the /api/v1 prefix')
const mappersSource = readFileSync('src/api/mappers.tsx', 'utf8')
if (!mappersSource.includes("filter((channel) => channel.type !== 'inbox')")) {
failures.push('project channels must hide the removed Inbox message stream')
}
for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\n\}/g)) {
const [, name, body] = match
if (/^\s*\w+\s*\?\s*:/m.test(body)) failures.push(`${name} fields must be required`)
if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
}
if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields')
const searchSource = readFileSync('src/api/search.ts', 'utf8')
if (!searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
for (const unsupportedType of ["'source'", "'inbox'", "'ai_session'"]) {
if (searchSource.includes(unsupportedType)) failures.push(`search DTO contains unsupported result type ${unsupportedType}`)
}
const loginSource = readFileSync('src/pages/login.tsx', 'utf8')
if (!loginSource.includes("import { normalizeBaseUrl } from '../api/client'")) failures.push('login must share the API base URL normalizer')
if (loginSource.includes('function normalizeBaseUrl')) failures.push('login must not define a second base URL normalizer')
const inboxSource = readFileSync('src/api/inbox.ts', 'utf8')
for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {
if (!inboxSource.includes(path)) failures.push(`inbox API must use ${path}`)
}
if (inboxSource.includes('body: { suggestions }')) failures.push('inbox confirm must not send client-authored suggestion content')
if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confirm must send only saved suggestion identities')
const inboxPageSource = readFileSync('src/pages/projects/project-inbox.tsx', 'utf8')
if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
const aiApiSource = readFileSync('src/api/ai.ts', 'utf8')
for (const required of ['/api/v1/projects/', '/ai-sessions', '/api/v1/ai-experts', 'listAIExperts', 'listAISessions', 'createAISession']) {
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
}
if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal')
if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass the AbortSignal to apiRequest')
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
const aiPageSource = readFileSync('src/pages/projects/project-ai.tsx', 'utf8')
for (const required of ['新建会话', '选择专家进行会话', 'agent-expert-picker', 'selectedExpertID', 'expertId', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
}
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must gate stale requests with ${required}`)
}
if (aiPageSource.includes('overview-head') || aiPageSource.includes('<Title heading=')) {
failures.push('project AI page must not render a title or subtitle header')
}
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息']) {
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains outdated or borrowed copy ${forbidden}`)
}
const tasksPageSource = readFileSync('src/pages/projects/project-tasks.tsx', 'utf8')
for (const required of ['selectedTag', 'setSelectedTag', 'visibleTasks', 'task-tag-filter', 'aria-pressed']) {
if (!tasksPageSource.includes(required)) failures.push(`project tasks page must support tag filtering with ${required}`)
}
const explorePageSource = readFileSync('src/pages/workspace-explore.tsx', 'utf8')
for (const required of ['explore-source-card', 'explore-reader-layout', '同步数据源', '添加数据源', 'detectSource', 'explore-source-modal', 'openAddSource', 'openEditSource']) {
if (!explorePageSource.includes(required)) failures.push(`workspace explore page must restore ${required}`)
}
const statusbarSource = readFileSync('src/pages/projects/project-statusbar.tsx', 'utf8')
for (const required of ['status-online-dot', 'status-upgrade', 'status-system', '个人资料', '升级套餐']) {
if (!statusbarSource.includes(required)) failures.push(`project statusbar must restore ${required}`)
}
const newChannelSource = readFileSync('src/pages/projects/project-new-channel.tsx', 'utf8')
for (const required of ['保存频道', '频道名称', '频道类型', '链接地址', '创建后可接入的内容']) {
if (!newChannelSource.includes(required)) failures.push(`new channel page must restore ${required}`)
}
const unsupportedControls = [
{ file: 'src/pages/projects/project-topbar.tsx', forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'] },
]
for (const check of unsupportedControls) {
const source = readFileSync(check.file, 'utf8')
if (check.required && !source.includes(check.required)) failures.push(`${check.file} must show ${check.required}`)
for (const forbidden of check.forbidden) {
if (source.includes(forbidden)) failures.push(`${check.file} contains unsupported control ${forbidden}`)
}
}
if (existsSync('src/App.tsx')) {
failures.push('legacy src/App.tsx should be removed after page split')
}
for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx']) {
if (existsSync(removed)) {
failures.push(`${removed} should be removed; frontend data must come from backend APIs`)
}
}
if (existsSync('src/app/App.tsx')) {
const appSource = readFileSync('src/app/App.tsx', 'utf8')
for (const forbidden of ['function LoginPage', 'function Workbench', 'function OverviewContent']) {
if (appSource.includes(forbidden)) {
failures.push(`src/app/App.tsx still contains ${forbidden}`)
}
}
}
if (failures.length) {
console.error(failures.join('\n'))
process.exit(1)
}
console.log('structure ok')

View File

@@ -0,0 +1,763 @@
import { createServer } from 'vite'
import { chromium } from 'playwright'
const port = Number(process.env.VISUAL_CHECK_PORT ?? 4175)
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: 1440, height: 1024 }, deviceScaleFactor: 1 })
const errors = []
const projectId = '019b0000-0000-7000-8000-000000000001'
const visualExperts = [
{
id: '019b0000-0000-7000-8000-000000000020', slug: 'product-manager', category: 'product', categoryName: '产品',
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
},
{
id: '019b0000-0000-7000-8000-000000000021', slug: 'ui-designer', category: 'design', categoryName: '设计',
name: 'UI 设计师', description: '负责界面设计、组件规范和视觉一致性。', emoji: '🎨', color: '#722ED1',
},
{
id: '019b0000-0000-7000-8000-000000000022', slug: 'frontend-developer', category: 'engineering', categoryName: '工程',
name: '前端开发者', description: '负责现代 Web 应用实现与性能优化。', emoji: '💻', color: '#0FC6C2',
},
]
let visualDatasetSources = [
{
id: '019b0000-0000-7000-8000-000000000030', name: '手动收集', kind: 'manual', url: '', iconUrl: '',
description: '手动收集的文章与线索', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1,
createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000031', name: '需求文档', kind: 'link', url: 'https://example.com/requirements', iconUrl: '',
description: '产品需求与业务文档', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1,
createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000032', name: '架构讨论', kind: 'link', url: 'https://example.com/architecture', iconUrl: '',
description: '架构方案与系统设计讨论', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 0,
createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z',
},
]
let visualDatasetItems = [
{
id: '019b0000-0000-7000-8000-000000000040', sourceId: visualDatasetSources[0].id,
title: '森林项目体验优化需求', summary: '整理工作台信息层级,并沉淀为可跟进计划。', content: '探索数据条目的正文内容。',
url: 'https://example.com/article-1',
imageUrl: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22152%22 height=%22116%22%3E%3Crect width=%22152%22 height=%22116%22 fill=%22%23165DFF%22/%3E%3C/svg%3E',
status: 'unread', starred: false, publishedAt: '2026-07-22T08:00:00Z',
createdAt: '2026-07-22T08:00:00Z', updatedAt: '2026-07-22T08:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000041', sourceId: visualDatasetSources[1].id,
title: '项目资料组织方案', summary: '围绕项目上下文统一组织任务、笔记、资料和 AI 会话。', content: '资料组织方案正文。',
url: 'https://example.com/article-2', imageUrl: '', status: 'read', starred: true, publishedAt: '2026-07-22T07:00:00Z',
createdAt: '2026-07-22T07:00:00Z', updatedAt: '2026-07-22T07:00:00Z',
},
]
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
const visualCheckWorkspace = {
project: {
id: projectId,
name: '森林项目',
identifier: 'forest',
icon: '森',
background: '#165DFF',
description: '前后端 API 对齐视觉检查项目',
initials: '森林',
unreadCount: 2,
},
channels: [
{ id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
{ id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'email', count: 2, url: '', sortOrder: 1 },
{ id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 0, url: '', sortOrder: 1 },
{ id: 'ai', projectId, type: 'ai_sessions', title: 'AI 会话', icon: 'robot', count: 0, url: '', sortOrder: 2 },
{ id: 'documents', projectId, type: 'documents', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 },
{ id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 },
],
tags: [
{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' },
{ id: '019b0000-0000-7000-8000-000000000005', name: '设计' },
],
recentSessions: [],
inbox: [
{
id: '019b0000-0000-7000-8000-000000000003',
projectId,
source: '需求文档',
title: '森林项目体验优化需求',
summary: '整理工作台信息层级,并把关键需求沉淀为可跟进的项目计划。',
status: 'open',
tag: '需求',
time: '2026-07-22T08:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000004',
projectId,
source: '架构讨论',
title: '项目资料组织方案',
summary: '围绕项目上下文统一组织任务、笔记、资料和 AI 会话。',
status: 'open',
tag: '架构',
time: '2026-07-22T07:00:00Z',
},
],
tasks: [
{
id: '019b0000-0000-7000-8000-000000000006',
projectId,
title: '梳理产品需求',
summary: '确认首版工作台的功能边界。',
completed: false,
owner: '森林AI',
createdAt: '2026-07-22',
completedAt: '',
duration: '',
progress: '40%',
tagId: '019b0000-0000-7000-8000-000000000002',
tag: '产品',
},
{
id: '019b0000-0000-7000-8000-000000000007',
projectId,
title: '优化会话界面',
summary: '统一 AI 会话的阅读与输入体验。',
completed: false,
owner: '森林AI',
createdAt: '2026-07-22',
completedAt: '',
duration: '',
progress: '60%',
tagId: '019b0000-0000-7000-8000-000000000005',
tag: '设计',
},
{
id: '019b0000-0000-7000-8000-000000000008',
projectId,
title: '确认项目定位',
summary: '完成项目功能与亮点说明。',
completed: true,
owner: '森林AI',
createdAt: '2026-07-21',
completedAt: '2026-07-22',
duration: '1 天',
progress: '100%',
tagId: '019b0000-0000-7000-8000-000000000002',
tag: '产品',
},
],
aiSessions: [],
documents: [],
cronPlans: [],
}
await page.route('http://agent.senlin.ai/api/v1/status', async (route) => {
await route.fulfill({
json: { timestamp: '2026-07-24T00:00:00Z' },
headers: { 'access-control-allow-origin': '*' },
})
})
await page.route('http://localhost:9150/api/v1/**', async (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/api/v1/status') {
await route.fulfill({ json: { timestamp: new Date().toISOString() } })
return
}
if (url.pathname === '/api/v1/auth/login') {
await route.fulfill({ json: { token: 'visual-check-token', user: { email: 'visual@senlin.ai', displayName: '视觉检查' } } })
return
}
if (url.pathname === '/api/v1/projects') {
await route.fulfill({ json: [visualCheckWorkspace.project] })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/workspace`) {
await route.fulfill({ json: visualCheckWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/channels` && route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const channel = {
id: '019b0000-0000-7000-8000-000000000060', projectId, type: 'custom_link',
title: input.title, icon: input.icon || 'link', count: 0, url: input.url, sortOrder: 8,
}
visualCheckWorkspace.channels = [...visualCheckWorkspace.channels, channel]
await route.fulfill({ status: 201, json: channel })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/documents` && route.request().method() === 'GET') {
await route.fulfill({ json: [] })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) {
if (route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const expert = visualExperts.find((item) => item.id === input.expertId)
await route.fulfill({
json: {
id: '019b0000-0000-7000-8000-000000000010',
projectId,
title: input.title,
context: input.context,
status: 'ready',
expert,
createdAt: '2026-07-22T09:00:00Z',
updatedAt: '2026-07-22T09:00:00Z',
},
})
return
}
await route.fulfill({
json: [{
id: '019b0000-0000-7000-8000-000000000009',
projectId,
title: '报价分析',
context: '分析当前方案的报价结构。',
status: 'ready',
expert: visualExperts[1],
createdAt: '2026-07-22T08:30:00Z',
updatedAt: '2026-07-22T08:30:00Z',
}],
})
return
}
if (url.pathname === '/api/v1/ai-experts') {
await route.fulfill({ json: visualExperts })
return
}
if (url.pathname === '/api/v1/dataset-sources' && route.request().method() === 'GET') {
await route.fulfill({ json: visualDatasetSources })
return
}
if (url.pathname === '/api/v1/dataset-sources' && route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const created = {
id: `019b0000-0000-7000-8000-${String(50 + visualDatasetSources.length).padStart(12, '0')}`,
...input,
builtIn: false,
lastSyncedAt: null,
itemCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
visualDatasetSources = [...visualDatasetSources, created]
await route.fulfill({ status: 201, json: created })
return
}
if (url.pathname.startsWith('/api/v1/dataset-sources/') && route.request().method() === 'PATCH') {
const id = url.pathname.split('/').at(-1)
const input = route.request().postDataJSON()
const current = visualDatasetSources.find((source) => source.id === id)
const updated = { ...current, ...input, updatedAt: new Date().toISOString() }
visualDatasetSources = visualDatasetSources.map((source) => source.id === id ? updated : source)
await route.fulfill({ json: updated })
return
}
if (url.pathname.startsWith('/api/v1/dataset-sources/') && route.request().method() === 'DELETE') {
const id = url.pathname.split('/').at(-1)
visualDatasetSources = visualDatasetSources.filter((source) => source.id !== id)
visualDatasetItems = visualDatasetItems.filter((item) => item.sourceId !== id)
await route.fulfill({ status: 204, body: '' })
return
}
if (url.pathname === '/api/v1/dataset-items' && route.request().method() === 'GET') {
const sourceId = url.searchParams.get('sourceId')
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 50)
const matchingItems = sourceId
? visualDatasetItems.filter((item) => item.sourceId === sourceId)
: visualDatasetItems
await route.fulfill({
json: {
items: matchingItems.slice(offset, offset + limit),
total: matchingItems.length,
offset,
limit,
},
})
return
}
if (url.pathname.endsWith('/deposit') && route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: { documentId: '019b0000-0000-7000-8000-000000000099', projectId } })
return
}
if (url.pathname.startsWith('/api/v1/dataset-items/') && route.request().method() === 'PATCH') {
const id = url.pathname.split('/').at(-1)
const input = route.request().postDataJSON()
const current = visualDatasetItems.find((item) => item.id === id)
const updated = { ...current, ...input, updatedAt: new Date().toISOString() }
visualDatasetItems = visualDatasetItems.map((item) => item.id === id ? updated : item)
await route.fulfill({ json: updated })
return
}
if (url.pathname === '/api/v1/dataset-sources/sync') {
await route.fulfill({
status: 200,
json: visualDatasetSources.map((source) => ({
sourceId: source.id,
status: 'completed',
result: 'format=rss fetched=2 inserted=0',
})),
})
return
}
await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } })
})
const collectMetrics = async () => page.evaluate(() => {
const statusbar = document.querySelector('.statusbar')
const statusName = document.querySelector('.status-name')
const statusOnlineDot = document.querySelector('.status-online-dot')
const statusUpgrade = document.querySelector('.status-upgrade')
const statusSystem = document.querySelector('.status-system')
const search = document.querySelector('.global-search')
const main = document.querySelector('.workbench-main')
const workspacePage = document.querySelector('.workspace-page')
const projectOverview = document.querySelector('.overview-page:not(.workspace-page)')
const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children')
const sidebar = document.querySelector('.channel-sidebar')
const channelList = document.querySelector('.channel-list')
const stage = document.querySelector('.stage')
const inspector = document.querySelector('.inspector')
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
.map((button) => button.textContent?.trim())
const dashboard = document.querySelector('.dashboard-button')
const workspaceExplore = document.querySelector('.dashboard-button[title="探索"]')
const project = document.querySelector('.project-button')
const create = document.querySelector('.create-project')
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
const railItems = [...document.querySelectorAll('.dashboard-button, .project-button, .create-project')]
const rect = (node) => {
const box = node?.getBoundingClientRect()
return box ? { left: box.left, right: box.right, top: box.top, width: box.width, height: box.height } : null
}
const railBox = rail?.getBoundingClientRect()
const itemRects = railItems.map(rect).filter(Boolean)
const horizontalInsets = railBox && project
? {
left: project.getBoundingClientRect().left - railBox.left,
right: railBox.right - project.getBoundingClientRect().right,
}
: null
const verticalGaps = itemRects.slice(1).map((item, index) => item.top - (itemRects[index].top + itemRects[index].height))
const overflowState = (node) => node
? {
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
overflowX: getComputedStyle(node).overflowX,
overflowY: getComputedStyle(node).overflowY,
scrollbarWidth: getComputedStyle(node).scrollbarWidth,
}
: null
return {
statusbarHeight: statusbar?.getBoundingClientRect().height,
statusName: rect(statusName),
statusOnlineDot: rect(statusOnlineDot),
statusUpgrade: rect(statusUpgrade),
statusSystemText: statusSystem?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
searchHeight: search?.getBoundingClientRect().height,
workbenchMain: rect(main),
hasWorkspacePage: Boolean(workspacePage),
hasProjectOverview: Boolean(projectOverview),
exploreSourceCardCount: document.querySelectorAll('.explore-source-card').length,
hasExploreReader: Boolean(document.querySelector('.explore-reader-layout')),
hasInboxChannel: [...document.querySelectorAll('.channel-button')].some((button) => button.textContent?.includes('Inbox')),
projectRailWidth: rail?.getBoundingClientRect().width,
projectRail: rect(rail),
channelSidebar: rect(sidebar),
stage: rect(stage),
inspector: rect(inspector),
workspaceActions,
dashboardButton: rect(dashboard),
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
dashboardUsesPanelIcon: Boolean(dashboard?.querySelector('.arco-icon-dashboard')),
dashboardUsesBrandImage: Boolean(dashboard?.querySelector('img')),
workspaceExploreButton: rect(workspaceExplore),
workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false,
projectButton: rect(project),
projectButtonActive: project?.classList.contains('active') ?? false,
createProjectButton: rect(create),
firstProjectBadge: rect(firstBadge),
horizontalInsets,
verticalGaps,
projectRailOverflow: overflowState(rail),
projectRailChildrenOverflow: overflowState(railChildren),
channelSidebarOverflow: overflowState(sidebar),
channelListOverflow: overflowState(channelList),
stageOverflow: overflowState(stage),
inspectorOverflow: overflowState(inspector),
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
viewportWidth: window.innerWidth,
}
})
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
await page.locator('.login-form input').first().fill('localhost:9150')
await page.locator('.login-form input').nth(1).fill('visual@senlin.ai')
await page.locator('.login-form input').nth(2).fill('password123')
await page.waitForSelector('.connection-card.online')
const loginConnectionStatus = await page.locator('.connection-card').textContent()
await page.screenshot({ path: 'test-results/login-react-acro.png', fullPage: true })
await page.locator('.login-form-panel .arco-btn-primary').click()
await page.waitForSelector('.workbench-shell')
await page.waitForTimeout(700)
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics()
await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceExploreMetrics = await collectMetrics()
await page.getByRole('button', { name: '添加数据源' }).click()
await page.waitForSelector('.explore-source-modal')
await page.waitForTimeout(250)
await page.screenshot({ path: 'test-results/explore-add-source-modal.png', fullPage: true })
const addSourceModalCheck = await page.evaluate(() => ({
title: document.querySelector('.explore-source-modal .arco-modal-title')?.textContent ?? '',
inputCount: document.querySelectorAll('.explore-source-modal input, .explore-source-modal textarea').length,
}))
await page.locator('.explore-source-modal input').first().fill('行业资讯')
await page.locator('.explore-source-modal input[placeholder="https://example.com/feed"]').fill('https://example.com/feed')
await page.locator('.explore-source-modal .arco-modal-footer .arco-btn-primary').click()
const addedSourceVisible = await page.locator('.explore-source-card', { hasText: '行业资讯' }).count() === 1
await page.locator('.explore-source-card').nth(1).hover()
await page.getByRole('button', { name: '编辑手动收集' }).click()
await page.waitForSelector('.explore-source-modal')
await page.waitForTimeout(250)
await page.screenshot({ path: 'test-results/explore-edit-source-modal.png', fullPage: true })
const editSourceModalCheck = await page.evaluate(() => ({
title: document.querySelector('.explore-source-modal .arco-modal-title')?.textContent ?? '',
name: document.querySelector('.explore-source-modal input')?.value ?? '',
}))
await page.locator('.explore-source-modal input').first().fill('手动整理')
await page.locator('.explore-source-modal .arco-modal-footer .arco-btn-primary').click()
const editedSourceCard = page.locator('.explore-source-card', { hasText: '手动整理' })
await editedSourceCard.waitFor({ state: 'visible' })
const editedSourceVisible = await editedSourceCard.count() === 1
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
const projectMetrics = await collectMetrics()
await page.locator('.channel-sidebar').hover()
const channelSidebarHoverMetrics = await collectMetrics()
await page.locator('.stage').hover()
const stageHoverMetrics = await collectMetrics()
await page.locator('.channel-list').hover()
const channelListHoverMetrics = await collectMetrics()
const channelPageChecks = []
let taskTagFilterCheck = null
let aiSessionCheck = null
let savedChannelVisible = false
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: '' },
{ label: '笔记资料', pageClass: 'documents-canvas', expectedHeading: '' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
const pageCheck = await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h4, h5')?.textContent ?? ''
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
return {
...expected,
foundPage: Boolean(pageNode),
heading,
activeChannel,
inputCount: pageNode?.querySelectorAll('input, textarea').length ?? 0,
hasSaveChannel: [...(pageNode?.querySelectorAll('button') ?? [])].some((button) => button.textContent?.includes('保存频道')),
hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')),
hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')),
hasComposer: Boolean(pageNode?.querySelector('.agent-composer')),
hasExpertPicker: Boolean(pageNode?.querySelector('.agent-expert-picker')),
expertHeading: pageNode?.querySelector('.agent-expert-heading')?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
}
}, channel)
if (channel.label === '工作计划') {
await page.getByRole('button', { name: '设计', exact: true }).click()
await page.waitForTimeout(150)
taskTagFilterCheck = await page.evaluate(() => ({
activeTag: document.querySelector('.task-tag-filter.active')?.textContent?.trim() ?? '',
pendingTitles: [...document.querySelectorAll('.task-plan-title')].map((node) => node.textContent?.trim()),
completedTitles: [...document.querySelectorAll('.completed-plan-title')].map((node) => node.textContent?.trim()),
pressed: document.querySelector('.task-tag-filter[aria-pressed="true"]')?.textContent?.trim() ?? '',
}))
await page.screenshot({ path: 'test-results/project-task-tag-filter-light.png', fullPage: true })
}
if (channel.label === 'AI 会话') {
await page.screenshot({ path: 'test-results/project-ai-expert-picker-light.png', fullPage: true })
await page.getByRole('button', { name: /产品经理/ }).click()
await page.locator('.agent-composer textarea').fill('请分析这个项目的下一步计划')
await page.getByRole('button', { name: '发送消息' }).click()
await page.waitForSelector('.agent-user-message')
aiSessionCheck = await page.evaluate(() => ({
userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '',
activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '',
composerValue: document.querySelector('.agent-composer textarea')?.value ?? '',
expertName: document.querySelector('.agent-assistant-name')?.textContent?.trim() ?? '',
}))
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
}
if (channel.label === '新建频道') {
const inputs = page.locator('.project-new-channel-page input')
await inputs.nth(0).fill('客户研究')
await inputs.nth(2).fill('https://example.com/customer-research')
await page.getByRole('button', { name: '保存频道' }).click()
await page.waitForTimeout(250)
savedChannelVisible = await page.locator('.channel-button', { hasText: '客户研究' }).count() === 1
}
if (channel.label === '笔记资料') {
await page.screenshot({ path: 'test-results/project-documents-light.png', fullPage: true })
}
channelPageChecks.push(pageCheck)
}
await page.locator('.topbar-actions .arco-btn').first().click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
await browser.close()
await server.close()
console.log(JSON.stringify({
workspaceMetrics,
workspaceExploreMetrics,
addSourceModalCheck,
addedSourceVisible,
editSourceModalCheck,
editedSourceVisible,
projectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
taskTagFilterCheck,
aiSessionCheck,
savedChannelVisible,
errors,
}, null, 2))
const failures = []
const metrics = workspaceMetrics
if (!loginConnectionStatus?.includes('连接正常')) failures.push(`scheme-less server address must connect, got ${JSON.stringify(loginConnectionStatus)}`)
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
if (!metrics.statusName) failures.push('expected authenticated status label')
if (!metrics.statusOnlineDot) failures.push('expected legacy online status dot')
if (metrics.statusUpgrade) failures.push('expected billing entry to be removed from the status bar')
if (!metrics.statusSystemText.includes('已连接到私有工作台')) failures.push(`expected connected status summary, got ${JSON.stringify(metrics.statusSystemText)}`)
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main')
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
if (!metrics.dashboardUsesPanelIcon || metrics.dashboardUsesBrandImage) failures.push('expected workspace entry to use the panel icon instead of the brand logo')
if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login')
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
if (metrics.workspaceActions.some((text) => text?.includes('团队视图') || text?.includes('进入重点项目'))) {
failures.push(`expected workspace header actions to remove team view and focused project buttons, got ${JSON.stringify(metrics.workspaceActions)}`)
}
if (!metrics.projectRail || !metrics.stage) {
failures.push(`missing workspace layout regions: rail=${JSON.stringify(metrics.projectRail)}, stage=${JSON.stringify(metrics.stage)}`)
} else {
if (Math.abs(metrics.stage.left - metrics.projectRail.right) > 1) {
failures.push(`expected workspace stage to start after project rail, got stage.left=${metrics.stage.left}, rail.right=${metrics.projectRail.right}`)
}
if (Math.abs(metrics.stage.right - metrics.viewportWidth) > 1) {
failures.push(`expected workspace stage to end at viewport right edge, got stage.right=${metrics.stage.right}, viewport=${metrics.viewportWidth}`)
}
}
if (!metrics.projectRailWidth || metrics.projectRailWidth < 88) {
failures.push(`expected project rail at least 88px wide, got ${metrics.projectRailWidth}`)
}
if (!metrics.projectButton) failures.push('missing project button')
if (!metrics.horizontalInsets || Math.abs(metrics.horizontalInsets.left - metrics.horizontalInsets.right) > 1) {
failures.push(`expected equal rail horizontal insets, got ${JSON.stringify(metrics.horizontalInsets)}`)
}
if (metrics.verticalGaps.some((gap) => gap !== 12)) {
failures.push(`expected all project rail vertical gaps to be 12px, got ${JSON.stringify(metrics.verticalGaps)}`)
}
for (const [name, overflow] of [
['project rail', metrics.projectRailOverflow],
['project rail children', metrics.projectRailChildrenOverflow],
]) {
if (!overflow || overflow.overflowX !== 'hidden' || overflow.overflowY !== 'hidden') {
failures.push(`expected ${name} scrollbars to be hidden with no overflow, got ${JSON.stringify(overflow)}`)
}
}
if (
!metrics.firstProjectBadge ||
metrics.firstProjectBadge.right > metrics.projectRailWidth ||
metrics.firstProjectBadge.left < 0
) {
failures.push(`expected first project badge to stay inside the project rail, got ${JSON.stringify(metrics.firstProjectBadge)}`)
}
if (
!metrics.dashboardButton ||
metrics.dashboardButton.width !== metrics.projectButton?.width ||
metrics.dashboardButton.height !== metrics.projectButton?.height
) {
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (
!metrics.workspaceExploreButton ||
metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
metrics.workspaceExploreButton.height !== metrics.projectButton?.height
) {
failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (
!metrics.createProjectButton ||
metrics.createProjectButton.width !== metrics.projectButton?.width ||
metrics.createProjectButton.height !== metrics.projectButton?.height
) {
failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
if (workspaceExploreMetrics.exploreSourceCardCount !== 4) failures.push(`expected four legacy explore source cards, got ${workspaceExploreMetrics.exploreSourceCardCount}`)
if (!workspaceExploreMetrics.hasExploreReader) failures.push('expected legacy explore article reader to render')
if (!addSourceModalCheck.title.includes('添加数据源') || addSourceModalCheck.inputCount < 3) {
failures.push(`expected add data source modal with fields, got ${JSON.stringify(addSourceModalCheck)}`)
}
if (!addedSourceVisible) failures.push('expected saved data source to appear in the source cards')
if (!editSourceModalCheck.title.includes('编辑数据源') || editSourceModalCheck.name !== '手动收集') {
failures.push(`expected prefilled edit data source modal, got ${JSON.stringify(editSourceModalCheck)}`)
}
if (!editedSourceVisible) failures.push('expected edited data source name to update its card')
if (!savedChannelVisible) failures.push('expected saved custom channel to appear in the project sidebar')
if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
} else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) {
failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`)
}
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
if (projectMetrics.hasInboxChannel) failures.push('expected removed Inbox message stream to stay hidden from project channels')
if (projectMetrics.hasWorkspacePage) failures.push('expected project overview mode to leave workspace page')
if (!projectMetrics.projectButtonActive) failures.push('expected first project button to be active in project mode')
if (projectMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active in project mode')
if (projectMetrics.inspector) {
failures.push(`expected project page to remove right inspector until it becomes an on-demand panel, got ${JSON.stringify(projectMetrics.inspector)}`)
}
if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage) {
failures.push(
`missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`,
)
} else {
const sameTop = [projectMetrics.projectRail, projectMetrics.channelSidebar, projectMetrics.stage]
.every((region) => Math.abs(region.top - projectMetrics.projectRail.top) <= 1)
if (!sameTop) {
failures.push(
`expected project rail, channel sidebar, and stage to share the same top edge, got rail=${projectMetrics.projectRail.top}, sidebar=${projectMetrics.channelSidebar.top}, stage=${projectMetrics.stage.top}`,
)
}
if (Math.abs(projectMetrics.channelSidebar.left - projectMetrics.projectRail.right) > 1) {
failures.push(`expected channel sidebar to sit after project rail, got sidebar.left=${projectMetrics.channelSidebar.left}, rail.right=${projectMetrics.projectRail.right}`)
}
if (Math.abs(projectMetrics.stage.left - projectMetrics.channelSidebar.right) > 1) {
failures.push(`expected stage to sit after channel sidebar, got stage.left=${projectMetrics.stage.left}, sidebar.right=${projectMetrics.channelSidebar.right}`)
}
if (Math.abs(projectMetrics.stage.right - projectMetrics.viewportWidth) > 1) {
failures.push(`expected project stage to end at viewport right edge, got stage.right=${projectMetrics.stage.right}, viewport=${projectMetrics.viewportWidth}`)
}
}
for (const [name, overflow] of [
['channel sidebar', projectMetrics.channelSidebarOverflow],
['channel list', projectMetrics.channelListOverflow],
['stage', projectMetrics.stageOverflow],
]) {
if (!overflow || overflow.overflowX !== 'hidden' || overflow.overflowY !== 'auto') {
failures.push(`expected ${name} to hide horizontal scrollbars and show vertical scrollbars only when needed, got ${JSON.stringify(overflow)}`)
}
if (overflow?.scrollbarWidth !== 'none') {
failures.push(`expected ${name} scrollbar to be hidden until hover, got ${JSON.stringify(overflow)}`)
}
}
for (const [name, overflow] of [
['channel sidebar hover', channelSidebarHoverMetrics.channelSidebarOverflow],
['channel list hover', channelListHoverMetrics.channelListOverflow],
['stage hover', stageHoverMetrics.stageOverflow],
]) {
if (overflow?.scrollbarWidth !== 'thin') {
failures.push(`expected ${name} scrollbar to appear on hover, got ${JSON.stringify(overflow)}`)
}
}
for (const check of channelPageChecks) {
if (!check.foundPage) {
failures.push(`expected ${check.label} to render .${check.pageClass}`)
}
if (check.expectedHeading && !check.heading.includes(check.expectedHeading)) {
failures.push(`expected ${check.label} heading to include ${check.expectedHeading}, got ${JSON.stringify(check.heading)}`)
}
if (!check.activeChannel.includes(check.label)) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
}
if (check.label === '新建频道' && (check.inputCount < 3 || !check.hasSaveChannel)) {
failures.push(`expected channel editor with persisted link fields, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
}
if (check.label === 'AI 会话' && (
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||
!check.expertHeading.includes('选择专家进行会话')
)) {
failures.push(`expected headerless AI session chat layout, got ${JSON.stringify(check)}`)
}
}
if (
!taskTagFilterCheck ||
taskTagFilterCheck.activeTag !== '设计' ||
taskTagFilterCheck.pressed !== '设计' ||
taskTagFilterCheck.pendingTitles.join('|') !== '优化会话界面' ||
taskTagFilterCheck.completedTitles.length !== 0
) {
failures.push(`expected clickable task tags to filter pending and completed plans, got ${JSON.stringify(taskTagFilterCheck)}`)
}
if (
!aiSessionCheck ||
aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' ||
!aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') ||
aiSessionCheck.composerValue !== '' ||
aiSessionCheck.expertName !== '产品经理'
) {
failures.push(`expected AI composer to create and select a session, got ${JSON.stringify(aiSessionCheck)}`)
}
if (failures.length) {
throw new Error(failures.join('\n'))
}

2963
apps/web_v1/src/App.css Normal file

File diff suppressed because it is too large Load Diff

65
apps/web_v1/src/api/ai.ts Normal file
View File

@@ -0,0 +1,65 @@
import { apiRequest, type ApiSession } from './client'
export type AISessionDTO = {
id: string
projectId: string
title: string
context: string
status: string
expert: AIExpertDTO | null
createdAt: string
updatedAt: string
}
export type AIExpertDTO = {
id: string
slug: string
category: string
categoryName: string
name: string
description: string
emoji: string
color: string
}
export type AIExpertDetailDTO = AIExpertDTO & {
systemPrompt: string
source: string
sourceLicense: string
}
export type CreateAISessionInput = {
title: string
context: string
expertId?: string
}
export async function listAIExperts(session: ApiSession, signal?: AbortSignal) {
return apiRequest<AIExpertDTO[]>('/api/v1/ai-experts', {
token: session.token,
signal,
})
}
export async function getAIExpert(session: ApiSession, expertId: string, signal?: AbortSignal) {
return apiRequest<AIExpertDetailDTO>(`/api/v1/ai-experts/${expertId}`, {
token: session.token,
signal,
})
}
export async function listAISessions(session: ApiSession, projectId: string, signal?: AbortSignal) {
return apiRequest<AISessionDTO[]>(`/api/v1/projects/${projectId}/ai-sessions`, {
token: session.token,
signal,
})
}
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput, signal?: AbortSignal) {
return apiRequest<AISessionDTO>(`/api/v1/projects/${projectId}/ai-sessions`, {
method: 'POST',
token: session.token,
body: input,
signal,
})
}

Some files were not shown because too many files have changed in this diff Show More