Compare commits
175 Commits
1bcdde49d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d98f65ca8d | |||
| 076be30146 | |||
| 93b671c746 | |||
| 139e0d59ed | |||
| f67660f3e5 | |||
| 28e0ea10e5 | |||
| 562c6b1bdb | |||
| dbf5c33c99 | |||
| 0cbf6d1311 | |||
| 4ffd51a831 | |||
| ac674d71e7 | |||
| b01f14ac00 | |||
| 4b3f0d9167 | |||
| b2afa4edf8 | |||
| 7534fadfca | |||
| b2c22901ba | |||
| 563e0fe115 | |||
| 81ffe64446 | |||
| 1e1718caee | |||
| 8e15c06148 | |||
| 794ec74c3f | |||
| 10790b233e | |||
| 5cd779c22c | |||
| 68f4149d34 | |||
| 3a76e787d6 | |||
| 7ea97349d3 | |||
| 9eb3586981 | |||
| 75f4071800 | |||
| 141b8650eb | |||
| 127dc04795 | |||
| f430566976 | |||
| d7eb20a85f | |||
| f2d1fef2d6 | |||
| ab8ba3c03f | |||
| 1578c81a89 | |||
| 77beaebb30 | |||
| 366136cee9 | |||
| d6e4998d87 | |||
| 7c9d5e4560 | |||
| fd8a2110a1 | |||
| 9900235eaa | |||
| bfcfd8d32e | |||
| 400d768ecf | |||
| 56d96f23dd | |||
| fbd142803a | |||
| 3705d75e59 | |||
| af8de3eff6 | |||
| bc7fa40995 | |||
| 5a1b04c4ed | |||
| 55ff320cec | |||
| 50d8246915 | |||
| 3749cf4543 | |||
| 5da343cc71 | |||
| 4b38a42607 | |||
| b053c3874a | |||
| 26dd3004a1 | |||
| 0ca2898ac0 | |||
| 6e7be2f3a4 | |||
| bb90ed1d38 | |||
| f248cfd79e | |||
| 6844af321e | |||
| 51ee510112 | |||
| 50274184dc | |||
| c3a7c3a2d4 | |||
| 5ee346e222 | |||
| f5e6db0c35 | |||
| a564d48154 | |||
| b01928a5e4 | |||
| aeb5276a73 | |||
| a23a110758 | |||
| 4a82efc984 | |||
| 42dd0dce47 | |||
| b28a5d635c | |||
| 5d2f45ad83 | |||
| 74775ed64f | |||
| e391d9dc2f | |||
| 43ac9388ac | |||
| fecff65975 | |||
| d576ad02c1 | |||
| 2e60f273fa | |||
| 1ed01a2059 | |||
| ab23a2d720 | |||
| 47c3ac60d5 | |||
| 17dd9de401 | |||
| 217f82c57c | |||
| 78d4845688 | |||
| 251b212e06 | |||
| 122f5d8c52 | |||
| 8767446b78 | |||
| 7c363bb473 | |||
| 85a25ca017 | |||
| 8cc130244b | |||
| 000de4bcdb | |||
| 2275d388a7 | |||
| c56ada3708 | |||
| 80bec26839 | |||
| 1fcbb31301 | |||
| 9e862f2a7f | |||
| a53f047efe | |||
| 14a140985c | |||
| 5341b44cc5 | |||
| 4bfeec8d2b | |||
| 7803b2faf0 | |||
| 1d35fcf4a1 | |||
| 98573cf4ca | |||
| bbc3a752dc | |||
| ea69d509a6 | |||
| f781823cab | |||
| e3a2c18ad9 | |||
| bb4d26185b | |||
| ad05902563 | |||
| 82af483780 | |||
| 8b29c2c090 | |||
| b00cacd460 | |||
| 29dbaeddf0 | |||
| ad6dcd6de4 | |||
| 465e7bea9b | |||
| 7a1ee3f17f | |||
| 1c58825a7b | |||
| d6395d3c64 | |||
| c613d4c997 | |||
| 54958382f1 | |||
| c1a144cb44 | |||
| df12201caa | |||
| c64daa8657 | |||
| 875b326ddc | |||
| 215268e836 | |||
| a299da63e7 | |||
| a65012c1b3 | |||
| d15ca2eb5f | |||
| fd8d188f29 | |||
| 443a9feaac | |||
| 2c7fd6b7f9 | |||
| 5abc9b634b | |||
| 22128f0171 | |||
| da76abf2d0 | |||
| 9c4ad986c0 | |||
| f4f9ce1c12 | |||
| 7a279efa8f | |||
| 2660bc65bd | |||
| bbd6c62029 | |||
| aa2bf9b88f | |||
| 5411f56beb | |||
| b4b533db4e | |||
| 9e76986879 | |||
| 3669e23085 | |||
| 0f2cbcb9ee | |||
| 03a5c52b72 | |||
| 1fcec3e0b5 | |||
| 7b1bbf17f3 | |||
| 884148b88f | |||
| ec59dee6b9 | |||
| 2d82705679 | |||
| e289b1dbb0 | |||
| 27ff659c30 | |||
| 43d0b9f049 | |||
| ba4ca16375 | |||
| 6341a6bd8b | |||
| 7086b8b0f4 | |||
| 092930e176 | |||
| 1a82a8b53d | |||
| 3194cbed29 | |||
| 95dce81161 | |||
| 6c12d4b0db | |||
| 5b95668945 | |||
| 4fc9271896 | |||
| 8f2b8c9585 | |||
| d538627272 | |||
| 4e46fee809 | |||
| f7cd69c70f | |||
| 7800b07d42 | |||
| 62bd3d0455 | |||
| 250b71951e | |||
| b7a84d31ea | |||
| 79feb20688 |
4
.gitignore
vendored
@@ -1,9 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
**/src-tauri/gen/
|
||||
backend/etc/agent.local.yaml
|
||||
.env
|
||||
data/
|
||||
coverage/
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
apps/desktop/release/
|
||||
.builds/
|
||||
|
||||
101
AGENTS.md
@@ -1,64 +1,71 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project
|
||||
## 项目
|
||||
|
||||
This repository contains the SenlinAI project workbench MVP.
|
||||
本仓库包含森林AI项目工作台 MVP。
|
||||
|
||||
The product is a private-deployment, project-centered workbench for individual knowledge workers, team leads, and trusted internal users. It organizes project tasks, inbox items, Markdown notes, files, AI sessions, project-local tags, and lightweight task distribution.
|
||||
该产品是一个以项目为中心、面向私有化部署的工作台,服务于个人知识工作者、团队负责人和可信的内部用户。它用于组织项目任务、inbox 条目、Markdown 笔记、文件、AI 会话、项目内标签,以及轻量级任务分发。
|
||||
|
||||
## Repository Layout
|
||||
## 仓库结构
|
||||
|
||||
- `backend`: Go Gin + Gorm + PostgreSQL API.
|
||||
- `apps/web`: Responsive Svelte + TypeScript web client.
|
||||
- `apps/desktop`: Tauri desktop shell for the web client.
|
||||
- `infra`: Local development infrastructure such as Docker Compose.
|
||||
- `docs/superpowers/specs`: Product and technical specs.
|
||||
- `docs/superpowers/plans`: Implementation plans.
|
||||
- `backend`: Go Gin + Gorm + PostgreSQL API。
|
||||
- `apps/web_v1`: 当前主 Web 客户端,使用 React + TypeScript + Arco Design。
|
||||
- `apps/desktop`: Web 客户端的 Tauri 桌面壳。
|
||||
- `infra`: 本地开发基础设施,例如 Docker Compose。
|
||||
- `docs/superpowers/specs`: 产品和技术规格文档。
|
||||
- `docs/superpowers/plans`: 实施计划。
|
||||
|
||||
## Backend Rules
|
||||
## 后端规则
|
||||
|
||||
- Use Go, Gin, Gorm, and PostgreSQL.
|
||||
- Keep backend modules focused under `backend/internal`.
|
||||
- Use module path `senlinai-agent/backend`.
|
||||
- Keep all server-local file path construction inside the file service.
|
||||
- Do not let HTTP handlers construct storage paths directly.
|
||||
- Keep project tags scoped to a project; do not introduce global tags in MVP.
|
||||
- Keep task sharing conservative: assignees see the task and only explicitly shared linked objects.
|
||||
- AI must not create official objects without user confirmation.
|
||||
- AI calls must record provider, key type, action, status, and errors.
|
||||
- 使用 Go、Gin、Gorm 和 PostgreSQL。
|
||||
- 后端模块集中放在 `backend/internal` 下,并保持职责聚焦。
|
||||
- 使用模块路径 `senlinai-agent/backend`。
|
||||
- 业务逻辑包统一放在 `backend/internal/logic` 下,例如 `ai`、`auth`、`files`、`inbox`、`notes`、`projects`、`search`、`tasks`。
|
||||
- Gorm 模型和数据库连接入口统一放在 `backend/internal/models`,不要恢复独立的 `domain` 或 `db` 包。
|
||||
- 模型结构体使用 `Sa` 前缀,数据库表名使用 `sa_` 前缀。
|
||||
- 所有服务端本地文件路径构造必须集中在文件服务中。
|
||||
- 不允许 HTTP handler 直接构造存储路径。
|
||||
- 项目标签必须限定在项目内;MVP 阶段不要引入全局标签体系。
|
||||
- 任务分享保持保守:被指派人只能看到任务,以及被显式分享的关联对象。
|
||||
- AI 未经用户确认,不得创建正式对象。
|
||||
- AI 调用必须记录 provider、key 类型、action、status 和 error。
|
||||
- 认证 session token 必须是已签名、会过期的 bearer token;不要接受可伪造的 `user:<id>` 字符串。
|
||||
- HTTP handler 必须从认证 middleware 读取当前用户,不得硬编码用户 ID。
|
||||
- 从 inbox 确认生成的任务、笔记和资料,应保留来源 inbox item ID。
|
||||
- 任务分享必须校验被分享的 note/source 与任务属于同一个项目。
|
||||
- 用户 AI key 必须加密存储;AI 调用在执行 provider 请求前应经过限流检查。
|
||||
|
||||
## Frontend Rules
|
||||
## 前端规则
|
||||
|
||||
- Use Svelte only for the frontend. Do not add React dependencies or React components.
|
||||
- Build the web app desktop-first, while keeping mobile browser layouts usable.
|
||||
- Keep feature code under `apps/web/src/features`.
|
||||
- The login screen must let the user enter the server IP address or domain name.
|
||||
- Use browser-style tabs for page-level workspace state.
|
||||
- Save editor and AI input drafts separately from tab state.
|
||||
- Use Markdown for MVP note editing.
|
||||
- 当前主前端使用 React、TypeScript、Vite 和 Arco Design,位于 `apps/web_v1`。
|
||||
- Web 应用优先面向桌面端构建,同时保持移动浏览器可用。
|
||||
- 登录界面必须允许用户输入服务器 IP 地址或域名。
|
||||
- 页面级工作台状态使用浏览器式标签页。
|
||||
- 编辑器草稿和 AI 输入草稿要与标签页状态分开保存。
|
||||
- MVP 阶段使用 Markdown 进行笔记编辑。
|
||||
|
||||
## Tauri Rules
|
||||
## Tauri 规则
|
||||
|
||||
- Tauri wraps the web client.
|
||||
- MVP desktop features are login persistence, file drag-and-drop upload, notifications, and global shortcut quick capture.
|
||||
- Do not add offline editing, local-first sync, background clipboard monitoring, or file-system indexing in MVP.
|
||||
- Tauri 用于包装当前主 Web 客户端。
|
||||
- MVP 桌面端功能包括登录持久化、文件拖拽上传、通知,以及全局快捷键快速收集。
|
||||
- MVP 阶段不要添加离线编辑、本地优先同步、后台剪贴板监控或文件系统索引。
|
||||
|
||||
## MVP Scope Guardrails
|
||||
## MVP 范围边界
|
||||
|
||||
Do not add these unless a later approved spec says so:
|
||||
除非后续批准的规格文档明确要求,否则不要添加以下功能:
|
||||
|
||||
- Real-time instant messaging.
|
||||
- Project-level member roles.
|
||||
- Anonymous public task sharing.
|
||||
- Autonomous Agent execution.
|
||||
- Semantic or vector search.
|
||||
- Native mobile apps.
|
||||
- Browser extension, email, IM, or third-party capture integrations.
|
||||
- 真正的实时即时通讯。
|
||||
- 项目级成员角色体系。
|
||||
- 匿名公开任务分享。
|
||||
- 自主 Agent 执行。
|
||||
- 语义搜索或向量搜索。
|
||||
- 原生移动 App。
|
||||
- 浏览器扩展、邮件、IM 或第三方收集集成。
|
||||
|
||||
## Verification
|
||||
## 验证
|
||||
|
||||
- Backend changes should run `go test ./...` from `backend`.
|
||||
- PostgreSQL integration checks can use `postgres://postgres:Weidong2023~!@8.137.107.29:19432/agent_dev?sslmode=disable` when a live database is required.
|
||||
- Web changes should run tests and build from `apps/web`.
|
||||
- Tauri changes should build the web app first, then run the Tauri verification command from `apps/desktop`.
|
||||
- Each implementation task should end with a focused commit.
|
||||
- 后端变更应在 `backend` 目录运行 `go test ./...`。
|
||||
- PostgreSQL 集成检查应从 `DATABASE_URL` 读取实时测试数据库 URL;不要提交真实凭据。
|
||||
- Web 变更应在 `apps/web_v1` 目录运行 `node scripts/visual-check.mjs`、`npm run build` 和 `npm run lint`。
|
||||
- Tauri 变更应先构建 Web 应用,然后在 `apps/desktop` 目录运行 Tauri 验证命令。
|
||||
- 每个实施任务结束时都应创建一个聚焦的提交。
|
||||
|
||||
105
README.md
@@ -1,19 +1,100 @@
|
||||
# SenlinAI Agent Workbench
|
||||
<p align="center">
|
||||
<img src="docs/senlinai-icon.svg" width="88" alt="森林AI Logo" />
|
||||
</p>
|
||||
|
||||
Private project-centered workbench MVP.
|
||||
<h1 align="center">森林AI</h1>
|
||||
|
||||
## Development
|
||||
<p align="center">以项目为中心的私有化智能工作台,让任务、资料、信息收集和 AI 协作在同一个空间自然生长。</p>
|
||||
|
||||
```powershell
|
||||
docker compose -f infra/docker-compose.yml up -d
|
||||
Set-Location backend
|
||||
go run ./cmd/api
|
||||
```
|
||||
## 产品简介
|
||||
|
||||
## Frontend
|
||||
森林AI 面向个人知识工作者、团队负责人和可信的内部协作成员,帮助用户把分散的事项、资料与沟通内容整理到清晰的项目空间中。
|
||||
|
||||
The web client uses Svelte and TypeScript only. The login shell lets users enter a server IP address or domain name, which is saved as the API base URL.
|
||||
每个项目都有独立的概况、工作计划、AI 会话、笔记资料和计划任务。用户既可以在工作台查看所有项目的整体进展,也可以进入具体项目,围绕目标持续收集信息、安排工作、沉淀知识并复盘成果。
|
||||
|
||||
## Verification
|
||||
## 适用场景
|
||||
|
||||
See `docs/mvp-verification.md` for backend, web, desktop, PostgreSQL, and manual MVP verification steps.
|
||||
- 个人同时推进多个长期项目,需要统一查看待办和知识资料。
|
||||
- 团队负责人希望快速了解各项目状态、未完成计划和重点事项。
|
||||
- 内部团队需要在可控环境中使用 AI,并保留人工确认环节。
|
||||
- 研究、咨询、产品、运营等工作需要把零散输入逐步转化为任务、笔记和资料。
|
||||
- 项目文件、讨论背景和执行计划分散在不同工具中,希望集中管理。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 统一工作台
|
||||
|
||||
工作台汇总所有项目的数量、未完成计划、AI 会话和知识资料,并集中展示跨项目待办。用户可以直接查看和编辑任务,也可以调整任务所属项目、标签与完成状态。
|
||||
|
||||
### 独立项目空间
|
||||
|
||||
每个项目拥有自己的名称、图标、背景、简介和访问标识。项目概况页集中呈现进行中任务、已完成任务、笔记资料和计划任务,让项目当前状态一目了然。
|
||||
|
||||
### Inbox 信息收集与整理
|
||||
|
||||
Inbox 用于承接尚未归类的想法、消息和材料。用户可以查看原始内容,让 AI 提供任务、笔记或资料建议,再自行勾选并确认创建。分析结果只是候选草稿,不会未经确认直接写入项目。
|
||||
|
||||
### 工作计划与项目标签
|
||||
|
||||
任务按项目组织,可记录标题、说明、标签、负责人、创建时间和完成状态。项目内标签帮助用户按主题或优先级整理计划,同时保留进行中与已完成事项,方便跟进和复盘。
|
||||
|
||||
### AI 会话
|
||||
|
||||
用户可以从本地专家库中选择适合的专业角色,再创建带有项目背景的 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)。运行时不会依赖外部专家站点。
|
||||
|
||||
### 笔记与资料管理
|
||||
|
||||
项目中的笔记、文档、图片、表格、PDF 和其他附件集中展示,并按更新时间浏览。资料与项目保持关联,减少文件散落和反复查找。
|
||||
|
||||
### 计划任务
|
||||
|
||||
用户可以为项目登记周期性计划和提醒安排,查看启用状态、最近时间与下次时间。计划任务用于帮助团队建立稳定节奏,不会自行替用户执行 AI 工作。
|
||||
|
||||
### 项目内搜索
|
||||
|
||||
通过顶部搜索框快速查找项目、任务和笔记,并在结果中确认内容及所属项目,缩短在多个项目之间定位信息的时间。
|
||||
|
||||
## 典型使用流程
|
||||
|
||||
1. 创建项目,补充项目名称、图标、背景和目标说明。
|
||||
2. 将临时想法、会议内容或待整理材料放入 Inbox。
|
||||
3. 使用 AI 分析内容,选择合适的任务、笔记或资料建议。
|
||||
4. 人工核对并确认,将候选内容转为正式项目对象。
|
||||
5. 在工作计划中安排和更新任务,在笔记资料中持续沉淀成果。
|
||||
6. 通过项目概况和统一工作台掌握进展,及时处理跨项目待办。
|
||||
|
||||
## 产品亮点
|
||||
|
||||
### 项目即上下文
|
||||
|
||||
任务、资料、AI 会话与收集内容都围绕项目组织。用户无需在多个孤立工具之间重复解释背景,项目本身就是持续积累的工作上下文。
|
||||
|
||||
### 从零散输入到正式成果
|
||||
|
||||
森林AI 不只保存信息,还提供从 Inbox 收集、AI 分析、人工筛选到正式创建的完整整理路径,让模糊想法逐步变成可执行计划和可复用知识。
|
||||
|
||||
### AI 辅助,用户决策
|
||||
|
||||
AI 负责提供建议,用户保留最终决定权。任何正式任务、笔记或资料都需要确认后创建,既获得智能辅助,也避免自动化越权和错误沉淀。
|
||||
|
||||
### 全局视角与项目专注并存
|
||||
|
||||
工作台适合掌握全局,项目空间适合深入执行。用户可以在跨项目统筹和单项目专注之间快速切换,不会失去上下文。
|
||||
|
||||
### 数据边界清晰
|
||||
|
||||
森林AI 面向私有化使用场景,强调数据自有、访问可控和项目范围内的信息组织,适合重视资料安全与内部协作边界的用户和团队。
|
||||
|
||||
### 轻量但完整的工作闭环
|
||||
|
||||
从收集、整理、计划、执行到知识沉淀,核心能力集中在一个清晰的工作台中。产品保持轻量,不以复杂流程增加使用负担。
|
||||
|
||||
## 产品原则
|
||||
|
||||
- 以项目为中心,让信息始终有明确归属。
|
||||
- AI 未经用户确认,不创建正式内容。
|
||||
- 项目标签和资料保持在各自项目范围内。
|
||||
- 优先支持可信内部协作,不追求开放式公开分享。
|
||||
- 保持清晰、克制和可掌控,让用户专注于真正的项目成果。
|
||||
|
||||
19
apps/desktop/README.md
Normal 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
@@ -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
|
||||
@@ -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>
|
||||
1810
apps/desktop/package-lock.json
generated
@@ -1,14 +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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
10
apps/desktop/public/senlinai-icon.svg
Normal 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 |
115
apps/desktop/scripts/visual-check.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createServer } from 'vite'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const port = Number(process.env.MINI_VISUAL_PORT ?? 4180)
|
||||
const server = await createServer({ root: process.cwd(), server: { host: '127.0.0.1', port, strictPort: true } })
|
||||
await server.listen()
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 420, height: 820 }, deviceScaleFactor: 1 })
|
||||
const errors = []
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) })
|
||||
|
||||
const projectId = '019b0000-0000-7000-8000-000000000101'
|
||||
const user = { email: 'demo@senlin.ai', displayName: '演示用户' }
|
||||
const workspace = {
|
||||
project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '项目协作空间', initials: '森林', unreadCount: 0 },
|
||||
channels: [], tags: [{ id: 'tag-product', name: '产品' }, { id: 'tag-design', name: '设计' }], recentSessions: [], inbox: [{ id: 'inbox-1', projectId, source: 'manual', title: '客户反馈', summary: '整理下周的反馈内容', status: 'open', tag: '产品', time: '2026-07-22T10:00:00Z' }],
|
||||
tasks: [
|
||||
{ id: 'task-1', projectId, title: '整理产品路线', summary: '确认下一阶段交付范围。', completed: false, owner: '张明', due: null, createdAt: '2026-07-22T09:00:00Z', completedAt: null, tagId: 'tag-product', tag: '产品' },
|
||||
{ id: 'task-2', projectId, title: '完成迷你布局', summary: '验证窄屏下的导航和滚动。', completed: true, owner: '张明', due: null, createdAt: '2026-07-21T10:00:00Z', completedAt: '2026-07-22T10:00:00Z', tagId: 'tag-design', tag: '设计' },
|
||||
],
|
||||
aiSessions: [{ id: 'session-1', projectId, title: '梳理版本计划', summary: '下一步怎么安排', updatedAt: '2026-07-22T10:00:00Z', references: [] }],
|
||||
documents: [{ id: 'doc-1', projectId, kind: 'markdown', name: '版本规划', extension: '.md', mimeType: 'text/markdown', updatedAt: '2026-07-22T10:00:00Z' }],
|
||||
cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }],
|
||||
}
|
||||
const expert = { id: 'expert-1', slug: 'product-manager', category: 'product', categoryName: '产品', name: '产品经理', description: '负责需求分析', emoji: '📘', color: '#165DFF' }
|
||||
const datasetSource = { id: 'source-1', name: '行业资讯', kind: 'rss', url: 'https://example.com/feed', iconUrl: '', description: '行业动态', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1, createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }
|
||||
const datasetItem = { id: 'item-1', sourceId: 'source-1', title: 'AI 工作台趋势', summary: '新的协作方式正在改变项目管理。', content: '这是来自数据源的探索内容,可在 Web 工作台继续管理数据源和同步任务。', url: '', imageUrl: '', status: 'unread', starred: false, publishedAt: '2026-07-22T10:00:00Z', createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }
|
||||
|
||||
await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
const request = route.request(); const path = new URL(request.url()).pathname
|
||||
if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'app-token', user } })
|
||||
if (path === '/api/v1/auth/me') return route.fulfill({ json: user })
|
||||
if (path === '/api/v1/projects') return route.fulfill({ json: [workspace.project] })
|
||||
if (path === `/api/v1/projects/${projectId}/workspace`) return route.fulfill({ json: workspace })
|
||||
if (path === `/api/v1/projects/${projectId}/documents/doc-1`) return route.fulfill({ json: { ...workspace.documents[0], revision: 1, markdown: '# 版本规划\n\n记录交付安排。', hasBlob: false, size: 32, createdAt: '2026-07-22T10:00:00Z' } })
|
||||
if (path === '/api/v1/ai-experts') return route.fulfill({ json: [expert] })
|
||||
if (path === '/api/v1/dataset-sources') return route.fulfill({ json: [datasetSource] })
|
||||
if (path === '/api/v1/dataset-items') return route.fulfill({ json: { items: [datasetItem], total: 1, limit: 50, offset: 0 } })
|
||||
if (path.includes('/tasks/') && request.method() === 'PATCH') return route.fulfill({ json: {} })
|
||||
return route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '未配置的检查接口' } } })
|
||||
})
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
|
||||
await page.locator('.login-card input').nth(2).press('Enter')
|
||||
await page.waitForSelector('.app-shell')
|
||||
await page.waitForSelector('.task-row')
|
||||
await page.screenshot({ path: 'test-results/app-home.png', fullPage: true })
|
||||
const home = await page.evaluate(() => ({
|
||||
width: document.querySelector('.app-shell')?.getBoundingClientRect().width,
|
||||
selectedAll: document.querySelector('.project-current span')?.textContent,
|
||||
navCount: document.querySelectorAll('.channel-rail button').length,
|
||||
statCount: document.querySelectorAll('.stat-card').length,
|
||||
pending: document.querySelectorAll('.task-row:not(.done)').length,
|
||||
completed: document.querySelectorAll('.task-row.done').length,
|
||||
}))
|
||||
await page.locator('.task-row').first().click()
|
||||
await page.waitForSelector('.arco-modal')
|
||||
const taskModalWidth = (await page.locator('.arco-modal').boundingBox())?.width ?? 0
|
||||
const taskModal = await page.getByText('编辑任务').count()
|
||||
await page.getByRole('button', { name: '取消' }).click()
|
||||
await page.getByRole('button', { name: '探索' }).click()
|
||||
await page.waitForSelector('.explore-detail')
|
||||
const exploreSources = await page.locator('.explore-source-strip button').count()
|
||||
const exploreDetail = await page.locator('.explore-detail').count()
|
||||
await page.screenshot({ path: 'test-results/app-explore.png', fullPage: true })
|
||||
await page.locator('.project-current').click()
|
||||
await page.getByRole('option', { name: '森林项目' }).click()
|
||||
await page.waitForSelector('.channel-rail')
|
||||
await page.locator('.channel-rail button[title="资料"]').click()
|
||||
const documents = await page.locator('.detail-row').count()
|
||||
await page.locator('.document-row').first().click()
|
||||
await page.waitForSelector('.markdown-editor-page')
|
||||
const markdownEditing = await page.locator('.markdown-editor-page').count()
|
||||
await page.locator('.markdown-editor-page .content-heading .arco-btn').click()
|
||||
const documentActions = await page.locator('.document-actions .arco-btn').count()
|
||||
await page.locator('.document-actions .arco-btn').nth(1).click()
|
||||
await page.waitForSelector('.markdown-editor-page')
|
||||
await page.screenshot({ path: 'test-results/app-markdown-new.png', fullPage: true })
|
||||
const markdownEditor = await page.locator('.markdown-editor-page').count()
|
||||
const markdownLayout = await page.evaluate(() => {
|
||||
const rect = (selector) => { const node = document.querySelector(selector); const value = node?.getBoundingClientRect(); return value ? { left: value.left, width: value.width } : null }
|
||||
return { scrollX: window.scrollX, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, shell: rect('.app-shell'), main: rect('.app-main'), rail: rect('.channel-rail'), editor: rect('.markdown-editor-page') }
|
||||
})
|
||||
await page.locator('.markdown-editor-page .content-heading .arco-btn').click()
|
||||
await page.locator('.channel-rail button[title="AI"]').click()
|
||||
await page.waitForSelector('.expert-chips')
|
||||
const experts = await page.locator('.expert-chips button').count()
|
||||
const aiSwitches = await page.locator('.ai-page-switch .arco-btn').count()
|
||||
await page.screenshot({ path: 'test-results/app-ai-new.png', fullPage: true })
|
||||
await page.locator('.ai-page-switch .arco-btn').nth(1).click()
|
||||
await page.waitForSelector('.ai-history-page')
|
||||
const aiHistory = await page.locator('.ai-history-page').count()
|
||||
await page.screenshot({ path: 'test-results/app-ai-history.png', fullPage: true })
|
||||
await page.locator('.profile-trigger').click()
|
||||
await page.getByText('修改资料').waitFor()
|
||||
const profileMenu = await page.getByText('修改资料').count()
|
||||
await page.screenshot({ path: 'test-results/app-ai.png', fullPage: true })
|
||||
await browser.close(); await server.close()
|
||||
|
||||
const failures = []
|
||||
if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
|
||||
if (home.selectedAll !== '全部项目') failures.push(`default project scope must be 全部项目, got ${home.selectedAll}`)
|
||||
if (home.navCount !== 0) failures.push(`all-projects view must hide the right rail, got ${home.navCount}`)
|
||||
if (home.statCount !== 4) failures.push(`expected four summary cards, got ${home.statCount}`)
|
||||
if (home.pending !== 1 || home.completed !== 0) failures.push(`all-project task sections incorrect: ${JSON.stringify(home)}`)
|
||||
if (taskModal !== 1) failures.push('task editor did not open')
|
||||
if (taskModalWidth > 396) failures.push(`task modal must fit the 420px window, got ${taskModalWidth}px`)
|
||||
if (exploreSources !== 2 || exploreDetail !== 1) failures.push(`explore reader did not render: ${JSON.stringify({ exploreSources, exploreDetail })}`)
|
||||
if (documents !== 1) failures.push(`expected one document, got ${documents}`)
|
||||
if (documentActions !== 2 || markdownEditor !== 1 || markdownEditing !== 1) failures.push(`document actions did not render: ${JSON.stringify({ documentActions, markdownEditor, markdownEditing })}`)
|
||||
if (experts !== 1 || aiSwitches !== 2 || aiHistory !== 1) failures.push(`AI session pages did not render: ${JSON.stringify({ experts, aiSwitches, aiHistory })}`)
|
||||
if (profileMenu !== 1) failures.push('profile menu did not open')
|
||||
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
|
||||
console.log(JSON.stringify({ home, documents, documentActions, markdownEditor, markdownEditing, markdownLayout, experts, aiSwitches, aiHistory, taskModal, taskModalWidth, exploreSources, exploreDetail, profileMenu, errors }, null, 2))
|
||||
if (failures.length) throw new Error(failures.join('\n'))
|
||||
4395
apps/desktop/src-tauri/Cargo.lock
generated
Normal 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",
|
||||
] }
|
||||
|
||||
BIN
apps/desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
apps/desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 926 B |
BIN
apps/desktop/src-tauri/icons/icon.icns
Normal file
BIN
apps/desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
senlinai_workbench_lib::run()
|
||||
senlinai_app_lib::run();
|
||||
}
|
||||
|
||||
162
apps/desktop/src-tauri/src/windows_appbar.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! Windows system AppBar integration for the App side panel.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
|
||||
|
||||
use windows::{
|
||||
core::{w, Error, Result},
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
|
||||
Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
},
|
||||
UI::{
|
||||
Shell::{
|
||||
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
||||
ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
||||
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
GetWindowRect, RegisterWindowMessageW, SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER,
|
||||
WM_ACTIVATE, WM_WINDOWPOSCHANGED,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SUBCLASS_ID: usize = 0x5341_4142; // "SAAB" (SenlinAI AppBar)
|
||||
|
||||
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
||||
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
||||
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Registers the main App window as a right-edge AppBar.
|
||||
pub fn register(hwnd: HWND) -> Result<()> {
|
||||
if REGISTERED_HWND.load(Ordering::Acquire) != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let callback_message = unsafe { RegisterWindowMessageW(w!("SenlinAI.App.AppBar.Callback")) };
|
||||
if callback_message == 0 {
|
||||
return Err(Error::from_win32());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
SetWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID, 0).ok()?;
|
||||
}
|
||||
|
||||
let mut data = appbar_data(hwnd);
|
||||
data.uCallbackMessage = callback_message;
|
||||
if unsafe { SHAppBarMessage(ABM_NEW, &mut data) } == 0 {
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID);
|
||||
}
|
||||
return Err(Error::from_win32());
|
||||
}
|
||||
|
||||
CALLBACK_MESSAGE.store(callback_message, Ordering::Release);
|
||||
REGISTERED_HWND.store(hwnd.0 as isize, Ordering::Release);
|
||||
|
||||
if let Err(error) = layout(hwnd) {
|
||||
unregister();
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
||||
pub fn unregister() {
|
||||
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
||||
CALLBACK_MESSAGE.store(0, Ordering::Release);
|
||||
|
||||
if raw_hwnd == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let hwnd = HWND(raw_hwnd as *mut _);
|
||||
let mut data = appbar_data(hwnd);
|
||||
unsafe {
|
||||
SHAppBarMessage(ABM_REMOVE, &mut data);
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn appbar_subclass_proc(
|
||||
hwnd: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_subclass_id: usize,
|
||||
_reference_data: usize,
|
||||
) -> LRESULT {
|
||||
let callback_message = CALLBACK_MESSAGE.load(Ordering::Acquire);
|
||||
|
||||
if message == callback_message && wparam.0 as u32 == ABN_POSCHANGED {
|
||||
let _ = layout(hwnd);
|
||||
} else if message == WM_ACTIVATE {
|
||||
let mut data = appbar_data(hwnd);
|
||||
SHAppBarMessage(ABM_ACTIVATE, &mut data);
|
||||
} else if message == WM_WINDOWPOSCHANGED {
|
||||
let mut data = appbar_data(hwnd);
|
||||
SHAppBarMessage(ABM_WINDOWPOSCHANGED, &mut data);
|
||||
|
||||
if !LAYOUT_IN_PROGRESS.load(Ordering::Acquire) {
|
||||
let _ = layout(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
DefSubclassProc(hwnd, message, wparam, lparam)
|
||||
}
|
||||
|
||||
fn layout(hwnd: HWND) -> Result<()> {
|
||||
if LAYOUT_IN_PROGRESS.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = layout_inner(hwnd);
|
||||
LAYOUT_IN_PROGRESS.store(false, Ordering::Release);
|
||||
result
|
||||
}
|
||||
|
||||
fn layout_inner(hwnd: HWND) -> Result<()> {
|
||||
let mut window_rect = RECT::default();
|
||||
unsafe { GetWindowRect(hwnd, &mut window_rect)? };
|
||||
let width = window_rect.right - window_rect.left;
|
||||
|
||||
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||
let mut monitor_info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
||||
|
||||
let mut data = appbar_data(hwnd);
|
||||
data.uEdge = ABE_RIGHT;
|
||||
data.rc = monitor_info.rcMonitor;
|
||||
data.rc.left = data.rc.right - width;
|
||||
|
||||
unsafe {
|
||||
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
||||
data.rc.left = data.rc.right - width;
|
||||
SHAppBarMessage(ABM_SETPOS, &mut data);
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
data.rc.left,
|
||||
data.rc.top,
|
||||
data.rc.right - data.rc.left,
|
||||
data.rc.bottom - data.rc.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn appbar_data(hwnd: HWND) -> APPBARDATA {
|
||||
APPBARDATA {
|
||||
cbSize: std::mem::size_of::<APPBARDATA>() as u32,
|
||||
hWnd: hwnd,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +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": null
|
||||
"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"
|
||||
"targets": "all",
|
||||
"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
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
|
||||
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot,
|
||||
IconSun, IconUpload, IconUser,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
ApiError, createAISession, createMarkdownDocument, createProject, fetchDocumentBlob, fetchProjectWorkspace, fetchProjects, getCurrentUser, getDocument,
|
||||
listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateDocument, updateTask,
|
||||
uploadDocument,
|
||||
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type DocumentDetail, type Expert, type Project, type Workspace, type WorkspaceDocument, type WorkspaceTask,
|
||||
} from './api'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
const SESSION_KEY = 'senlin-app-session'
|
||||
type View = 'tasks' | 'documents' | 'ai' | 'cron'
|
||||
type WorkspaceMode = 'workbench' | 'explore'
|
||||
type SavedSession = ApiSession & { user?: CurrentUser }
|
||||
type TaskWithProject = WorkspaceTask & { projectName: string }
|
||||
|
||||
const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'tasks', label: '计划', icon: <IconList /> },
|
||||
{ id: 'documents', label: '资料', icon: <IconFile /> },
|
||||
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
|
||||
{ id: 'cron', label: '任务', icon: <IconClockCircle /> },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const stored = useMemo(readStoredSession, [])
|
||||
const [session, setSession] = useState<ApiSession | null>(stored)
|
||||
const [user, setUser] = useState<CurrentUser | null>(stored?.user ?? null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [workspaces, setWorkspaces] = useState<Record<string, Workspace>>({})
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('workbench')
|
||||
const [view, setView] = useState<View>('tasks')
|
||||
const [dark, setDark] = useState(false)
|
||||
const [loading, setLoading] = useState(Boolean(stored))
|
||||
const [error, setError] = useState('')
|
||||
const [taskEditor, setTaskEditor] = useState<TaskWithProject | null>(null)
|
||||
const [projectModalOpen, setProjectModalOpen] = useState(false)
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
||||
const [experts, setExperts] = useState<Expert[]>([])
|
||||
const workspaceRequestRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
if (!user) void getCurrentUser(session).then(setUser).catch((requestError) => setError(errorMessage(requestError)))
|
||||
void loadWorkspaces(session)
|
||||
}, [session])
|
||||
|
||||
async function loadWorkspaces(activeSession = session) {
|
||||
if (!activeSession) return
|
||||
const requestId = ++workspaceRequestRef.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const nextProjects = await fetchProjects(activeSession)
|
||||
const items = await Promise.all(nextProjects.map(async (project) => [project.id, await fetchProjectWorkspace(activeSession, project.id)] as const))
|
||||
if (requestId !== workspaceRequestRef.current) return
|
||||
setProjects(nextProjects)
|
||||
setWorkspaces(Object.fromEntries(items))
|
||||
setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '')
|
||||
} catch (requestError) {
|
||||
if (requestId === workspaceRequestRef.current) setError(errorMessage(requestError))
|
||||
} finally {
|
||||
if (requestId === workspaceRequestRef.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProjectWorkspace(targetProjectId: string, activeSession = session) {
|
||||
if (!activeSession || !targetProjectId) return
|
||||
const workspace = await fetchProjectWorkspace(activeSession, targetProjectId)
|
||||
setWorkspaces((current) => ({ ...current, [targetProjectId]: workspace }))
|
||||
}
|
||||
|
||||
async function loadAI() {
|
||||
if (!session || experts.length) return
|
||||
try { setExperts(await listAIExperts(session)) } catch (requestError) { setError(errorMessage(requestError)) }
|
||||
}
|
||||
|
||||
const visibleWorkspaces = projectId ? [workspaces[projectId]].filter(Boolean) : Object.values(workspaces)
|
||||
const activeProject = projectId ? projects.find((project) => project.id === projectId) : undefined
|
||||
const tasks = visibleWorkspaces.flatMap((workspace) => workspace.tasks.map((task) => ({ ...task, projectName: workspace.project.name })))
|
||||
const title = activeProject?.name ?? '全部项目'
|
||||
|
||||
function cycleProject(direction: number) {
|
||||
const index = projects.findIndex((project) => project.id === projectId)
|
||||
const next = projectId ? (index + direction + projects.length) % projects.length : direction > 0 ? 0 : projects.length - 1
|
||||
setProjectId(projects[next]?.id ?? '')
|
||||
setWorkspaceMode('workbench')
|
||||
setView('tasks')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
workspaceRequestRef.current += 1
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
localStorage.removeItem('senlin-mini-session')
|
||||
setSession(null); setUser(null); setProjects([]); setWorkspaces({}); setProjectId(''); setView('tasks')
|
||||
}
|
||||
|
||||
if (!session) return <Login onLogin={(next) => { workspaceRequestRef.current += 1; setProjects([]); setWorkspaces({}); setProjectId(''); setSession(next); setUser(next.user); persistSession(next) }} />
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${dark ? 'theme-dark' : ''} ${!projectId || workspaceMode === 'explore' ? 'no-rail' : ''}`}>
|
||||
<header className="app-header">
|
||||
<div className="app-brand" data-tauri-drag-region><img src="/senlinai-icon.svg" alt="" /><span>森林AI</span></div>
|
||||
<div className="header-actions">
|
||||
<Button type="text" shape="circle" aria-label={dark ? '切换浅色模式' : '切换深色模式'} icon={dark ? <IconSun /> : <IconMoon />} onClick={() => setDark((value) => !value)} />
|
||||
<ProfileMenu user={user} onProfile={() => setProfileModalOpen(true)} onLogout={() => logout()} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="project-strip" aria-label="项目列表">
|
||||
<button className={workspaceMode === 'workbench' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('workbench')}><IconApps /><span>工作台</span></button>
|
||||
<button className={workspaceMode === 'explore' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('explore')}><IconCompass /><span>探索</span></button>
|
||||
<Select className="project-select" value={projectId || 'all'} onChange={(value) => { setProjectId(value === 'all' ? '' : value); setWorkspaceMode('workbench'); setView('tasks') }} triggerElement={() => <button className={`project-pill project-current ${projectId ? 'active' : ''}`} aria-label={`切换项目,当前为${title}`}><i style={{ background: activeProject?.background || '#165DFF' }}>{activeProject ? projectLabel(activeProject) : <IconFolder />}</i><span>{title}</span><IconDown className="project-dropdown-icon" /></button>}>
|
||||
<Select.Option value="all">全部项目</Select.Option>
|
||||
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
|
||||
</Select>
|
||||
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
|
||||
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
|
||||
<Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
|
||||
</section>
|
||||
|
||||
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||||
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel session={session} projectName={activeProject?.name ?? ''} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} onUploadDocument={async (file) => { await uploadDocument(session, projectId, file); await refreshProjectWorkspace(projectId) }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await refreshProjectWorkspace(projectId) }} onLoadMarkdown={(documentId) => getDocument(session, projectId, documentId)} onUpdateMarkdown={async (input) => { await updateDocument(session, projectId, input.id, input); await refreshProjectWorkspace(projectId) }} onOpenExternalDocument={async (documentId) => { const preview = window.open('', '_blank'); try { const blob = await fetchDocumentBlob(session, projectId, documentId); const url = URL.createObjectURL(blob); if (preview) { preview.opener = null; preview.location.href = url } else { const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.click() } window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } catch (requestError) { preview?.close(); throw requestError } }} onCreateAISession={async (input) => { await createAISession(session, projectId, input); await refreshProjectWorkspace(projectId) }} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
|
||||
|
||||
{projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
|
||||
{navItems.map((item) => <button key={item.id} title={item.label} className={view === item.id ? 'active' : ''} onClick={() => { setView(item.id); if (item.id === 'ai') void loadAI() }}>{item.icon}<span>{item.label}</span></button>)}
|
||||
</aside> : null}
|
||||
|
||||
<TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
|
||||
if (!taskEditor) return
|
||||
await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed })
|
||||
await refreshProjectWorkspace(taskEditor.projectId)
|
||||
setTaskEditor(null)
|
||||
}} />
|
||||
<ProjectModal visible={projectModalOpen} onClose={() => setProjectModalOpen(false)} onSave={async (name) => {
|
||||
const project = await createProject(session, { name, identifier: slugify(name) || `project-${Date.now().toString(36)}`, icon: 'folder', background: '#165DFF', description: '' })
|
||||
setProjectModalOpen(false)
|
||||
await loadWorkspaces()
|
||||
setProjectId(project.id)
|
||||
}} />
|
||||
<ProfileModal user={user} visible={profileModalOpen} onClose={() => setProfileModalOpen(false)} onSave={async (input) => {
|
||||
const nextUser = await updateCurrentUser(session, input)
|
||||
setUser(nextUser)
|
||||
persistSession({ ...session, user: nextUser })
|
||||
setProfileModalOpen(false)
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts, onUploadDocument, onCreateMarkdown, onLoadMarkdown, onUpdateMarkdown, onOpenExternalDocument, onCreateAISession }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[]; onUploadDocument: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void>; onLoadMarkdown: (documentId: string) => Promise<DocumentDetail>; onUpdateMarkdown: (input: { id: string; name: string; markdown: string; revision: number }) => Promise<void>; onOpenExternalDocument: (documentId: string) => Promise<void>; onCreateAISession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
|
||||
let panel: React.ReactNode
|
||||
if (view === 'tasks') panel = <TaskBoard title="工作计划" subtitle="" tasks={tasks} onEditTask={onEditTask} />
|
||||
else if (view === 'documents') panel = <DocumentPanel workspaces={workspaces} onUpload={onUploadDocument} onCreateMarkdown={onCreateMarkdown} onLoadMarkdown={onLoadMarkdown} onUpdateMarkdown={onUpdateMarkdown} onOpenExternalDocument={onOpenExternalDocument} />
|
||||
else if (view === 'ai') panel = <AIPanel workspaces={workspaces} experts={experts} onLoad={onLoadAI} onCreateSession={onCreateAISession} />
|
||||
else panel = <CronPanel title="" workspaces={workspaces} />
|
||||
return <section className="project-content"><header className="project-page-header"><Title heading={3}>{title}</Title></header>{panel}</section>
|
||||
}
|
||||
|
||||
function TaskBoard({ title, subtitle, tasks, onEditTask, showCompleted = true }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; showCompleted?: boolean }) {
|
||||
const [tag, setTag] = useState('全部')
|
||||
const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))]
|
||||
const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag)
|
||||
const pending = visible.filter((task) => !task.completed)
|
||||
const completed = visible.filter((task) => task.completed)
|
||||
return <section className="content-page task-page">
|
||||
<div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div><Tag color="arcoblue">{pending.length} 项待办</Tag></div>
|
||||
<div className="tag-filter"><button className={tag === '全部' ? 'active' : ''} onClick={() => setTag('全部')}>全部</button>{tags.map((item) => <button key={item} className={tag === item ? 'active' : ''} onClick={() => setTag(item)}>{item}</button>)}</div>
|
||||
<section className="task-section"><div className="section-label"><strong>待办任务</strong><span>{pending.length}</span></div><div className="task-list">{pending.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!pending.length && <Empty description="当前没有待办任务" />}</div></section>
|
||||
{showCompleted ? <section className="task-section completed-section"><div className="section-label"><strong>已完成</strong><span>{completed.length}</span></div><div className="task-list">{completed.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!completed.length && <Empty description="尚未完成任何任务" />}</div></section> : null}
|
||||
</section>
|
||||
}
|
||||
|
||||
function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) {
|
||||
return <button className={`task-row ${task.completed ? 'done' : ''}`} onClick={onClick}><span className="task-check">{task.completed ? <IconCheckCircleFill /> : <IconCheckCircle />}</span><span className="task-copy"><strong>{task.title}</strong><small>{task.summary || '暂无任务说明'}</small></span><span className="task-meta"><Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || task.projectName}</Tag><small>{formatDate(task.completedAt || task.createdAt)}</small></span></button>
|
||||
}
|
||||
|
||||
|
||||
function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void }) {
|
||||
const pending = tasks.filter((task) => !task.completed)
|
||||
const latestFiles = workspaces.flatMap((workspace) => workspace.documents.map((document) => ({ ...document, projectName: workspace.project.name }))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
|
||||
const statItems = [
|
||||
{ label: '项目', value: projects.length, color: 'blue' }, { label: '待办', value: pending.length, color: 'orange' },
|
||||
{ label: '文件', value: latestFiles.length, color: 'green' }, { label: '计划任务', value: workspaces.reduce((total, workspace) => total + workspace.cronPlans.length, 0), color: 'purple' },
|
||||
]
|
||||
return <section className="content-page all-projects-home"><div className="content-heading"><div><Title heading={4}>全部项目</Title></div><Button type="primary" icon={<IconPlus />} onClick={onCreateProject}>创建项目</Button></div><div className="stat-grid">{statItems.map((item) => <div className={`stat-card ${item.color}`} key={item.label}><strong>{item.value}</strong><span>{item.label}</span></div>)}</div><TaskBoard title="待办任务" subtitle="" tasks={tasks} onEditTask={onEditTask} showCompleted={false} /><section className="latest-files"><div className="section-label"><strong>最新文件</strong><span>{latestFiles.length}</span></div><div className="detail-list">{latestFiles.map((file) => <article className="detail-row" key={file.id}><IconFile /><div><strong>{file.name}</strong><p>{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}</p></div><Tag>{file.projectName}</Tag></article>)}{!latestFiles.length && <Empty description="暂无文件" />}</div></section></section>
|
||||
}
|
||||
|
||||
function ExplorePanel({ session, projectName }: { session: ApiSession; projectName: string }) {
|
||||
const [sources, setSources] = useState<DatasetSource[]>([])
|
||||
const [items, setItems] = useState<DatasetItem[]>([])
|
||||
const [sourceId, setSourceId] = useState('all')
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => { void refreshSources() }, [session])
|
||||
useEffect(() => { void refreshItems() }, [session, sourceId])
|
||||
|
||||
async function refreshSources() {
|
||||
setLoading(true); setError('')
|
||||
try { setSources(await listDatasetSources(session)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
|
||||
}
|
||||
async function refreshItems() {
|
||||
setLoading(true); setError('')
|
||||
try { const page = await listDatasetItems(session, sourceId === 'all' ? undefined : sourceId); setItems(page.items); setSelectedId((current) => page.items.some((item) => item.id === current) ? current : page.items[0]?.id ?? '') } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const allCount = sources.reduce((total, source) => total + source.itemCount, 0)
|
||||
const selected = items.find((item) => item.id === selectedId) ?? items[0]
|
||||
const selectedSource = sources.find((source) => source.id === selected?.sourceId)
|
||||
return <section className="content-page explore-page"><div className="content-heading"><div><Title heading={4}>探索</Title><Text type="secondary">集中阅读数据源内容,筛选后沉淀到项目资料。</Text></div><Button type="text" shape="circle" aria-label="刷新探索内容" loading={loading} icon={<IconRefresh />} onClick={() => { void refreshSources(); void refreshItems() }} /></div>{error ? <Alert type="error" content={error} /> : null}<div className="explore-source-strip"><button className={sourceId === 'all' ? 'active' : ''} onClick={() => setSourceId('all')}><IconCompass /><span>全部</span><small>{allCount}</small></button>{sources.map((source) => <button className={sourceId === source.id ? 'active' : ''} key={source.id} onClick={() => setSourceId(source.id)}>{source.iconUrl ? <img src={source.iconUrl} alt="" /> : <IconBook />}<span>{source.name}</span><small>{source.itemCount}</small></button>)}</div><div className="explore-reader">{items.length ? <><div className="explore-item-list">{items.map((item) => <button className={item.id === selected?.id ? 'active' : ''} key={item.id} onClick={() => setSelectedId(item.id)}><span><b>{item.title}</b><small>{item.summary || '暂无摘要'}</small></span><i>{item.starred ? '★' : formatDate(item.publishedAt || item.createdAt)}</i></button>)}</div><article className="explore-detail"><div className="explore-detail-meta"><Tag color="arcoblue">{selectedSource?.name || '全部'}</Tag><small>{formatDate(selected?.publishedAt || selected?.createdAt || '')}</small></div><Title heading={5}>{selected?.title}</Title>{selected?.imageUrl ? <img src={selected.imageUrl} alt="" /> : null}<p>{selected?.content || selected?.summary || '暂无正文'}</p><div className="explore-detail-actions"><Button type="text" icon={<IconBook />} disabled={!projectName}>沉淀到{projectName || '项目'}</Button>{selected?.url ? <Button type="text" href={selected.url} target="_blank">打开原文</Button> : null}</div></article></> : <Empty description="暂无探索内容,请先在 Web 工作台添加或同步数据源" />}</div></section>
|
||||
}
|
||||
|
||||
function DocumentPanel({ workspaces, onUpload, onCreateMarkdown, onLoadMarkdown, onUpdateMarkdown, onOpenExternalDocument }: { workspaces: Workspace[]; onUpload: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void>; onLoadMarkdown: (documentId: string) => Promise<DocumentDetail>; onUpdateMarkdown: (input: { id: string; name: string; markdown: string; revision: number }) => Promise<void>; onOpenExternalDocument: (documentId: string) => Promise<void> }) {
|
||||
const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<DocumentDetail | null>(null)
|
||||
const [name, setName] = useState('未命名笔记.md')
|
||||
const [markdown, setMarkdown] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function saveMarkdown() {
|
||||
if (!name.trim()) return
|
||||
setSaving(true); setError('')
|
||||
try { if (editing) await onUpdateMarkdown({ id: editing.id, name: name.trim(), markdown, revision: editing.revision }); else await onCreateMarkdown({ name: name.trim(), markdown }); setCreating(false); setEditing(null); setName('未命名笔记.md'); setMarkdown('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function uploadFiles(files: FileList | null) {
|
||||
if (!files?.length) return
|
||||
setUploading(true); setError('')
|
||||
try { for (const file of Array.from(files)) await onUpload(file) } catch (requestError) { setError(errorMessage(requestError)) } finally { setUploading(false); if (inputRef.current) inputRef.current.value = '' }
|
||||
}
|
||||
|
||||
async function openDocument(document: WorkspaceDocument) {
|
||||
setError('')
|
||||
try {
|
||||
if (document.extension.toLowerCase() === '.md' || document.mimeType.includes('markdown')) { const detail = await onLoadMarkdown(document.id); setEditing(detail); setName(detail.name); setMarkdown(detail.markdown ?? '') }
|
||||
else await onOpenExternalDocument(document.id)
|
||||
} catch (requestError) { setError(errorMessage(requestError)) }
|
||||
}
|
||||
|
||||
if (creating || editing) return <section className="content-page markdown-editor-page"><div className="content-heading"><div><Text type="secondary">项目资料</Text><Title heading={4}>{editing ? '编辑 Markdown' : '新建 Markdown'}</Title></div><Button type="text" onClick={() => { setCreating(false); setEditing(null); setError('') }}>返回资料</Button></div>{error ? <Alert type="error" content={error} /> : null}<Form layout="vertical"><Form.Item label="文件名称" required><Input value={name} onChange={setName} placeholder="例如:会议记录.md" /></Form.Item><Form.Item label="Markdown 内容"><Input.TextArea value={markdown} onChange={setMarkdown} placeholder={'# 标题\n\n开始记录…'} autoSize={{ minRows: 14, maxRows: 24 }} /></Form.Item><div className="markdown-editor-actions"><Button onClick={() => { setCreating(false); setEditing(null) }}>取消</Button><Button type="primary" loading={saving} onClick={() => { void saveMarkdown() }}>{editing ? '保存修改' : '创建文件'}</Button></div></Form></section>
|
||||
|
||||
return <section className="content-page documents-page"><div className="content-heading"><div><Title heading={4}>笔记资料</Title></div><div className="document-actions"><input ref={inputRef} className="file-picker" type="file" multiple onChange={(event) => { void uploadFiles(event.target.files) }} /><Button icon={<IconUpload />} loading={uploading} onClick={() => inputRef.current?.click()}>上传文件</Button><Button type="primary" icon={<IconPlus />} onClick={() => setCreating(true)}>新建 Markdown</Button></div></div>{error ? <Alert type="error" content={error} /> : null}<div className="detail-list">{documents.map((item) => <button className="detail-row document-row" key={item.id} onClick={() => { void openDocument(item) }}><IconFile /><div><strong>{item.name}</strong><p>{item.extension || item.mimeType || item.kind} · 更新于 {formatDate(item.updatedAt)}</p></div><Tag>{item.extension.toLowerCase() === '.md' ? '编辑' : '浏览器打开'}</Tag></button>)}{!documents.length && <Empty description="暂无笔记或资料" />}</div></section>
|
||||
}
|
||||
|
||||
function AIPanel({ workspaces, experts, onLoad, onCreateSession }: { workspaces: Workspace[]; experts: Expert[]; onLoad: () => void; onCreateSession: (input: { title: string; context: string; expertId?: string }) => Promise<void> }) {
|
||||
useEffect(() => { onLoad() }, [onLoad])
|
||||
const [page, setPage] = useState<'new' | 'history'>('new')
|
||||
const [title, setTitle] = useState('')
|
||||
const [context, setContext] = useState('')
|
||||
const [expertId, setExpertId] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const sessions = workspaces.flatMap((workspace) => workspace.aiSessions.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
useEffect(() => { setExpertId((current) => experts.some((expert) => expert.id === current) ? current : experts[0]?.id ?? '') }, [experts])
|
||||
|
||||
async function createSession() {
|
||||
setSaving(true); setError('')
|
||||
try { await onCreateSession({ title: title.trim() || '新会话', context: context.trim(), expertId: expertId || undefined }); setTitle(''); setContext(''); setPage('history') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return <section className="content-page ai-session-page"><div className="content-heading"><div><Title heading={4}>{page === 'new' ? '新建会话' : '历史会话'}</Title></div><div className="ai-page-switch"><Button type={page === 'new' ? 'primary' : 'text'} icon={<IconPlus />} onClick={() => setPage('new')}>新建会话</Button><Button type={page === 'history' ? 'primary' : 'text'} icon={<IconClockCircle />} onClick={() => setPage('history')}>历史会话</Button></div></div>{error ? <Alert type="error" content={error} /> : null}{page === 'new' ? <Form className="ai-session-form" layout="vertical"><Form.Item label="会话标题"><Input value={title} onChange={setTitle} placeholder="例如:梳理本周项目计划" /></Form.Item><Form.Item label="选择专家"><div className="expert-chips">{experts.map((expert) => <button className={expert.id === expertId ? 'active' : ''} key={expert.id} type="button" onClick={() => setExpertId(expert.id)}><i style={{ background: expert.color }}>{expert.emoji}</i>{expert.name}</button>)}{!experts.length && <Text type="secondary">暂无可用专家</Text>}</div></Form.Item><Form.Item label="上下文或问题"><Input.TextArea value={context} onChange={setContext} placeholder="描述你希望 AI 协助完成的内容…" autoSize={{ minRows: 8, maxRows: 16 }} /></Form.Item><div className="ai-session-actions"><Button type="primary" loading={saving} icon={<IconRobot />} onClick={() => { void createSession() }}>创建会话</Button></div></Form> : <div className="ai-history-page detail-list">{sessions.map((item) => <article className="detail-row" key={item.id}><IconRobot /><div><strong>{item.title}</strong><p>{item.summary || 'AI 会话'} · {formatDate(item.updatedAt)}</p></div><Tag>{item.projectName}</Tag></article>)}{!sessions.length && <Empty description="暂无历史会话,先新建一个会话吧" />}</div>}</section>
|
||||
}
|
||||
|
||||
function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
|
||||
const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
return <Panel title="计划任务" subtitle={title}>{plans.map((item) => <article className="detail-row" key={item.id}><IconClockCircle /><div><strong>{item.title}</strong><p>{item.schedule} · {item.enabled ? '已启用' : '已停用'}</p></div><Tag>{item.projectName}</Tag></article>)}{!plans.length && <Empty description="暂无计划任务" />}</Panel>
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <section className="content-page"><div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div></div><div className="detail-list">{children}</div></section> }
|
||||
|
||||
function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) {
|
||||
const menu = <Menu><Menu.Item key="profile" onClick={onProfile}>修改资料</Menu.Item><Menu.Item key="logout" onClick={onLogout}>退出登录</Menu.Item></Menu>
|
||||
return <Dropdown droplist={menu} position="br" trigger="click"><button className="profile-trigger" aria-label={`用户菜单,${user?.displayName || '用户'}`}><Avatar size={28}>{user?.displayName.slice(0, 1) || <IconUser />}</Avatar><span>{user?.displayName || '用户'}</span><IconDown /></button></Dropdown>
|
||||
}
|
||||
|
||||
function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise<void> }) {
|
||||
const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||||
useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed); setError('') } }, [task])
|
||||
return <Modal title="编辑任务" visible={Boolean(task)} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!title.trim()) return; setSaving(true); setError(''); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="标题" required><Input value={title} onChange={setTitle} /></Form.Item><Form.Item label="说明"><Input.TextArea value={summary} onChange={setSummary} autoSize={{ minRows: 3, maxRows: 6 }} /></Form.Item><Form.Item label="标签"><Select value={tag || undefined} allowClear onChange={(value) => setTag(value ?? '')}>{[...new Set(tags.filter(Boolean))].map((item) => <Select.Option key={item} value={item}>{item}</Select.Option>)}</Select></Form.Item><Form.Item label="完成状态"><Switch checked={completed} checkedText="已完成" uncheckedText="待办" onChange={completed => setCompleted(completed)} /></Form.Item></Form></Modal>
|
||||
}
|
||||
|
||||
function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
|
||||
const [name, setName] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||||
useEffect(() => { if (visible) setError('') }, [visible])
|
||||
return <Modal title="新建项目" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!name.trim()) return; setSaving(true); setError(''); try { await onSave(name.trim()); setName('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}>{error ? <Alert type="error" content={error} /> : null}<Input autoFocus placeholder="项目名称" value={name} onChange={setName} /></Modal>
|
||||
}
|
||||
|
||||
function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void> }) {
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
|
||||
useEffect(() => { setDisplayName(user?.displayName ?? ''); if (visible) setError('') }, [user, visible])
|
||||
return <Modal title="修改资料" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!displayName.trim()) return; setSaving(true); setError(''); try { await onSave({ displayName: displayName.trim(), currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="名称" required><Input value={displayName} onChange={setDisplayName} /></Form.Item><Form.Item label="邮箱"><Input disabled value={user?.email} /></Form.Item><Form.Item label="当前密码"><Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" /></Form.Item><Form.Item label="新密码"><Input.Password value={newPassword} onChange={setNewPassword} placeholder="不修改可留空" /></Form.Item></Form></Modal>
|
||||
}
|
||||
|
||||
function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) {
|
||||
const [server, setServer] = useState('http://agent.apinb.com'); const [email, setEmail] = useState('root'); const [password, setPassword] = useState('123456'); const [loading, setLoading] = useState(false); const [error, setError] = useState('')
|
||||
return <div className="login-page"><form className="login-card" onSubmit={(event) => { event.preventDefault(); void submit() }}><Title heading={3} align="center"><img src="/senlinai-icon.svg" alt="" width="50%" /> <br/> 森林AI Agent</Title><Text type="secondary">连接你的私有工作台</Text><label>服务器地址<Input value={server} onChange={setServer} /></label><label>邮箱<Input value={email} onChange={setEmail} /></label><label>密码<Input.Password value={password} onChange={setPassword} /></label>{error && <Alert type="error" content={error} />}<Button type="primary" htmlType="submit" long loading={loading}>登录工作台</Button></form></div>
|
||||
async function submit() { setLoading(true); setError(''); try { onLogin(await login(server, email.trim(), password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } }
|
||||
}
|
||||
|
||||
function readStoredSession(): SavedSession | null { try { const value = JSON.parse(localStorage.getItem(SESSION_KEY) || localStorage.getItem('senlin-mini-session') || 'null') as SavedSession | null; if (!value?.baseUrl || !value.token) return null; setApiBaseUrl(value.baseUrl); return value } catch { return null } }
|
||||
function persistSession(session: SavedSession) { localStorage.setItem(SESSION_KEY, JSON.stringify(session)) }
|
||||
function projectLabel(project: Project) { if (project.icon && !['folder', 'project'].includes(project.icon)) return Array.from(project.icon).slice(0, 2).join(''); return Array.from(project.name.trim()).slice(0, 2).join('').toUpperCase() || <IconFolder /> }
|
||||
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `${date.getMonth() + 1}/${date.getDate()}` }
|
||||
function slugify(value: string) { return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') }
|
||||
function errorMessage(error: unknown) { return error instanceof ApiError || error instanceof Error ? error.message : '操作失败,请稍后重试' }
|
||||
213
apps/desktop/src/api.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
export type ApiSession = { baseUrl: string; token: string }
|
||||
|
||||
export type CurrentUser = { email: string; displayName: string }
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkspaceChannel = {
|
||||
id: string
|
||||
projectId: string
|
||||
type: string
|
||||
title: string
|
||||
icon: string
|
||||
count: number
|
||||
url: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type WorkspaceTask = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
summary: string
|
||||
completed: boolean
|
||||
owner: string
|
||||
due: string | null
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
tagId: string | null
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type WorkspaceDocument = { id: string; projectId: string; kind: string; name: string; extension: string; mimeType: string; updatedAt: string }
|
||||
export type DocumentDetail = WorkspaceDocument & { revision: number; markdown?: string; hasBlob: boolean; size: number; createdAt: string }
|
||||
export type WorkspaceAISession = { id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }
|
||||
export type WorkspaceInbox = { id: string; projectId: string; source: string; title: string; summary: string; status: string; tag: string; time: string }
|
||||
export type WorkspaceCronPlan = { id: string; projectId: string; title: string; schedule: string; nextRun: string | null; enabled: boolean; lastResult: string; owner: string }
|
||||
|
||||
export type Workspace = {
|
||||
project: Project & { initials: string; unreadCount: number }
|
||||
channels: WorkspaceChannel[]
|
||||
tags: Array<{ id: string; name: string }>
|
||||
recentSessions: WorkspaceAISession[]
|
||||
inbox: WorkspaceInbox[]
|
||||
tasks: WorkspaceTask[]
|
||||
aiSessions: WorkspaceAISession[]
|
||||
documents: WorkspaceDocument[]
|
||||
cronPlans: WorkspaceCronPlan[]
|
||||
}
|
||||
|
||||
export type Expert = { id: string; slug: string; category: string; categoryName: string; name: string; description: string; emoji: string; color: string }
|
||||
export type AISession = { id: string; projectId: string; title: string; context: string; status: string; expert: Expert | null; createdAt: string; updatedAt: string }
|
||||
export type CreateTaskInput = { title: string; description?: string; status?: string; dueAt?: string; tag?: string }
|
||||
export type UpdateTaskInput = { title: string; description?: string; status?: string; completed: boolean; nextProjectId?: string; tag?: string }
|
||||
export type DatasetSource = { id: string; name: string; kind: 'manual' | 'link' | 'rss'; url: string; iconUrl: string; description: string; enabled: boolean; builtIn: boolean; lastSyncedAt: string | null; itemCount: number; createdAt: string; updatedAt: string }
|
||||
export type DatasetItem = { id: string; sourceId: string; title: string; summary: string; content: string; url: string; imageUrl: string; status: 'unread' | 'read' | 'archived'; starred: boolean; publishedAt: string | null; createdAt: string; updatedAt: string }
|
||||
export type DatasetItemPage = { items: DatasetItem[]; total: number; limit: number; offset: number }
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
|
||||
|
||||
export function setApiBaseUrl(value: string) { baseUrl = normalizeBaseUrl(value) }
|
||||
export function getApiBaseUrl() { return baseUrl }
|
||||
|
||||
export async function login(server: string, email: string, password: string): Promise<ApiSession & { user: CurrentUser }> {
|
||||
setApiBaseUrl(server)
|
||||
const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', { method: 'POST', body: { email, password } })
|
||||
return { baseUrl, token: response.token, user: response.user }
|
||||
}
|
||||
|
||||
export function getCurrentUser(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<CurrentUser>('/api/v1/auth/me', { token: session.token })
|
||||
}
|
||||
|
||||
export function updateCurrentUser(session: ApiSession, input: { displayName: string; currentPassword?: string; newPassword?: string }) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<CurrentUser>('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export function fetchProjects(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<Project[]>('/api/v1/projects', { token: session.token })
|
||||
}
|
||||
|
||||
export function fetchProjectWorkspace(session: ApiSession, projectId: string) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<Workspace>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
|
||||
}
|
||||
|
||||
export function createProject(session: ApiSession, input: Pick<Project, 'name' | 'identifier' | 'icon' | 'background' | 'description'>) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<Project>('/api/v1/projects', { method: 'POST', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
|
||||
useSessionBase(session)
|
||||
return apiRequest(`/api/v1/projects/${projectId}/tasks`, { method: 'POST', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) {
|
||||
useSessionBase(session)
|
||||
return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export function uploadDocument(session: ApiSession, projectId: string, file: File, parentId = '') {
|
||||
useSessionBase(session)
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
if (parentId) body.append('parentId', parentId)
|
||||
return apiRequest(`/api/v1/projects/${projectId}/documents/uploads`, { method: 'POST', token: session.token, body })
|
||||
}
|
||||
|
||||
export function createMarkdownDocument(session: ApiSession, projectId: string, input: { name: string; markdown?: string; parentId?: string }) {
|
||||
useSessionBase(session)
|
||||
return apiRequest(`/api/v1/projects/${projectId}/documents/markdown`, { method: 'POST', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export function getDocument(session: ApiSession, projectId: string, documentId: string) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<DocumentDetail>(`/api/v1/projects/${projectId}/documents/${documentId}`, { token: session.token })
|
||||
}
|
||||
|
||||
export function updateDocument(session: ApiSession, projectId: string, documentId: string, input: { name: string; markdown: string; revision: number }) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<DocumentDetail>(`/api/v1/projects/${projectId}/documents/${documentId}`, { method: 'PATCH', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export async function fetchDocumentBlob(session: ApiSession, projectId: string, documentId: string) {
|
||||
useSessionBase(session)
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/documents/${documentId}/content`, { headers: { Authorization: `Bearer ${session.token}` } })
|
||||
if (!response.ok) throw new ApiError(response.status, fallbackCode(response.status), fallbackMessage(response.status))
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export function listAIExperts(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<Expert[]>('/api/v1/ai-experts', { token: session.token })
|
||||
}
|
||||
|
||||
export function listAISessions(session: ApiSession, projectId: string) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<AISession[]>(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token })
|
||||
}
|
||||
|
||||
export function createAISession(session: ApiSession, projectId: string, input: { title: string; context: string; expertId?: string }) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<AISession>(`/api/v1/projects/${projectId}/ai-sessions`, { method: 'POST', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export function listDatasetSources(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return apiRequest<DatasetSource[]>('/api/v1/dataset-sources', { token: session.token })
|
||||
}
|
||||
|
||||
export function listDatasetItems(session: ApiSession, sourceId?: string, offset = 0, limit = 50) {
|
||||
useSessionBase(session)
|
||||
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) })
|
||||
if (sourceId) params.set('sourceId', sourceId)
|
||||
return apiRequest<DatasetItemPage>(`/api/v1/dataset-items?${params}`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function checkServerConnection(server: string, signal?: AbortSignal) {
|
||||
try {
|
||||
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = { method?: string; token?: string; body?: unknown; responseType?: 'void'; signal?: AbortSignal }
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: { ...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) },
|
||||
body: options.body === undefined ? undefined : isFormData ? options.body as FormData : JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null
|
||||
throw new ApiError(response.status, payload?.error?.code ?? fallbackCode(response.status), payload?.error?.message ?? fallbackMessage(response.status))
|
||||
}
|
||||
if (response.status === 204 || options.responseType === 'void') return undefined as T
|
||||
const text = await response.text()
|
||||
if (!text.trim()) throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据')
|
||||
try { return JSON.parse(text) as T } catch { throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据') }
|
||||
}
|
||||
|
||||
function useSessionBase(session: ApiSession) { baseUrl = normalizeBaseUrl(session.baseUrl) }
|
||||
function normalizeBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/, ''); return !normalized || /^https?:\/\//i.test(normalized) ? normalized : `http://${normalized}` }
|
||||
function fallbackCode(status: number) { return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : status === 404 ? 'not_found' : status === 409 ? 'conflict' : status >= 500 ? 'internal_error' : 'request_failed' }
|
||||
function fallbackMessage(status: number) { return status === 401 ? '登录已失效,请重新登录' : status === 403 ? '没有权限执行此操作' : status === 404 ? '请求的内容不存在' : status === 409 ? '操作冲突,请刷新后重试' : status >= 500 ? '服务器暂时无法处理请求,请稍后重试' : '请求失败,请稍后重试' }
|
||||
38
apps/desktop/src/main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
type ErrorBoundaryState = { error: Error | null }
|
||||
|
||||
class AppErrorBoundary extends React.Component<React.PropsWithChildren, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: 24, color: '#1d2129', background: '#f2f3f5' }}>
|
||||
<h2>页面加载失败</h2>
|
||||
<p>请重新登录后再试;如果问题仍出现,请将此信息反馈给管理员。</p>
|
||||
<p style={{ color: '#86909c', wordBreak: 'break-word' }}>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => { localStorage.removeItem('senlin-mini-session'); window.location.reload() }}>退出并重新登录</button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider>
|
||||
<AppErrorBoundary><App /></AppErrorBoundary>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
97
apps/desktop/src/styles.css
Normal file
@@ -0,0 +1,97 @@
|
||||
:root { font-family: Inter, "Microsoft YaHei", sans-serif; color: #1d2129; background: #f4f5f7; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; }
|
||||
button { font: inherit; }
|
||||
.arco-modal-wrapper { padding: 12px; }
|
||||
.arco-modal { width: min(520px, calc(100vw - 24px)); max-width: calc(100vw - 24px); }
|
||||
.arco-modal-header { min-width: 0; }
|
||||
.arco-modal-title { overflow-wrap: anywhere; }
|
||||
.arco-modal-body { max-height: calc(100vh - 180px); overflow-y: auto; overscroll-behavior: contain; }
|
||||
.arco-modal-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.arco-trigger-popup { max-width: calc(100vw - 24px); }
|
||||
|
||||
.app-shell { --canvas: #f6f7f9; --surface: #fff; --line: #e5e6eb; --text: #1d2129; --muted: #86909c; --rail: #f2f3f5; display: grid; grid-template: 48px 46px minmax(0, 1fr) / minmax(0, 1fr) 58px; min-height: 100vh; color: var(--text); background: var(--canvas); overflow: clip; }
|
||||
.app-shell.theme-dark { --canvas: #171a1f; --surface: #23272e; --line: #3a3f47; --text: #f2f3f5; --muted: #a9b0bb; --rail: #2b3038; color-scheme: dark; }
|
||||
.app-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; min-width: 0; padding: 0 12px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface); }
|
||||
.app-brand { display: flex; align-items: center; gap: 7px; min-width: 0; height: 100%; font-weight: 700; letter-spacing: .1px; }
|
||||
.app-brand img { width: 23px; height: 23px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 5px; }
|
||||
.profile-trigger { display: inline-flex; align-items: center; gap: 6px; max-width: 144px; border: 0; padding: 4px; color: var(--text); background: transparent; border-radius: 8px; cursor: pointer; }
|
||||
.profile-trigger:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
|
||||
.profile-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
|
||||
.profile-trigger > svg { flex: 0 0 auto; color: var(--muted); font-size: 13px; }
|
||||
|
||||
.project-strip { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; min-width: 0; padding: 5px 9px; border-bottom: 1px solid var(--line); background: var(--surface); }
|
||||
.project-scroller { display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; flex: 1; }
|
||||
.project-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; max-width: 122px; min-height: 30px; padding: 4px 8px; border: 1px solid transparent; border-radius: 8px; color: #4e5969; background: transparent; cursor: pointer; }
|
||||
.project-pill:hover, .project-pill.active { color: #165dff; background: #e8f3ff; border-color: #d3e7ff; }
|
||||
.project-pill i { display: grid; place-items: center; width: 19px; height: 19px; border-radius: 6px; color: #fff; font-style: normal; font-size: 9px; font-weight: 700; }
|
||||
.project-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
|
||||
.project-select { min-width: 0; flex: 1; }.project-select .arco-select-view { border: 0; padding: 0; background: transparent; }.project-select .arco-select-view-value { display: none; }.project-current { width: 100%; max-width: none; justify-content: flex-start; }.project-current .project-dropdown-icon { flex: 0 0 auto; margin-left: 1px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.app-alert { grid-column: 1 / 2; z-index: 2; margin: 8px 12px -46px; align-self: start; }
|
||||
.app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; }
|
||||
.app-shell.no-rail .app-main { grid-column: 1 / -1; }
|
||||
.app-main > .arco-spin { min-height: 100%; }
|
||||
.content-page { width: 100%; max-width: 650px; min-width: 0; margin: 0 auto; }
|
||||
.project-content { min-width: 0; }
|
||||
.project-page-header { width: calc(100% + 30px); margin: -18px -15px 18px; padding: 20px 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface); }
|
||||
.project-page-header .arco-typography { margin: 0; font-size: 26px; line-height: 1.25; }
|
||||
.document-actions, .markdown-editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.file-picker { display: none; }
|
||||
.document-row { width: 100%; color: var(--text); text-align: left; cursor: pointer; }
|
||||
.document-row:hover { border-color: #9bc7ff; background: #f7fbff; }
|
||||
.documents-page .arco-alert, .markdown-editor-page .arco-alert { margin-bottom: 12px; }
|
||||
.markdown-editor-page .arco-form { display: grid; min-width: 0; gap: 2px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
|
||||
.markdown-editor-page .arco-form-item, .markdown-editor-page .arco-form-item-wrapper { min-width: 0; }
|
||||
.markdown-editor-page .arco-textarea { min-height: 280px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.65; }
|
||||
.content-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.content-heading .arco-typography { margin: 0; }
|
||||
.content-heading h4 { margin-top: 2px; font-size: 20px; }
|
||||
.tag-filter { display: flex; gap: 6px; margin-bottom: 16px; overflow-x: auto; padding-bottom: 2px; }
|
||||
.tag-filter button { flex: 0 0 auto; padding: 5px 10px; border: 0; border-radius: 999px; color: var(--muted); background: var(--surface); cursor: pointer; font-size: 12px; }
|
||||
.tag-filter button.active { color: #fff; background: #165dff; }
|
||||
.task-section { margin-bottom: 18px; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
|
||||
.completed-section { opacity: .82; }
|
||||
.section-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; font-size: 13px; }
|
||||
.section-label span { display: grid; place-items: center; min-width: 21px; height: 21px; border-radius: 50%; color: #165dff; background: #e8f3ff; font-size: 11px; font-weight: 700; }
|
||||
.task-list { display: grid; gap: 7px; }
|
||||
.task-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; border: 1px solid transparent; border-radius: 9px; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
|
||||
.task-row:hover { border-color: #c9d7ee; background: #f7faff; }
|
||||
.task-row.done .task-copy strong { color: var(--muted); text-decoration: line-through; }
|
||||
.task-check { color: #165dff; font-size: 21px; line-height: 0; }
|
||||
.task-row.done .task-check { color: #00b42a; }
|
||||
.task-copy { display: grid; min-width: 0; gap: 3px; }
|
||||
.task-copy strong, .task-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-copy strong { font-size: 13px; }
|
||||
.task-copy small { color: var(--muted); font-size: 11px; }
|
||||
.task-meta { display: grid; justify-items: end; gap: 4px; }
|
||||
.task-meta small { color: var(--muted); font-size: 10px; }
|
||||
|
||||
.channel-rail { grid-column: 2; grid-row: 3; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 6px; border-left: 1px solid var(--line); background: var(--rail); overflow-y: auto; }
|
||||
.channel-rail button { display: grid; place-items: center; width: 42px; min-height: 43px; gap: 2px; border: 0; border-radius: 10px; color: #6b7077; background: transparent; cursor: pointer; }
|
||||
.channel-rail button:hover { color: #165dff; background: #e8f3ff; }
|
||||
.channel-rail button.active { color: #165dff; background: #dbeeff; box-shadow: inset 0 0 0 1px #c9e2ff; }
|
||||
.channel-rail svg { font-size: 20px; }
|
||||
.channel-rail span { font-size: 9px; line-height: 1; }
|
||||
|
||||
.detail-list { display: grid; gap: 8px; }
|
||||
.detail-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
||||
.detail-row > svg { color: #165dff; font-size: 18px; }
|
||||
.detail-row div { min-width: 0; }
|
||||
.detail-row strong, .detail-row p { display: block; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-row strong { font-size: 13px; }.detail-row p { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.ai-page-switch, .ai-session-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||
.ai-session-page .arco-alert { margin-bottom: 12px; }
|
||||
.ai-session-form { display: grid; gap: 2px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
|
||||
.ai-session-form .arco-textarea { min-height: 180px; line-height: 1.65; }
|
||||
.expert-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.expert-chips button { display: inline-flex; align-items: center; gap: 4px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); background: var(--surface); cursor: pointer; font-size: 11px; }.expert-chips button.active { color: #165dff; border-color: #9bc7ff; background: #edf6ff; }.expert-chips i { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; font-style: normal; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 13px; }.stat-card { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }.stat-card strong { font-size: 22px; line-height: 1; }.stat-card span { color: var(--muted); font-size: 11px; }.stat-card.blue strong { color: #165dff; }.stat-card.orange strong { color: #ff7d00; }.stat-card.green strong { color: #00b42a; }.stat-card.purple strong { color: #722ed1; }
|
||||
.quick-workspace-actions { display: flex; gap: 8px; margin-bottom: 18px; }.all-projects-home .task-page { margin-top: 4px; }.latest-files { padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.project-card-grid { display: grid; gap: 9px; }.project-explore-card { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; padding: 13px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.project-explore-card:hover { border-color: #9fc9ff; background: #f7faff; }.project-explore-card > i { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: #fff; font-style: normal; font-weight: 700; font-size: 11px; }.project-explore-card span { display: grid; min-width: 0; gap: 3px; }.project-explore-card strong, .project-explore-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.project-explore-card strong { font-size: 13px; }.project-explore-card small { color: var(--muted); font-size: 11px; }
|
||||
.explore-source-strip { display: flex; gap: 7px; margin-bottom: 12px; overflow-x: auto; padding-bottom: 2px; }.explore-source-strip button { display: grid; grid-template-columns: 18px auto; grid-template-rows: auto auto; column-gap: 5px; align-items: center; min-width: 82px; padding: 8px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.explore-source-strip button.active { border-color: #9bc7ff; background: #edf6ff; color: #165dff; }.explore-source-strip svg, .explore-source-strip img { grid-row: 1 / 3; width: 18px; height: 18px; border-radius: 5px; object-fit: cover; }.explore-source-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 600; }.explore-source-strip small { color: var(--muted); font-size: 10px; }.explore-reader { display: grid; gap: 10px; }.explore-item-list { display: grid; gap: 6px; }.explore-item-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.explore-item-list button.active { border-color: #9bc7ff; background: #f0f7ff; }.explore-item-list span { display: grid; min-width: 0; gap: 4px; }.explore-item-list b, .explore-item-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.explore-item-list b { font-size: 13px; }.explore-item-list small { color: var(--muted); font-size: 11px; }.explore-item-list i { align-self: center; color: #86909c; font-style: normal; font-size: 10px; }.explore-detail { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.explore-detail .arco-typography { margin-top: 9px; }.explore-detail-meta { display: flex; align-items: center; justify-content: space-between; }.explore-detail-meta small { color: var(--muted); font-size: 11px; }.explore-detail > img { width: 100%; max-height: 180px; margin: 4px 0 10px; border-radius: 9px; object-fit: cover; }.explore-detail p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; white-space: pre-wrap; }.explore-detail-actions { display: flex; gap: 4px; margin-top: 8px; }
|
||||
|
||||
.login-page { display: grid; min-height: 100vh; place-items: center; padding: 20px; background: #edf2f9; }.login-card { display: grid; width: min(100%, 360px); gap: 12px; padding: 28px; border: 1px solid #e5e6eb; border-radius: 16px; background: #fff; box-shadow: 0 18px 48px rgba(31, 35, 41, .12); }.login-card > img { width: 34px; }.login-card .arco-typography { margin: 0; }.login-card label { display: grid; gap: 5px; color: #4e5969; font-size: 12px; }
|
||||
.theme-dark .project-strip, .theme-dark .app-header, .theme-dark .task-section, .theme-dark .detail-row, .theme-dark .tag-filter button { color: var(--text); background: var(--surface); }.theme-dark .project-pill { color: var(--muted); }.theme-dark .project-pill.active, .theme-dark .channel-rail button.active { color: #8ac7ff; background: #1d4d77; }.theme-dark .task-row:hover { background: #293847; border-color: #477da7; }.theme-dark .section-label span { color: #8ac7ff; background: #1d4d77; }
|
||||
|
||||
@media (max-width: 360px) { .profile-trigger > span, .project-cycle { display: none; }.project-pill { max-width: 90px; }.project-strip { gap: 2px; padding-inline: 5px; }.app-main { padding: 13px 9px 20px; }.project-page-header { width: calc(100% + 18px); margin: -13px -9px 15px; padding: 17px 9px 14px; }.document-actions { width: 100%; justify-content: flex-start; }.document-actions .arco-btn { padding-inline: 8px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; }.stat-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@@ -1,2 +1 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
21
apps/desktop/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
15
apps/desktop/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
12
apps/desktop/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
BIN
apps/site/assets/ai-session.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
apps/site/assets/documents.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/site/assets/explore.png
Normal file
|
After Width: | Height: | Size: 478 KiB |
10
apps/site/assets/senlinai-icon.svg
Normal 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 |
BIN
apps/site/assets/workspace.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
39
apps/site/design-qa.md
Normal 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 product’s 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
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,9 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('project workbench shell renders', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('项目工作台')).toBeVisible();
|
||||
await expect(page.getByLabel('服务器登录')).toBeVisible();
|
||||
await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
|
||||
await expect(page.getByLabel('项目总览')).toBeVisible();
|
||||
});
|
||||
2690
apps/web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
|
||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
||||
|
||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
||||
|
||||
function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
|
||||
apiBase = event.detail.apiBase;
|
||||
localStorage.setItem('apiBase', apiBase);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<h1>项目工作台</h1>
|
||||
<ServerLogin {apiBase} on:serverChange={handleServerChange} />
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<ProjectDashboard
|
||||
summary={{
|
||||
project_id: 1,
|
||||
pending_inbox_count: 0,
|
||||
open_task_count: 0,
|
||||
recent_note_count: 0,
|
||||
recent_session_count: 0,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
@@ -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];
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let apiBase: string;
|
||||
|
||||
const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
|
||||
let server = apiBase;
|
||||
|
||||
function saveServer() {
|
||||
const normalized = normalizeServer(server);
|
||||
server = normalized;
|
||||
dispatch('serverChange', { apiBase: normalized });
|
||||
}
|
||||
|
||||
function normalizeServer(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
return trimmed;
|
||||
}
|
||||
return `http://${trimmed}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}>
|
||||
<label for="server-address">服务器 IP 或域名</label>
|
||||
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
||||
<button type="submit">保存服务器</button>
|
||||
</form>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
export type Suggestion = {
|
||||
kind: 'task' | 'note' | 'source';
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -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: '联系客户' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -1,111 +0,0 @@
|
||||
:root {
|
||||
color: #202124;
|
||||
background: #f6f7f9;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid #d9dde3;
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.server-login {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-login label {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.server-login input {
|
||||
width: 100%;
|
||||
border: 1px solid #c7ccd4;
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.server-login button {
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-metric {
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dashboard-metric span {
|
||||
display: block;
|
||||
color: #5f6673;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9dde3;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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) {
|
||||
async function request<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${apiBase}${path}`);
|
||||
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`),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -1,18 +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"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
2
apps/web/.gitignore → apps/web_v1/.gitignore
vendored
@@ -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
@@ -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
@@ -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
39
apps/web_v1/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
apps/web_v1/public/favicon.svg
Normal 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 |
10
apps/web_v1/public/senlinai-icon.svg
Normal 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 |
151
apps/web_v1/scripts/api-client.test.mjs
Normal 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
|
||||
}
|
||||
41
apps/web_v1/scripts/search-request-gate.test.mjs
Normal 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)
|
||||
})
|
||||
195
apps/web_v1/scripts/structure-check.mjs
Normal 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')
|
||||
763
apps/web_v1/scripts/visual-check.mjs
Normal 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
65
apps/web_v1/src/api/ai.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
154
apps/web_v1/src/api/client.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
export type ApiSession = {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type CurrentUser = {
|
||||
email: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: string
|
||||
body?: unknown
|
||||
token?: string
|
||||
responseType?: 'json' | 'void'
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type ErrorEnvelope = {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code: string
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
|
||||
|
||||
export function setApiBaseUrl(baseUrl: string) {
|
||||
configuredBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
}
|
||||
|
||||
export function getApiBaseUrl() {
|
||||
return configuredBaseUrl
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<ApiSession & { user: CurrentUser }> {
|
||||
const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
})
|
||||
return { baseUrl: configuredBaseUrl, token: response.token, user: response.user }
|
||||
}
|
||||
|
||||
export function updateCurrentUser(
|
||||
session: ApiSession,
|
||||
input: { displayName: string; currentPassword?: string; newPassword?: string },
|
||||
) {
|
||||
return apiRequest<CurrentUser>('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${configuredBaseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
},
|
||||
body: requestBody as BodyInit | undefined,
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let envelope: ErrorEnvelope | null = null
|
||||
try {
|
||||
const payload: unknown = await response.json()
|
||||
if (isErrorEnvelope(payload)) envelope = payload
|
||||
} catch {
|
||||
// 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。
|
||||
}
|
||||
if (envelope) throw new ApiError(response.status, envelope.error.code, envelope.error.message)
|
||||
throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status))
|
||||
}
|
||||
|
||||
if (response.status === 204 || options.responseType === 'void') {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
let text: string
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch {
|
||||
throw invalidResponse(response.status)
|
||||
}
|
||||
if (text.trim() === '') {
|
||||
throw invalidResponse(response.status)
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
throw invalidResponse(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
function invalidResponse(status: number) {
|
||||
return new ApiError(status, 'invalid_response', '服务器返回了无法识别的数据')
|
||||
}
|
||||
|
||||
function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
|
||||
if (!value || typeof value !== 'object' || !('error' in value)) return false
|
||||
const error = value.error
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.trim() !== '' &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string' &&
|
||||
error.message.trim() !== ''
|
||||
)
|
||||
}
|
||||
|
||||
function fallbackErrorCode(status: number) {
|
||||
if (status === 401) return 'unauthorized'
|
||||
if (status === 403) return 'forbidden'
|
||||
if (status === 404) return 'not_found'
|
||||
if (status === 409) return 'conflict'
|
||||
if (status >= 500) return 'internal_error'
|
||||
return 'request_failed'
|
||||
}
|
||||
|
||||
function fallbackErrorMessage(status: number) {
|
||||
if (status === 401) return '登录已失效,请重新登录'
|
||||
if (status === 403) return '没有权限执行此操作'
|
||||
if (status === 404) return '请求的内容不存在'
|
||||
if (status === 409) return '操作冲突,请刷新后重试'
|
||||
if (status >= 500) return '服务器暂时无法处理请求,请稍后重试'
|
||||
return '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value: string) {
|
||||
const normalized = value.trim().replace(/\/+$/, '')
|
||||
if (!normalized || /^https?:\/\//i.test(normalized)) return normalized
|
||||
return `http://${normalized}`
|
||||
}
|
||||
129
apps/web_v1/src/api/dataset.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type DatasetSourceKind = 'manual' | 'link' | 'rss'
|
||||
|
||||
export type DatasetSourceDTO = {
|
||||
id: string
|
||||
name: string
|
||||
kind: DatasetSourceKind
|
||||
url: string
|
||||
iconUrl: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
builtIn: boolean
|
||||
lastSyncedAt: string | null
|
||||
itemCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DatasetItemDTO = {
|
||||
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 DatasetSyncResultDTO = {
|
||||
sourceId: string
|
||||
status: string
|
||||
result: string
|
||||
}
|
||||
|
||||
export type DatasetItemPageDTO = {
|
||||
items: DatasetItemDTO[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export type DatasetDepositDTO = {
|
||||
documentId: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export type DatasetSourceInput = {
|
||||
name: string
|
||||
kind: DatasetSourceKind
|
||||
url: string
|
||||
iconUrl: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type DatasetItemUpdate = {
|
||||
status?: DatasetItemDTO['status']
|
||||
starred?: boolean
|
||||
}
|
||||
|
||||
export function listDatasetSources(session: ApiSession, signal?: AbortSignal) {
|
||||
return apiRequest<DatasetSourceDTO[]>('/api/v1/dataset-sources', { token: session.token, signal })
|
||||
}
|
||||
|
||||
export function createDatasetSource(session: ApiSession, input: DatasetSourceInput) {
|
||||
return apiRequest<DatasetSourceDTO>('/api/v1/dataset-sources', {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDatasetSource(session: ApiSession, sourceId: string, input: DatasetSourceInput) {
|
||||
return apiRequest<DatasetSourceDTO>(`/api/v1/dataset-sources/${sourceId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDatasetSource(session: ApiSession, sourceId: string) {
|
||||
return apiRequest<void>(`/api/v1/dataset-sources/${sourceId}`, {
|
||||
method: 'DELETE',
|
||||
token: session.token,
|
||||
responseType: 'void',
|
||||
})
|
||||
}
|
||||
|
||||
export function listDatasetItems(
|
||||
session: ApiSession,
|
||||
sourceId?: string,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) })
|
||||
if (sourceId) params.set('sourceId', sourceId)
|
||||
return apiRequest<DatasetItemPageDTO>(`/api/v1/dataset-items?${params}`, { token: session.token, signal })
|
||||
}
|
||||
|
||||
export function updateDatasetItem(session: ApiSession, itemId: string, input: DatasetItemUpdate) {
|
||||
return apiRequest<DatasetItemDTO>(`/api/v1/dataset-items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function syncDatasetSources(session: ApiSession) {
|
||||
return apiRequest<DatasetSyncResultDTO[]>('/api/v1/dataset-sources/sync', {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: {},
|
||||
})
|
||||
}
|
||||
|
||||
export function depositDatasetItem(session: ApiSession, itemId: string, projectId: string) {
|
||||
return apiRequest<DatasetDepositDTO>(`/api/v1/dataset-items/${itemId}/deposit`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { projectId },
|
||||
})
|
||||
}
|
||||
189
apps/web_v1/src/api/documents.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { ApiError, apiRequest, getApiBaseUrl, type ApiSession } from './client'
|
||||
|
||||
export type DocumentDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
parentId: string | null
|
||||
kind: 'folder' | 'file'
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
size: number
|
||||
revision: number
|
||||
markdown?: string
|
||||
hasBlob: boolean
|
||||
sourceInboxItemId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DocumentOpenIntent = {
|
||||
intentId: string
|
||||
mode: 'create' | 'open'
|
||||
projectId: string
|
||||
documentId?: string
|
||||
suggestedName?: string
|
||||
initialMarkdown?: string
|
||||
targetDirectoryId?: string
|
||||
sourceType?: string
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
export type DocumentShareDTO = {
|
||||
id: string
|
||||
url?: string
|
||||
expiresAt: string
|
||||
revokedAt: string | null
|
||||
}
|
||||
|
||||
export function listDocuments(session: ApiSession, projectId: string, signal?: AbortSignal) {
|
||||
return apiRequest<DocumentDTO[]>(`/api/v1/projects/${projectId}/documents`, { token: session.token, signal })
|
||||
}
|
||||
|
||||
export function getDocument(session: ApiSession, projectId: string, documentId: string, signal?: AbortSignal) {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/${documentId}`, { token: session.token, signal })
|
||||
}
|
||||
|
||||
export function createDocumentFolder(session: ApiSession, projectId: string, name: string, parentId = '') {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/folders`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { name, parentId },
|
||||
})
|
||||
}
|
||||
|
||||
export function createMarkdownDocument(
|
||||
session: ApiSession,
|
||||
projectId: string,
|
||||
input: { name: string; parentId?: string; markdown?: string; sourceInboxItemId?: string },
|
||||
) {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/markdown`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDocument(
|
||||
session: ApiSession,
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
input: { name?: string; parentId?: string; markdown?: string; revision?: number },
|
||||
) {
|
||||
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/${documentId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(session: ApiSession, projectId: string, documentId: string) {
|
||||
return apiRequest<void>(`/api/v1/projects/${projectId}/documents/${documentId}`, {
|
||||
method: 'DELETE',
|
||||
token: session.token,
|
||||
responseType: 'void',
|
||||
})
|
||||
}
|
||||
|
||||
export function createDocumentShare(session: ApiSession, projectId: string, documentId: string, expiresInDays = 7) {
|
||||
return apiRequest<DocumentShareDTO>(`/api/v1/projects/${projectId}/documents/${documentId}/shares`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { expiresInDays },
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchDocumentBlob(session: ApiSession, projectId: string, documentId: string, download = false) {
|
||||
const suffix = download ? 'download' : 'content'
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/${suffix}`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export function fetchLegacySheetPreview(session: ApiSession, projectId: string, documentId: string) {
|
||||
return apiRequest<{ sheetName: string; rows: string[][]; truncated: boolean }>(
|
||||
`/api/v1/projects/${projectId}/documents/${documentId}/sheet-preview`,
|
||||
{ token: session.token },
|
||||
)
|
||||
}
|
||||
|
||||
export async function exportDocument(session: ApiSession, projectId: string, documentId: string, format: 'md' | 'pdf' | 'docx') {
|
||||
const response = await fetch(
|
||||
`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/export?format=${format}`,
|
||||
{ headers: { Authorization: `Bearer ${session.token}` } },
|
||||
)
|
||||
if (!response.ok) throw await responseError(response)
|
||||
const blob = await response.blob()
|
||||
const disposition = response.headers.get('content-disposition') ?? ''
|
||||
downloadBlob(blob, dispositionName(disposition) || `document.${format}`)
|
||||
}
|
||||
|
||||
export type UploadControl = {
|
||||
promise: Promise<DocumentDTO>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function uploadDocument(
|
||||
session: ApiSession,
|
||||
projectId: string,
|
||||
file: File,
|
||||
parentId: string,
|
||||
onProgress: (progress: number) => void,
|
||||
): UploadControl {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const promise = new Promise<DocumentDTO>((resolve, reject) => {
|
||||
xhr.open('POST', `${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/uploads`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${session.token}`)
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onerror = () => reject(new ApiError(0, 'network_error', '上传失败,请检查网络后重试'))
|
||||
xhr.onabort = () => reject(new ApiError(0, 'upload_cancelled', '上传已取消'))
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText) as DocumentDTO)
|
||||
} catch {
|
||||
reject(new ApiError(xhr.status, 'invalid_response', '服务器返回了无效的上传结果'))
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } }
|
||||
reject(new ApiError(xhr.status, payload.error?.code ?? 'upload_failed', payload.error?.message ?? '上传失败'))
|
||||
} catch {
|
||||
reject(new ApiError(xhr.status, 'upload_failed', '上传失败'))
|
||||
}
|
||||
}
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
if (parentId) body.append('parentId', parentId)
|
||||
xhr.send(body)
|
||||
})
|
||||
return { promise, cancel: () => xhr.abort() }
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = name
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
async function responseError(response: Response) {
|
||||
try {
|
||||
const payload = await response.json() as { error?: { code?: string; message?: string } }
|
||||
return new ApiError(response.status, payload.error?.code ?? 'request_failed', payload.error?.message ?? '请求失败')
|
||||
} catch {
|
||||
return new ApiError(response.status, 'request_failed', '请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
function dispositionName(value: string) {
|
||||
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|
||||
return encoded ? decodeURIComponent(encoded) : ''
|
||||
}
|
||||
56
apps/web_v1/src/api/inbox.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type InboxItemDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
sourceType: string
|
||||
title: string
|
||||
body: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type InboxSuggestionDTO = {
|
||||
id: string
|
||||
kind: 'task' | 'document'
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type AnalyzeInboxResponseDTO = {
|
||||
suggestions: InboxSuggestionDTO[]
|
||||
}
|
||||
|
||||
export type ConfirmInboxResponseDTO = {
|
||||
createdCount: number
|
||||
}
|
||||
|
||||
export type CaptureInboxInput = {
|
||||
sourceType: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export async function captureProjectInbox(session: ApiSession, projectId: string, input: CaptureInboxInput) {
|
||||
return apiRequest<InboxItemDTO>(`/api/v1/projects/${projectId}/inbox`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function analyzeInboxItem(session: ApiSession, inboxId: string) {
|
||||
return apiRequest<AnalyzeInboxResponseDTO>(`/api/v1/inbox/${inboxId}/analyze`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
})
|
||||
}
|
||||
|
||||
export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestionIds: string[]) {
|
||||
return apiRequest<ConfirmInboxResponseDTO>(`/api/v1/inbox/${inboxId}/confirm`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { suggestionIds },
|
||||
})
|
||||
}
|
||||
219
apps/web_v1/src/api/mappers.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
|
||||
import type {
|
||||
ProjectWorkspaceDTO,
|
||||
WorkspaceAISessionDTO,
|
||||
WorkspaceChannelDTO,
|
||||
WorkspaceCronPlanDTO,
|
||||
WorkspaceInboxDTO,
|
||||
WorkspaceDocumentDTO,
|
||||
WorkspaceTaskDTO,
|
||||
} from './projects'
|
||||
import type { AISession, ChannelKey, CronJob, DocumentSummary, InboxItem, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
|
||||
|
||||
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
|
||||
|
||||
export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWorkspace {
|
||||
const project: Project = {
|
||||
id: payload.project.id,
|
||||
name: payload.project.name,
|
||||
identifier: payload.project.identifier ?? '',
|
||||
icon: payload.project.icon ?? '',
|
||||
background: payload.project.background ?? '',
|
||||
description: payload.project.description,
|
||||
short: projectIconLabel(payload.project.icon ?? '', payload.project.initials),
|
||||
badge: payload.project.unreadCount,
|
||||
urgent: payload.project.unreadCount > 20,
|
||||
color: projectColor(payload.project.background ?? '', index),
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel),
|
||||
tags: payload.tags.map((tag) => tag.name),
|
||||
recentSessions: payload.recentSessions.map(mapAISession),
|
||||
inbox: payload.inbox.map(mapInbox),
|
||||
tasks: payload.tasks.map(mapTask),
|
||||
aiSessions: payload.aiSessions.map(mapAISession),
|
||||
documents: payload.documents.map(mapDocument),
|
||||
cronJobs: payload.cronPlans.map(mapCronPlan),
|
||||
}
|
||||
}
|
||||
|
||||
function projectIconLabel(icon: string, fallback: string) {
|
||||
const trimmed = icon.trim()
|
||||
if (!trimmed) return fallback
|
||||
const chars = Array.from(trimmed)
|
||||
if (chars.length <= 2) return trimmed
|
||||
return chars.slice(0, 2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function projectColor(background: string, index: number) {
|
||||
const trimmed = background.trim()
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) {
|
||||
return trimmed
|
||||
}
|
||||
return projectColors[index % projectColors.length]
|
||||
}
|
||||
|
||||
function mapChannel(channel: WorkspaceChannelDTO): ProjectChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
key: mapChannelKey(channel.type, channel.id),
|
||||
label: channelLabel(channel),
|
||||
count: channel.count,
|
||||
icon: channelIcon(channel),
|
||||
url: channel.url,
|
||||
sortOrder: channel.sortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
function mapChannelKey(type: string, id = ''): ChannelKey {
|
||||
switch (type) {
|
||||
case 'inbox':
|
||||
return 'inbox'
|
||||
case 'tasks':
|
||||
return 'tasks'
|
||||
case 'ai_sessions':
|
||||
return 'ai'
|
||||
case 'documents':
|
||||
return 'documents'
|
||||
case 'cron':
|
||||
return 'cron'
|
||||
case 'overview':
|
||||
return 'overview'
|
||||
default:
|
||||
return `custom:${id || type}`
|
||||
}
|
||||
}
|
||||
|
||||
function channelLabel(channel: WorkspaceChannelDTO) {
|
||||
switch (channel.type) {
|
||||
case 'overview':
|
||||
return '概况'
|
||||
case 'inbox':
|
||||
return 'Inbox 消息流'
|
||||
case 'tasks':
|
||||
return '工作计划'
|
||||
case 'ai_sessions':
|
||||
return 'AI 会话'
|
||||
case 'documents':
|
||||
return '笔记资料'
|
||||
case 'cron':
|
||||
return '计划任务'
|
||||
default:
|
||||
return channel.title
|
||||
}
|
||||
}
|
||||
|
||||
function channelIcon(channel: WorkspaceChannelDTO) {
|
||||
switch (channel.type) {
|
||||
case 'overview':
|
||||
return <IconHome />
|
||||
case 'inbox':
|
||||
return <IconEmail />
|
||||
case 'tasks':
|
||||
return <IconList />
|
||||
case 'ai_sessions':
|
||||
return <IconRobot />
|
||||
case 'documents':
|
||||
return <IconFile />
|
||||
case 'cron':
|
||||
return <IconClockCircle />
|
||||
default:
|
||||
return channel.icon === 'user' ? <IconUserGroup /> : <IconLink />
|
||||
}
|
||||
}
|
||||
|
||||
function mapInbox(item: WorkspaceInboxDTO): InboxItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
meta: `来源:${item.source}`,
|
||||
owner: '系统',
|
||||
time: formatBackendDate(item.time),
|
||||
tag: item.tag || item.status,
|
||||
summary: item.summary,
|
||||
status: item.status,
|
||||
}
|
||||
}
|
||||
|
||||
function mapTask(task: WorkspaceTaskDTO): TaskItem {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
owner: task.owner,
|
||||
progress: task.completed ? '100%' : '',
|
||||
due: task.due ? formatBackendDate(task.due) : '未设置',
|
||||
createdAt: formatBackendDate(task.createdAt),
|
||||
completedAt: task.completedAt ? formatBackendDate(task.completedAt) : '',
|
||||
duration: '',
|
||||
tag: task.tag || '',
|
||||
summary: task.summary,
|
||||
completed: task.completed,
|
||||
}
|
||||
}
|
||||
|
||||
function formatBackendDate(value: string) {
|
||||
const date = parseBackendDate(value)
|
||||
if (!date) return '未知时间'
|
||||
|
||||
const now = new Date()
|
||||
const today = startOfDay(now)
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
const dateDay = startOfDay(date)
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
|
||||
if (dateDay.getTime() === today.getTime()) return `今天 ${time}`
|
||||
if (dateDay.getTime() === yesterday.getTime()) return `昨天 ${time}`
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
|
||||
}
|
||||
|
||||
function parseBackendDate(value: string) {
|
||||
if (!value) return null
|
||||
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`
|
||||
const date = new Date(normalized)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function startOfDay(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function mapAISession(session: WorkspaceAISessionDTO): AISession {
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
summary: session.summary,
|
||||
owner: 'AI',
|
||||
time: formatBackendDate(session.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function mapDocument(document: WorkspaceDocumentDTO): DocumentSummary {
|
||||
return {
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
kind: document.kind,
|
||||
extension: document.extension,
|
||||
mimeType: document.mimeType,
|
||||
updated: formatBackendDate(document.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function mapCronPlan(plan: WorkspaceCronPlanDTO): CronJob {
|
||||
return {
|
||||
id: plan.id,
|
||||
name: plan.title,
|
||||
expr: plan.schedule,
|
||||
status: plan.enabled ? '运行中' : '暂停',
|
||||
lastRun: plan.lastResult,
|
||||
nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '暂停中',
|
||||
owner: plan.owner,
|
||||
enabled: plan.enabled,
|
||||
}
|
||||
}
|
||||
244
apps/web_v1/src/api/projects.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type ProjectDTO = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkspaceProjectDTO = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
initials: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export type WorkspaceChannelDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
type: string
|
||||
title: string
|
||||
icon: string
|
||||
count: number
|
||||
url: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type WorkspaceTagDTO = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type WorkspaceInboxDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
source: string
|
||||
title: string
|
||||
summary: string
|
||||
status: string
|
||||
tag: string
|
||||
time: string
|
||||
}
|
||||
|
||||
export type WorkspaceTaskDTO = {
|
||||
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 WorkspaceAISessionDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
summary: string
|
||||
updatedAt: string
|
||||
references: string[]
|
||||
}
|
||||
|
||||
export type WorkspaceDocumentDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
kind: string
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkspaceCronPlanDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
schedule: string
|
||||
nextRun: string | null
|
||||
enabled: boolean
|
||||
lastResult: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export type ProjectWorkspaceDTO = {
|
||||
project: WorkspaceProjectDTO
|
||||
channels: WorkspaceChannelDTO[]
|
||||
tags: WorkspaceTagDTO[]
|
||||
recentSessions: WorkspaceAISessionDTO[]
|
||||
inbox: WorkspaceInboxDTO[]
|
||||
tasks: WorkspaceTaskDTO[]
|
||||
aiSessions: WorkspaceAISessionDTO[]
|
||||
documents: WorkspaceDocumentDTO[]
|
||||
cronPlans: WorkspaceCronPlanDTO[]
|
||||
}
|
||||
|
||||
export type TaskDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
completed: boolean
|
||||
dueAt: string | null
|
||||
assigneeId: string | null
|
||||
tagId: string | null
|
||||
tag: string
|
||||
sourceInboxItemId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CronPlanDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
schedule: string
|
||||
nextRunAt: string | null
|
||||
enabled: boolean
|
||||
lastResult: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateProjectInput = {
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type UpdateProjectInput = Partial<CreateProjectInput>
|
||||
|
||||
export type CreateProjectChannelInput = {
|
||||
title: string
|
||||
icon?: string
|
||||
url: 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 CreateCronPlanInput = {
|
||||
title: string
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
nextRunAt?: string
|
||||
}
|
||||
|
||||
export type CreateProjectTagInput = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function fetchProjects(session: ApiSession) {
|
||||
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchProjectWorkspace(session: ApiSession, projectId: string) {
|
||||
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchProjectTags(session: ApiSession, projectId: string) {
|
||||
return apiRequest<WorkspaceTagDTO[]>(`/api/v1/projects/${projectId}/tags`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
||||
return apiRequest<ProjectDTO>('/api/v1/projects', {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProject(session: ApiSession, projectId: string, input: UpdateProjectInput) {
|
||||
return apiRequest<ProjectDTO>(`/api/v1/projects/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createProjectChannel(session: ApiSession, projectId: string, input: CreateProjectChannelInput) {
|
||||
return apiRequest<WorkspaceChannelDTO>(`/api/v1/projects/${projectId}/channels`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
|
||||
return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) {
|
||||
return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
|
||||
return apiRequest<CronPlanDTO>(`/api/v1/projects/${projectId}/cron-plans`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createProjectTag(session: ApiSession, projectId: string, input: CreateProjectTagInput) {
|
||||
return apiRequest<WorkspaceTagDTO>(`/api/v1/projects/${projectId}/tags`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
20
apps/web_v1/src/api/search.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type SearchResultDTO = {
|
||||
type: 'project' | 'task' | 'document'
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
snippet: string
|
||||
}
|
||||
|
||||
export type SearchResponseDTO = {
|
||||
items: SearchResultDTO[]
|
||||
}
|
||||
|
||||
const searchPath = '/api/v1/search'
|
||||
|
||||
export async function searchWorkspace(session: ApiSession, query: string, signal?: AbortSignal) {
|
||||
const search = new URLSearchParams({ q: query })
|
||||
return apiRequest<SearchResponseDTO>(`${searchPath}?${search.toString()}`, { token: session.token, signal })
|
||||
}
|
||||
518
apps/web_v1/src/app/App.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import '../App.css'
|
||||
import { ApiError, login, setApiBaseUrl, updateCurrentUser, type ApiSession, type CurrentUser } from '../api/client'
|
||||
import { createAISession, listAIExperts, listAISessions, type CreateAISessionInput } from '../api/ai'
|
||||
import {
|
||||
createDatasetSource,
|
||||
deleteDatasetSource,
|
||||
depositDatasetItem,
|
||||
listDatasetItems,
|
||||
listDatasetSources,
|
||||
syncDatasetSources,
|
||||
updateDatasetItem,
|
||||
updateDatasetSource,
|
||||
type DatasetItemUpdate,
|
||||
type DatasetSourceInput,
|
||||
} from '../api/dataset'
|
||||
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
|
||||
import { mapWorkspace } from '../api/mappers'
|
||||
import {
|
||||
createCronPlan,
|
||||
|
||||
createProjectChannel,
|
||||
createProject,
|
||||
createProjectTag,
|
||||
createTask,
|
||||
fetchProjectWorkspace,
|
||||
fetchProjects,
|
||||
updateProject,
|
||||
updateTask,
|
||||
} from '../api/projects'
|
||||
import type { DocumentOpenIntent } from '../api/documents'
|
||||
import type { SearchResultDTO } from '../api/search'
|
||||
import { LoginPage } from '../pages/login'
|
||||
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||
import { SearchResultPreview } from '../pages/projects/search-result-preview'
|
||||
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
||||
import { ProjectPage } from '../pages/workspace-home'
|
||||
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
import { useWorkbenchSearch } from './use-workbench-search'
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState<Screen>('login')
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
|
||||
const [session, setSession] = useState<ApiSession | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
|
||||
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||
const activeProjectIDRef = useRef('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||
const [, setSelectedItem] = useState('探索采集')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||
const [documentIntent, setDocumentIntent] = useState<DocumentOpenIntent | null>(null)
|
||||
const workspaceSearch = useWorkbenchSearch(session)
|
||||
|
||||
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listAISessions(session, projectId, signal)
|
||||
}, [session])
|
||||
|
||||
const handleListAIExperts = useCallback((signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listAIExperts(session, signal)
|
||||
}, [session])
|
||||
|
||||
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return createAISession(session, projectId, input, signal)
|
||||
}, [session])
|
||||
|
||||
const handleListDatasetSources = useCallback((signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listDatasetSources(session, signal)
|
||||
}, [session])
|
||||
|
||||
const handleListDatasetItems = useCallback((sourceId?: string, offset = 0, limit = 50, signal?: AbortSignal) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listDatasetItems(session, sourceId, offset, limit, signal)
|
||||
}, [session])
|
||||
|
||||
const handleCreateDatasetSource = useCallback((input: DatasetSourceInput) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return createDatasetSource(session, input)
|
||||
}, [session])
|
||||
|
||||
const handleUpdateDatasetSource = useCallback((sourceId: string, input: DatasetSourceInput) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return updateDatasetSource(session, sourceId, input)
|
||||
}, [session])
|
||||
|
||||
const handleDeleteDatasetSource = useCallback((sourceId: string) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return deleteDatasetSource(session, sourceId)
|
||||
}, [session])
|
||||
|
||||
const handleUpdateDatasetItem = useCallback((itemId: string, input: DatasetItemUpdate) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return updateDatasetItem(session, itemId, input)
|
||||
}, [session])
|
||||
|
||||
const handleSyncDatasetSources = useCallback(() => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return syncDatasetSources(session)
|
||||
}, [session])
|
||||
|
||||
const handleDepositDatasetItem = useCallback(async (itemId: string, projectId: string) => {
|
||||
if (!session) throw new Error('未登录')
|
||||
const result = await depositDatasetItem(session, itemId, projectId)
|
||||
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectId)
|
||||
if (workspaceIndex >= 0) {
|
||||
try {
|
||||
const payload = await fetchProjectWorkspace(session, projectId)
|
||||
const refreshed = mapWorkspace(payload, workspaceIndex)
|
||||
setWorkspaces((current) => current.map((workspace) => workspace.project.id === projectId ? refreshed : workspace))
|
||||
} catch {
|
||||
return { ...result, refreshFailed: true }
|
||||
}
|
||||
}
|
||||
setDocumentIntent({
|
||||
intentId: crypto.randomUUID(),
|
||||
mode: 'open',
|
||||
projectId,
|
||||
documentId: result.documentId,
|
||||
sourceType: 'dataset',
|
||||
sourceId: itemId,
|
||||
})
|
||||
selectActiveProject(projectId)
|
||||
setActiveView('project')
|
||||
setActiveChannel('documents')
|
||||
return result
|
||||
}, [session, workspaces])
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID)
|
||||
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
||||
|
||||
function selectActiveProject(projectID: string) {
|
||||
activeProjectIDRef.current = projectID
|
||||
setActiveProjectID(projectID)
|
||||
}
|
||||
|
||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
||||
const projectIDWhenStarted = activeProjectIDRef.current
|
||||
const backendProjects = await fetchProjects(nextSession)
|
||||
const backendWorkspaces = await Promise.all(
|
||||
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
|
||||
)
|
||||
setWorkspaces(backendWorkspaces)
|
||||
const latestProjectID = activeProjectIDRef.current
|
||||
const requestedProjectID = latestProjectID !== projectIDWhenStarted
|
||||
? latestProjectID
|
||||
: preferredProjectID ?? latestProjectID
|
||||
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? ''
|
||||
selectActiveProject(nextProjectID)
|
||||
return backendWorkspaces
|
||||
}
|
||||
|
||||
async function handleLogin(input: { server: string; email: string; password: string }) {
|
||||
setLoading(true)
|
||||
try {
|
||||
setApiBaseUrl(input.server)
|
||||
const loginResult = await login(input.email, input.password)
|
||||
const { user, ...nextSession } = loginResult
|
||||
await loadWorkspaces(nextSession, '')
|
||||
setSession(nextSession)
|
||||
setCurrentUser(user)
|
||||
selectActiveProject('')
|
||||
setActiveChannel('overview')
|
||||
setActiveView('workspace')
|
||||
setScreen('workbench')
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateProfile(input: { displayName: string; currentPassword?: string; newPassword?: string }) {
|
||||
const updated = await updateCurrentUser(requireSession(), input)
|
||||
setCurrentUser(updated)
|
||||
Message.success('个人资料已更新')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
setSession(null)
|
||||
setCurrentUser(null)
|
||||
setWorkspaces([])
|
||||
selectActiveProject('')
|
||||
setActiveView('workspace')
|
||||
setActiveChannel('overview')
|
||||
setActiveTaskID(null)
|
||||
setScreen('login')
|
||||
}
|
||||
|
||||
async function refreshAfterAction(nextProjectID?: string) {
|
||||
if (!session) return
|
||||
await loadWorkspaces(session, nextProjectID)
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void>, success: string) {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await action()
|
||||
setActiveModal(null)
|
||||
Message.success(success)
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function requireSession() {
|
||||
if (!session) throw new Error('未登录')
|
||||
return session
|
||||
}
|
||||
|
||||
function requireActiveProject() {
|
||||
if (!activeWorkspace?.project.id) throw new Error('未选择项目')
|
||||
return activeWorkspace.project.id
|
||||
}
|
||||
|
||||
function normalizeOptionalTime(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? undefined : trimmed
|
||||
}
|
||||
|
||||
function handleCreateProject(draft: ProjectDraft) {
|
||||
if (!draft.name.trim()) {
|
||||
Message.warning('请输入项目名称')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
const created = await createProject(requireSession(), {
|
||||
name: draft.name.trim(),
|
||||
identifier: draft.identifier.trim(),
|
||||
icon: draft.icon.trim(),
|
||||
background: draft.background.trim(),
|
||||
description: draft.description.trim(),
|
||||
})
|
||||
const nextProjectID = created.id
|
||||
await refreshAfterAction(nextProjectID)
|
||||
if (nextProjectID) {
|
||||
selectActiveProject(nextProjectID)
|
||||
setActiveView('project')
|
||||
setActiveChannel('overview')
|
||||
}
|
||||
}, '项目已创建')
|
||||
}
|
||||
|
||||
function handleCreateTask(draft: TaskDraft) {
|
||||
if (!draft.title.trim()) {
|
||||
Message.warning('请输入任务标题')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
await createTask(requireSession(), requireActiveProject(), {
|
||||
title: draft.title.trim(),
|
||||
description: draft.description.trim(),
|
||||
status: 'open',
|
||||
tag: draft.tag.trim(),
|
||||
})
|
||||
await refreshAfterAction()
|
||||
setActiveChannel('tasks')
|
||||
}, '任务已创建')
|
||||
}
|
||||
|
||||
function handleCreateCronPlan(draft: CronDraft) {
|
||||
if (!draft.title.trim()) {
|
||||
Message.warning('请输入计划名称')
|
||||
return
|
||||
}
|
||||
if (!draft.schedule.trim()) {
|
||||
Message.warning('请输入 Cron 表达式')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
await createCronPlan(requireSession(), requireActiveProject(), {
|
||||
title: draft.title.trim(),
|
||||
schedule: draft.schedule.trim(),
|
||||
enabled: draft.enabled,
|
||||
nextRunAt: normalizeOptionalTime(draft.nextRunAt),
|
||||
})
|
||||
await refreshAfterAction()
|
||||
setActiveChannel('cron')
|
||||
}, '计划任务已创建')
|
||||
}
|
||||
|
||||
function handleCreateProjectTag(name: string) {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
Message.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
|
||||
await refreshAfterAction()
|
||||
}, '标签已创建')
|
||||
}
|
||||
|
||||
async function handleCreateProjectChannel(input: { title: string; icon: string; url: string }) {
|
||||
const projectID = requireActiveProject()
|
||||
await createProjectChannel(requireSession(), projectID, input)
|
||||
await refreshAfterAction(projectID)
|
||||
setActiveChannel('overview')
|
||||
}
|
||||
|
||||
function openTask(project: Project, taskID: string) {
|
||||
selectActiveProject(project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel('tasks')
|
||||
setActiveTaskID(taskID)
|
||||
}
|
||||
|
||||
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
|
||||
void runAction(async () => {
|
||||
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
|
||||
title: update.title,
|
||||
description: update.summary,
|
||||
completed: update.completed,
|
||||
nextProjectId: update.nextProjectId,
|
||||
tag: update.tag,
|
||||
})
|
||||
await refreshAfterAction(update.nextProjectId)
|
||||
}, '任务已更新')
|
||||
}
|
||||
|
||||
async function handleUpdateProject(update: ProjectSettingsUpdate) {
|
||||
const currentSession = requireSession()
|
||||
await updateProject(currentSession, update.projectId, {
|
||||
name: update.name,
|
||||
identifier: update.identifier,
|
||||
icon: update.icon,
|
||||
background: update.background,
|
||||
description: update.description,
|
||||
})
|
||||
await loadWorkspaces(currentSession, update.projectId)
|
||||
Message.success('项目设置已更新')
|
||||
}
|
||||
|
||||
async function handleAnalyzeInbox(inboxId: string) {
|
||||
const response = await analyzeInboxItem(requireSession(), inboxId)
|
||||
return response.suggestions
|
||||
}
|
||||
|
||||
async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) {
|
||||
const confirmationProjectID = activeProjectIDRef.current
|
||||
let response
|
||||
try {
|
||||
response = await confirmInboxItem(requireSession(), inboxId, suggestionIds)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) throw error
|
||||
if (activeProjectIDRef.current !== confirmationProjectID) {
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新核对当前工作区')
|
||||
}
|
||||
try {
|
||||
await refreshAfterAction()
|
||||
} catch {
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对')
|
||||
}
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,工作区已刷新,请重新核对')
|
||||
}
|
||||
if (activeProjectIDRef.current !== confirmationProjectID) {
|
||||
return { createdCount: response.createdCount }
|
||||
}
|
||||
try {
|
||||
await refreshAfterAction()
|
||||
return { createdCount: response.createdCount }
|
||||
} catch {
|
||||
return {
|
||||
createdCount: response.createdCount,
|
||||
refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectSearchResult(result: SearchResultDTO) {
|
||||
const target = searchResultTarget(result.type)
|
||||
if (!target) {
|
||||
Message.warning('该搜索结果暂不支持导航')
|
||||
return
|
||||
}
|
||||
const owningWorkspace = workspaces.find((workspace) => workspace.project.id === result.projectId)
|
||||
if (!owningWorkspace) {
|
||||
if (isAuthorizedExternalPreview(result)) {
|
||||
setSearchResultPreview(result)
|
||||
} else {
|
||||
Message.warning('该搜索结果所在项目当前不可访问')
|
||||
}
|
||||
return
|
||||
}
|
||||
selectActiveProject(owningWorkspace.project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel(target.channel)
|
||||
setActiveTaskID(target.openTask ? result.id : null)
|
||||
if (result.type === 'document') {
|
||||
setDocumentIntent({
|
||||
intentId: crypto.randomUUID(),
|
||||
mode: 'open',
|
||||
projectId: result.projectId,
|
||||
documentId: result.id,
|
||||
sourceType: 'search',
|
||||
sourceId: result.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||
{screen === 'login' ? (
|
||||
<Spin loading={loading} style={{ width: '100%' }}>
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
</Spin>
|
||||
) : session && currentUser ? (
|
||||
<ProjectPage
|
||||
activeView={activeView}
|
||||
activeWorkspace={activeWorkspace}
|
||||
session={session}
|
||||
documentIntent={documentIntent}
|
||||
onDocumentIntentConsumed={(intentId) => {
|
||||
setDocumentIntent((current) => current?.intentId === intentId ? null : current)
|
||||
}}
|
||||
workspaces={workspaces}
|
||||
activeChannel={activeChannel}
|
||||
activeTaskID={activeTaskID}
|
||||
theme={theme}
|
||||
onSelectWorkspace={() => setActiveView('workspace')}
|
||||
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
||||
onSelectProject={(project) => {
|
||||
selectActiveProject(project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel('overview')
|
||||
setActiveTaskID(null)
|
||||
}}
|
||||
onSelectChannel={(channel) => {
|
||||
setActiveChannel(channel)
|
||||
setActiveTaskID(null)
|
||||
}}
|
||||
onSelectItem={setSelectedItem}
|
||||
onOpenTask={openTask}
|
||||
onCloseTask={() => setActiveTaskID(null)}
|
||||
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
|
||||
onCreateProject={() => setActiveModal('project')}
|
||||
onCreateTask={() => setActiveModal('task')}
|
||||
onCreateCronPlan={() => setActiveModal('cron')}
|
||||
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
||||
onUpdateProject={handleUpdateProject}
|
||||
onCreateProjectTag={handleCreateProjectTag}
|
||||
currentUser={currentUser}
|
||||
onUpdateProfile={handleUpdateProfile}
|
||||
onLogout={handleLogout}
|
||||
onCreateProjectChannel={handleCreateProjectChannel}
|
||||
searchQuery={workspaceSearch.query}
|
||||
searchLoading={workspaceSearch.loading}
|
||||
searchSearched={workspaceSearch.searched}
|
||||
searchResults={workspaceSearch.results}
|
||||
onSearchQueryChange={workspaceSearch.onQueryChange}
|
||||
onSearch={() => void workspaceSearch.onSearch()}
|
||||
onSelectSearchResult={handleSelectSearchResult}
|
||||
onAnalyzeInbox={handleAnalyzeInbox}
|
||||
onConfirmInbox={handleConfirmInbox}
|
||||
onListAISessions={handleListAISessions}
|
||||
onListAIExperts={handleListAIExperts}
|
||||
onCreateAISession={handleCreateAISession}
|
||||
onListDatasetSources={handleListDatasetSources}
|
||||
onListDatasetItems={handleListDatasetItems}
|
||||
onCreateDatasetSource={handleCreateDatasetSource}
|
||||
onUpdateDatasetSource={handleUpdateDatasetSource}
|
||||
onDeleteDatasetSource={handleDeleteDatasetSource}
|
||||
onUpdateDatasetItem={handleUpdateDatasetItem}
|
||||
onSyncDatasetSources={handleSyncDatasetSources}
|
||||
onDepositDatasetItem={handleDepositDatasetItem}
|
||||
/>
|
||||
) : (
|
||||
<Spin loading />
|
||||
)}
|
||||
<ProjectActionModals
|
||||
activeModal={activeModal}
|
||||
loading={actionLoading}
|
||||
onClose={() => setActiveModal(null)}
|
||||
onCreateProject={handleCreateProject}
|
||||
onCreateTask={handleCreateTask}
|
||||
onCreateCronPlan={handleCreateCronPlan}
|
||||
tagOptions={activeTagOptions}
|
||||
/>
|
||||
<SearchResultPreview result={searchResultPreview} onClose={() => setSearchResultPreview(null)} />
|
||||
</main>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function searchResultTarget(type: string): { channel: ChannelKey; openTask: boolean } | null {
|
||||
if (type === 'project') return { channel: 'overview', openTask: false }
|
||||
if (type === 'task') return { channel: 'tasks', openTask: true }
|
||||
if (type === 'document') return { channel: 'documents', openTask: false }
|
||||
return null
|
||||
}
|
||||
|
||||
function isAuthorizedExternalPreview(result: SearchResultDTO) {
|
||||
return (
|
||||
(result.type === 'task' || result.type === 'document')
|
||||
&& typeof result.projectId === 'string'
|
||||
&& result.projectId.trim() !== ''
|
||||
&& typeof result.title === 'string'
|
||||
&& result.title.trim() !== ''
|
||||
&& typeof result.snippet === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
29
apps/web_v1/src/app/search-request-gate.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type SearchRequestTicket = {
|
||||
signal: AbortSignal
|
||||
isCurrent: () => boolean
|
||||
}
|
||||
|
||||
export function createSearchRequestGate() {
|
||||
let generation = 0
|
||||
let controller: AbortController | null = null
|
||||
|
||||
return {
|
||||
begin(): SearchRequestTicket {
|
||||
generation += 1
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const requestGeneration = generation
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => requestGeneration === generation,
|
||||
}
|
||||
},
|
||||
invalidate() {
|
||||
generation += 1
|
||||
controller?.abort()
|
||||
controller = null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type SearchRequestGate = ReturnType<typeof createSearchRequestGate>
|
||||
72
apps/web_v1/src/app/use-workbench-search.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Message } from '@arco-design/web-react'
|
||||
import type { ApiSession } from '../api/client'
|
||||
import { searchWorkspace, type SearchResultDTO } from '../api/search'
|
||||
import { createSearchRequestGate, type SearchRequestGate } from './search-request-gate'
|
||||
|
||||
export function useWorkbenchSearch(session: ApiSession | null) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResultDTO[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const requestGate = useRef<SearchRequestGate | null>(null)
|
||||
if (!requestGate.current) requestGate.current = createSearchRequestGate()
|
||||
|
||||
useEffect(() => {
|
||||
const gate = requestGate.current
|
||||
gate?.invalidate()
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setSearched(false)
|
||||
return () => gate?.invalidate()
|
||||
}, [session])
|
||||
|
||||
function changeQuery(value: string) {
|
||||
requestGate.current?.invalidate()
|
||||
setQuery(value)
|
||||
setLoading(false)
|
||||
setSearched(false)
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
requestGate.current?.invalidate()
|
||||
setResults([])
|
||||
setSearched(false)
|
||||
Message.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
if (!session) {
|
||||
requestGate.current?.invalidate()
|
||||
Message.error('未登录或登录已失效')
|
||||
return
|
||||
}
|
||||
|
||||
const request = requestGate.current!.begin()
|
||||
setLoading(true)
|
||||
setSearched(false)
|
||||
try {
|
||||
const response = await searchWorkspace(session, trimmedQuery, request.signal)
|
||||
if (!request.isCurrent()) return
|
||||
setResults(response.items)
|
||||
setSearched(true)
|
||||
} catch (error) {
|
||||
if (!request.isCurrent()) return
|
||||
setResults([])
|
||||
Message.error(error instanceof Error ? error.message : '搜索失败,请稍后重试')
|
||||
} finally {
|
||||
if (request.isCurrent()) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
loading,
|
||||
searched,
|
||||
onQueryChange: changeQuery,
|
||||
onSearch: submitSearch,
|
||||
}
|
||||
}
|
||||
30
apps/web_v1/src/index.css
Normal file
@@ -0,0 +1,30 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
10
apps/web_v1/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './app/App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
143
apps/web_v1/src/pages/login.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconBook,
|
||||
IconCheckCircleFill,
|
||||
IconCloseCircleFill,
|
||||
IconLaunch,
|
||||
IconLoading,
|
||||
IconSafe,
|
||||
IconUserGroup,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { normalizeBaseUrl } from '../api/client'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
|
||||
const [server, setServer] = useState('agent.senlin.ai')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => {
|
||||
void checkServerStatus(server, controller.signal, setStatus)
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [server])
|
||||
|
||||
return (
|
||||
<section className="login-screen">
|
||||
<Card className="login-card" bordered>
|
||||
<div className="login-brand-panel">
|
||||
<div className="brand-lockup large">
|
||||
<img className="brand-icon" src="/senlinai-icon.svg" alt="" aria-hidden="true" />
|
||||
<Title heading={2}>森林AI</Title>
|
||||
</div>
|
||||
<Text className="login-slogan">私有部署 · 安全可控的智能协作平台</Text>
|
||||
<Text type="secondary">知识沉淀 · 团队协作 · AI Agent</Text>
|
||||
<Space className="login-footer-links" size={24}>
|
||||
<Text type="secondary">v1.0 完整版</Text>
|
||||
<Text type="secondary">隐私政策</Text>
|
||||
<Text type="secondary">服务协议</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="login-form-panel">
|
||||
<Title heading={4}>登录到您的森林AI</Title>
|
||||
<Text type="secondary">连接私有工作台</Text>
|
||||
<Form layout="vertical" className="login-form">
|
||||
<Form.Item label="服务器地址(IP 或域名优先)">
|
||||
<Input value={server} onChange={setServer} placeholder="例如:https://10.0.0.15:8080" suffix={<IconLaunch />} />
|
||||
<ConnectionCard status={status} onCheck={() => checkServerStatus(server, undefined, setStatus)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="账号">
|
||||
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码">
|
||||
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<div className="login-row">
|
||||
<Space size={8}>
|
||||
<span className="check-dot" />
|
||||
<Text>记住服务器地址</Text>
|
||||
</Space>
|
||||
<Button type="text" size="mini">无法连接?</Button>
|
||||
</div>
|
||||
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="login-value-panel">
|
||||
<Title heading={3}>安全 · 专注 · 高效</Title>
|
||||
<Paragraph>
|
||||
森林AI 是面向知识工作者与内部团队的私有部署工作台,帮助团队在统一空间中收集信息、整理知识、协同决策。
|
||||
</Paragraph>
|
||||
<ValuePoint icon={<IconSafe />} title="私有部署,数据自有" desc="所有数据与模型运行在您的环境中,安全可控。" />
|
||||
<ValuePoint icon={<IconBook />} title="知识沉淀,结构清晰" desc="从收集到整理,形成可复用的团队知识库。" />
|
||||
<ValuePoint icon={<IconUserGroup />} title="协同高效,聚焦成果" desc="围绕项目与任务协同,减少沟通成本,专注交付。" />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionCard({ status, onCheck }: { status: ConnectionStatus; onCheck: () => void }) {
|
||||
const icon = status === 'checking' ? <IconLoading /> : status === 'online' ? <IconCheckCircleFill /> : <IconCloseCircleFill />
|
||||
const title = status === 'checking' ? '正在检查' : status === 'online' ? '连接正常' : '连接异常'
|
||||
|
||||
return (
|
||||
<div className={`connection-card ${status}`}>
|
||||
{icon}
|
||||
<div>
|
||||
<Text className="connection-title">{title}</Text>
|
||||
</div>
|
||||
<Button type="text" size="mini" loading={status === 'checking'} onClick={onCheck}>
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function checkServerStatus(
|
||||
server: string,
|
||||
signal: AbortSignal | undefined,
|
||||
setStatus: (status: ConnectionStatus) => void,
|
||||
) {
|
||||
const baseUrl = normalizeBaseUrl(server)
|
||||
setStatus('checking')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/status`, { signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const payload = await response.json() as { timestamp?: string }
|
||||
if (typeof payload.timestamp !== 'string') throw new Error('invalid status payload')
|
||||
setStatus('online')
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
setStatus('offline')
|
||||
}
|
||||
}
|
||||
|
||||
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="value-point">
|
||||
<span>{icon}</span>
|
||||
<div>
|
||||
<Text className="value-title">{title}</Text>
|
||||
<Text type="secondary">{desc}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
apps/web_v1/src/pages/projects/document-markdown-editor.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
export function DocumentMarkdownEditor({
|
||||
value,
|
||||
dark,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string
|
||||
dark: boolean
|
||||
onChange: (value: string) => void
|
||||
onSave: () => void
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onSaveRef = useRef(onSave)
|
||||
onChangeRef.current = onChange
|
||||
onSaveRef.current = onSave
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
markdown(),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
onSaveRef.current()
|
||||
return true
|
||||
},
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '14px' },
|
||||
'.cm-scroller': { fontFamily: 'JetBrains Mono, Cascadia Code, Consolas, monospace', lineHeight: '1.7' },
|
||||
'.cm-content': { padding: '22px 26px 40px' },
|
||||
'.cm-gutters': { borderRight: '1px solid var(--senlin-border)' },
|
||||
}),
|
||||
...(dark ? [oneDark] : []),
|
||||
],
|
||||
}),
|
||||
})
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// Recreate only when theme changes; external value updates are handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dark])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || view.state.doc.toString() === value) return
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
|
||||
}, [value])
|
||||
|
||||
return <div className="document-codemirror" ref={hostRef} />
|
||||
}
|
||||
155
apps/web_v1/src/pages/projects/project-action-modals.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
|
||||
const { Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export type ProjectActionModal = 'project' | 'task' | 'cron' | null
|
||||
|
||||
export type ProjectDraft = {
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type TaskDraft = {
|
||||
title: string
|
||||
description: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type CronDraft = {
|
||||
title: string
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
nextRunAt: string
|
||||
}
|
||||
|
||||
export function ProjectActionModals({
|
||||
activeModal,
|
||||
loading,
|
||||
onClose,
|
||||
onCreateProject,
|
||||
onCreateTask,
|
||||
onCreateCronPlan,
|
||||
tagOptions,
|
||||
}: {
|
||||
activeModal: ProjectActionModal
|
||||
loading: boolean
|
||||
onClose: () => void
|
||||
onCreateProject: (draft: ProjectDraft) => void
|
||||
onCreateTask: (draft: TaskDraft) => void
|
||||
onCreateCronPlan: (draft: CronDraft) => void
|
||||
tagOptions: string[]
|
||||
}) {
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
|
||||
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (activeModal === null) {
|
||||
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
setTask({ title: '', description: '', tag: '' })
|
||||
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
}
|
||||
}, [activeModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="新建项目"
|
||||
visible={activeModal === 'project'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onCreateProject(project)}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>项目名称</Text>
|
||||
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目标识</Text>
|
||||
<Input placeholder="例如:A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目图标</Text>
|
||||
<Input placeholder="例如:A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目背景</Text>
|
||||
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目简介</Text>
|
||||
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="新建任务"
|
||||
visible={activeModal === 'task'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onCreateTask(task)}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>任务标题</Text>
|
||||
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>任务说明</Text>
|
||||
<TextArea rows={4} placeholder="补充背景、验收口径或下一步" value={task.description} onChange={(description) => setTask((draft) => ({ ...draft, description }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>所属标签</Text>
|
||||
<Select
|
||||
allowCreate
|
||||
placeholder="选择或输入标签"
|
||||
value={task.tag}
|
||||
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
|
||||
>
|
||||
{tagOptions.map((tag) => (
|
||||
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="新建计划任务"
|
||||
visible={activeModal === 'cron'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onCreateCronPlan(cron)}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>计划名称</Text>
|
||||
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>Cron 表达式</Text>
|
||||
<Input value={cron.schedule} onChange={(schedule) => setCron((draft) => ({ ...draft, schedule }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>下次运行时间</Text>
|
||||
<Input placeholder="2026-07-22T08:00:00Z,可留空" value={cron.nextRunAt} onChange={(nextRunAt) => setCron((draft) => ({ ...draft, nextRunAt }))} />
|
||||
</label>
|
||||
<label className="action-switch">
|
||||
<Text>启用计划</Text>
|
||||
<Switch checked={cron.enabled} onChange={(enabled) => setCron((draft) => ({ ...draft, enabled }))} />
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
368
apps/web_v1/src/pages/projects/project-ai.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Button, Card, Empty, Input, Spin, Typography } from '@arco-design/web-react'
|
||||
import { IconArrowUp, IconAttachment, IconPlus, IconRobot, IconSearch } from '@arco-design/web-react/icon'
|
||||
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export function ProjectAi({
|
||||
activeWorkspace,
|
||||
onSelectItem,
|
||||
onListSessions,
|
||||
onListExperts,
|
||||
onCreateSession,
|
||||
}: {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onSelectItem: (title: string) => void
|
||||
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||
onListExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
|
||||
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<AISessionDTO[]>([])
|
||||
const [experts, setExperts] = useState<AIExpertDTO[]>([])
|
||||
const [selectedSessionID, setSelectedSessionID] = useState<string | null>(null)
|
||||
const [selectedExpertID, setSelectedExpertID] = useState<string | null>(null)
|
||||
const [expertQuery, setExpertQuery] = useState('')
|
||||
const [expertCategory, setExpertCategory] = useState('all')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingExperts, setLoadingExperts] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const projectId = activeWorkspace.project.id
|
||||
const projectRef = useRef(projectId)
|
||||
const generationRef = useRef(0)
|
||||
const listControllerRef = useRef<AbortController | null>(null)
|
||||
const createControllerRef = useRef<AbortController | null>(null)
|
||||
projectRef.current = projectId
|
||||
|
||||
useEffect(() => {
|
||||
const generation = ++generationRef.current
|
||||
const controller = new AbortController()
|
||||
listControllerRef.current?.abort()
|
||||
createControllerRef.current?.abort()
|
||||
listControllerRef.current = controller
|
||||
createControllerRef.current = null
|
||||
setSessions([])
|
||||
setExperts([])
|
||||
setSelectedSessionID(null)
|
||||
setSelectedExpertID(null)
|
||||
setExpertQuery('')
|
||||
setExpertCategory('all')
|
||||
setPrompt(readPromptDraft(projectId))
|
||||
setLoading(true)
|
||||
setLoadingExperts(true)
|
||||
setCreating(false)
|
||||
setError('')
|
||||
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
||||
|
||||
void onListSessions(projectId, controller.signal)
|
||||
.then((items) => {
|
||||
if (isCurrent()) setSessions(items)
|
||||
})
|
||||
.catch((requestError: unknown) => {
|
||||
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false)
|
||||
})
|
||||
|
||||
void onListExperts(controller.signal)
|
||||
.then((items) => {
|
||||
if (isCurrent()) setExperts(items)
|
||||
})
|
||||
.catch((requestError: unknown) => {
|
||||
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : '专家库加载失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) {
|
||||
setLoadingExperts(false)
|
||||
listControllerRef.current = null
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
generationRef.current += 1
|
||||
controller.abort()
|
||||
if (listControllerRef.current === controller) listControllerRef.current = null
|
||||
createControllerRef.current?.abort()
|
||||
createControllerRef.current = null
|
||||
}
|
||||
}, [onListExperts, onListSessions, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (prompt) localStorage.setItem(promptDraftKey(projectId), prompt)
|
||||
else localStorage.removeItem(promptDraftKey(projectId))
|
||||
} catch {
|
||||
// Draft persistence is best effort; an unavailable browser store must not block chat.
|
||||
}
|
||||
}, [projectId, prompt])
|
||||
|
||||
const selectedSession = useMemo(
|
||||
() => sessions.find((session) => session.id === selectedSessionID) ?? null,
|
||||
[selectedSessionID, sessions],
|
||||
)
|
||||
const selectedExpert = useMemo(
|
||||
() => experts.find((expert) => expert.id === selectedExpertID) ?? selectedSession?.expert ?? null,
|
||||
[experts, selectedExpertID, selectedSession],
|
||||
)
|
||||
const expertCategories = useMemo(() => {
|
||||
const categories = new Map<string, string>()
|
||||
experts.forEach((expert) => categories.set(expert.category, expert.categoryName))
|
||||
return [...categories.entries()].map(([id, name]) => ({ id, name }))
|
||||
}, [experts])
|
||||
const visibleExperts = useMemo(() => {
|
||||
const keyword = expertQuery.trim().toLocaleLowerCase()
|
||||
return experts.filter((expert) => {
|
||||
if (expertCategory !== 'all' && expert.category !== expertCategory) return false
|
||||
if (!keyword) return true
|
||||
return `${expert.name} ${expert.description} ${expert.categoryName}`.toLocaleLowerCase().includes(keyword)
|
||||
})
|
||||
}, [expertCategory, expertQuery, experts])
|
||||
|
||||
const createSession = async () => {
|
||||
const message = prompt.trim()
|
||||
if (!selectedExpert) {
|
||||
setError('请先选择一位专家')
|
||||
return
|
||||
}
|
||||
if (!message) {
|
||||
setError('请输入会话内容')
|
||||
return
|
||||
}
|
||||
const generation = ++generationRef.current
|
||||
listControllerRef.current?.abort()
|
||||
listControllerRef.current = null
|
||||
createControllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
createControllerRef.current = controller
|
||||
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
||||
|
||||
setLoading(false)
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await onCreateSession(projectId, {
|
||||
title: sessionTitle(message),
|
||||
context: message,
|
||||
expertId: selectedExpert.id,
|
||||
}, controller.signal)
|
||||
if (!isCurrent()) return
|
||||
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
|
||||
setSelectedSessionID(created.id)
|
||||
setSelectedExpertID(created.expert?.id ?? selectedExpert.id)
|
||||
setPrompt('')
|
||||
onSelectItem(created.title)
|
||||
} catch (requestError) {
|
||||
if (!isCurrent()) return
|
||||
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setCreating(false)
|
||||
createControllerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectSession(session: AISessionDTO) {
|
||||
setSelectedSessionID(session.id)
|
||||
setSelectedExpertID(session.expert?.id ?? null)
|
||||
setError('')
|
||||
onSelectItem(session.title)
|
||||
}
|
||||
|
||||
function startNewSession() {
|
||||
setSelectedSessionID(null)
|
||||
setSelectedExpertID(null)
|
||||
setExpertQuery('')
|
||||
setExpertCategory('all')
|
||||
setPrompt('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-ai-page">
|
||||
{error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||||
|
||||
<div className="agent-chat-shell">
|
||||
<Card className="agent-session-list queue-section" bordered>
|
||||
<Button className="agent-new-chat" icon={<IconPlus />} onClick={startNewSession}>新建会话</Button>
|
||||
|
||||
<Spin loading={loading} style={{ width: '100%' }}>
|
||||
{sessions.length ? (
|
||||
<div className="agent-session-groups">
|
||||
<section className="agent-session-group">
|
||||
<Text type="secondary">最近会话</Text>
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
className={selectedSessionID === session.id ? 'agent-session active' : 'agent-session'}
|
||||
key={session.id}
|
||||
title={session.title}
|
||||
onClick={() => selectSession(session)}
|
||||
>
|
||||
<span className="agent-session-emoji">{session.expert?.emoji || <IconRobot />}</span>
|
||||
<span>{session.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
) : loading ? null : <Empty className="agent-session-empty" description="暂无会话" />}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card className="agent-chat-panel queue-section" bordered>
|
||||
<div className="agent-chat-main">
|
||||
{selectedSession ? (
|
||||
<div className="agent-thread">
|
||||
<div className="agent-user-message">{selectedSession.context || selectedSession.title}</div>
|
||||
<div className="agent-assistant-message">
|
||||
<span className="agent-assistant-avatar" style={{ background: selectedExpert?.color || undefined }}>
|
||||
{selectedExpert?.emoji || <IconRobot />}
|
||||
</span>
|
||||
<div>
|
||||
<Text className="agent-assistant-name">{selectedExpert?.name || '森林AI'}</Text>
|
||||
<p>会话已创建。你可以继续补充问题或项目背景。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ExpertPicker
|
||||
experts={visibleExperts}
|
||||
categories={expertCategories}
|
||||
selectedExpertID={selectedExpertID}
|
||||
query={expertQuery}
|
||||
category={expertCategory}
|
||||
loading={loadingExperts}
|
||||
onQueryChange={setExpertQuery}
|
||||
onCategoryChange={setExpertCategory}
|
||||
onSelect={setSelectedExpertID}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="agent-composer">
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
placeholder={selectedExpert
|
||||
? `向${selectedExpert.name}发送消息,结合“${activeWorkspace.project.name}”项目上下文`
|
||||
: '请先从上方选择一位专家'}
|
||||
autoSize={{ minRows: 2, maxRows: 7 }}
|
||||
onChange={setPrompt}
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void createSession()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="agent-composer-footer">
|
||||
<Button className="agent-model-chip" icon={<span>{selectedExpert?.emoji || <IconRobot />}</span>}>
|
||||
{selectedExpert?.name || '选择专家'}
|
||||
</Button>
|
||||
<div className="agent-composer-actions">
|
||||
<Button aria-label="添加附件" type="text" shape="circle" icon={<IconAttachment />} />
|
||||
<Button
|
||||
aria-label="发送消息"
|
||||
className="agent-send-button"
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<IconArrowUp />}
|
||||
loading={creating}
|
||||
disabled={!selectedExpert || !prompt.trim()}
|
||||
onClick={() => void createSession()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Text className="agent-composer-hint" type="secondary">AI 生成内容仅作为建议,转为正式对象前仍需确认</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpertPicker({
|
||||
experts,
|
||||
categories,
|
||||
selectedExpertID,
|
||||
query,
|
||||
category,
|
||||
loading,
|
||||
onQueryChange,
|
||||
onCategoryChange,
|
||||
onSelect,
|
||||
}: {
|
||||
experts: AIExpertDTO[]
|
||||
categories: Array<{ id: string; name: string }>
|
||||
selectedExpertID: string | null
|
||||
query: string
|
||||
category: string
|
||||
loading: boolean
|
||||
onQueryChange: (value: string) => void
|
||||
onCategoryChange: (value: string) => void
|
||||
onSelect: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="agent-expert-picker">
|
||||
<div className="agent-expert-heading">
|
||||
<span className="agent-empty-icon"><IconRobot /></span>
|
||||
<strong>选择专家进行会话</strong>
|
||||
<Text type="secondary">选择合适的专业角色,让会话从明确的方法和交付目标开始</Text>
|
||||
</div>
|
||||
<Input
|
||||
className="agent-expert-search"
|
||||
prefix={<IconSearch />}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="搜索专家名称、简介或分类"
|
||||
allowClear
|
||||
/>
|
||||
<div className="agent-expert-categories">
|
||||
<button className={category === 'all' ? 'active' : ''} onClick={() => onCategoryChange('all')}>全部</button>
|
||||
{categories.map((item) => (
|
||||
<button className={category === item.id ? 'active' : ''} key={item.id} onClick={() => onCategoryChange(item.id)}>{item.name}</button>
|
||||
))}
|
||||
</div>
|
||||
<Spin className="agent-expert-loading" loading={loading}>
|
||||
{experts.length ? (
|
||||
<div className="agent-expert-grid">
|
||||
{experts.map((expert) => (
|
||||
<button
|
||||
className={selectedExpertID === expert.id ? 'agent-expert-card active' : 'agent-expert-card'}
|
||||
key={expert.id}
|
||||
onClick={() => onSelect(expert.id)}
|
||||
>
|
||||
<span className="agent-expert-avatar" style={{ background: expert.color }}>{expert.emoji || expert.name.slice(0, 1)}</span>
|
||||
<span className="agent-expert-copy">
|
||||
<small>{expert.categoryName}</small>
|
||||
<strong>{expert.name}</strong>
|
||||
<span>{expert.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : loading ? null : <Empty description="没有匹配的专家,换个关键词试试" />}
|
||||
</Spin>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sessionTitle(message: string) {
|
||||
const firstLine = message.split(/\r?\n/, 1)[0].trim()
|
||||
const characters = Array.from(firstLine)
|
||||
return characters.length > 36 ? `${characters.slice(0, 36).join('')}…` : firstLine
|
||||
}
|
||||
|
||||
function promptDraftKey(projectId: string) {
|
||||
return `senlin:ai:prompt:${projectId}`
|
||||
}
|
||||
|
||||
function readPromptDraft(projectId: string) {
|
||||
try {
|
||||
return localStorage.getItem(promptDraftKey(projectId)) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
103
apps/web_v1/src/pages/projects/project-channel-page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Button, Card, Typography } from '@arco-design/web-react'
|
||||
import { IconLaunch } from '@arco-design/web-react/icon'
|
||||
import { ProjectAi } from './project-ai'
|
||||
import { ProjectCron } from './project-cron'
|
||||
import { ProjectInbox } from './project-inbox'
|
||||
import { ProjectNewChannel } from './project-new-channel'
|
||||
import { ProjectDocuments } from './project-documents'
|
||||
import { ProjectOverview } from './project-overview'
|
||||
import type { ProjectTaskUpdate } from './project-task-edit-modal'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
|
||||
import type { InboxSuggestionDTO } from '../../api/inbox'
|
||||
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||
import type { ApiSession } from '../../api/client'
|
||||
import type { DocumentOpenIntent } from '../../api/documents'
|
||||
|
||||
export function ProjectChannelPage({
|
||||
activeChannel,
|
||||
activeWorkspace,
|
||||
session,
|
||||
theme,
|
||||
documentIntent,
|
||||
onDocumentIntentConsumed,
|
||||
activeTaskID,
|
||||
onSelectItem,
|
||||
onOpenTask,
|
||||
onCloseTask,
|
||||
onCreateTask,
|
||||
onCreateCronPlan,
|
||||
onCreateProjectTag,
|
||||
onUpdateTask,
|
||||
onAnalyzeInbox,
|
||||
onConfirmInbox,
|
||||
onListAISessions,
|
||||
onListAIExperts,
|
||||
onCreateAISession,
|
||||
onCreateProjectChannel,
|
||||
}: {
|
||||
activeChannel: ChannelKey
|
||||
activeWorkspace: ProjectWorkspace
|
||||
session: ApiSession
|
||||
theme: 'light' | 'dark'
|
||||
documentIntent: DocumentOpenIntent | null
|
||||
onDocumentIntentConsumed: (intentId: string) => void
|
||||
activeTaskID: string | null
|
||||
onSelectItem: (title: string) => void
|
||||
onOpenTask: (taskID: string) => void
|
||||
onCloseTask: () => void
|
||||
onCreateTask: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||
onCreateProjectChannel: (input: { title: string; icon: string; url: string }) => Promise<void>
|
||||
}) {
|
||||
if (activeChannel.startsWith('custom:')) {
|
||||
const channel = activeWorkspace.channels.find((item) => item.key === activeChannel)
|
||||
return channel ? <CustomLinkChannel label={channel.label} url={channel.url} /> : <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} />
|
||||
}
|
||||
switch (activeChannel) {
|
||||
case 'inbox':
|
||||
return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} />
|
||||
case 'tasks':
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
||||
case 'ai':
|
||||
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onListExperts={onListAIExperts} onCreateSession={onCreateAISession} />
|
||||
case 'documents':
|
||||
return (
|
||||
<ProjectDocuments
|
||||
key={activeWorkspace.project.id}
|
||||
session={session}
|
||||
projectId={activeWorkspace.project.id}
|
||||
theme={theme}
|
||||
intent={documentIntent}
|
||||
onIntentConsumed={onDocumentIntentConsumed}
|
||||
/>
|
||||
)
|
||||
case 'cron':
|
||||
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
|
||||
case 'new-channel':
|
||||
return <ProjectNewChannel activeWorkspace={activeWorkspace} onCreate={onCreateProjectChannel} />
|
||||
case 'overview':
|
||||
default:
|
||||
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} />
|
||||
}
|
||||
}
|
||||
|
||||
function CustomLinkChannel({ label, url }: { label: string; url?: string }) {
|
||||
const { Title, Text } = Typography
|
||||
return (
|
||||
<div className="project-channel-page overview-page">
|
||||
<Card className="queue-section" bordered>
|
||||
<Title heading={4}>{label}</Title>
|
||||
<Text type="secondary">此频道保存为项目内的外部链接入口。</Text>
|
||||
{url ? <Button type="primary" icon={<IconLaunch />} onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}>打开链接</Button> : null}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
apps/web_v1/src/pages/projects/project-cron.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Button, Card, Empty, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onCreateCronPlan: () => void }) {
|
||||
const { cronJobs, project } = activeWorkspace
|
||||
const enabledCount = cronJobs.filter((job) => job.enabled).length
|
||||
const pausedCount = cronJobs.length - enabledCount
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-cron-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>计划任务</Title>
|
||||
<Text type="secondary">{project.name} 的计划任务元数据与提醒安排;当前不会自主执行 AI 工作。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}>新建计划</Button>
|
||||
</div>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>任务列表</Title>
|
||||
<Space>
|
||||
<Tag color="green">{enabledCount} 运行中</Tag>
|
||||
<Tag color="gray">{pausedCount} 暂停</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
{cronJobs.length === 0 ? <Empty description="暂无计划任务" /> : cronJobs.map((job) => (
|
||||
<div
|
||||
className="data-row cron-row"
|
||||
key={job.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectItem(job.name)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') onSelectItem(job.name)
|
||||
}}
|
||||
>
|
||||
<span className="row-title"><IconClockCircle /> {job.name}</span>
|
||||
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
|
||||
<span>{job.expr}</span>
|
||||
<span>{job.lastRun}</span>
|
||||
<span>{job.nextRun}</span>
|
||||
<span>{job.owner}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
858
apps/web_v1/src/pages/projects/project-documents.tsx
Normal file
@@ -0,0 +1,858 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Menu,
|
||||
Message,
|
||||
Progress,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconClose,
|
||||
IconDelete,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFile,
|
||||
IconFileAudio,
|
||||
IconFileImage,
|
||||
IconFilePdf,
|
||||
IconFileVideo,
|
||||
IconFolder,
|
||||
IconFolderAdd,
|
||||
IconLeft,
|
||||
IconPlus,
|
||||
IconRight,
|
||||
IconSave,
|
||||
IconShareAlt,
|
||||
IconUpload,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { ApiSession } from '../../api/client'
|
||||
import {
|
||||
createDocumentFolder,
|
||||
createDocumentShare,
|
||||
createMarkdownDocument,
|
||||
deleteDocument,
|
||||
downloadBlob,
|
||||
exportDocument,
|
||||
fetchDocumentBlob,
|
||||
fetchLegacySheetPreview,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
uploadDocument,
|
||||
type DocumentDTO,
|
||||
type DocumentOpenIntent,
|
||||
type UploadControl,
|
||||
} from '../../api/documents'
|
||||
import type { Theme } from './project-types'
|
||||
import { DocumentMarkdownEditor } from './document-markdown-editor'
|
||||
|
||||
type SaveStatus = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||
|
||||
type DocumentTab = {
|
||||
key: string
|
||||
documentId?: string
|
||||
parentId?: string
|
||||
name: string
|
||||
extension: string
|
||||
mimeType: string
|
||||
markdown: string
|
||||
revision: number
|
||||
status: SaveStatus
|
||||
mode: 'edit' | 'preview'
|
||||
}
|
||||
|
||||
type UploadItem = {
|
||||
id: string
|
||||
file: File
|
||||
progress: number
|
||||
status: 'queued' | 'uploading' | 'done' | 'error' | 'cancelled'
|
||||
error?: string
|
||||
}
|
||||
|
||||
type PersistedDocumentDraft = Pick<DocumentTab, 'key' | 'documentId' | 'parentId' | 'name' | 'extension' | 'mimeType' | 'markdown' | 'revision' | 'status' | 'mode'>
|
||||
|
||||
export function ProjectDocuments({
|
||||
session,
|
||||
projectId,
|
||||
theme,
|
||||
intent,
|
||||
onIntentConsumed,
|
||||
}: {
|
||||
session: ApiSession
|
||||
projectId: string
|
||||
theme: Theme
|
||||
intent: DocumentOpenIntent | null
|
||||
onIntentConsumed: (intentId: string) => void
|
||||
}) {
|
||||
const [documents, setDocuments] = useState<DocumentDTO[]>([])
|
||||
const [tabs, setTabs] = useState<DocumentTab[]>([])
|
||||
const [activeKey, setActiveKey] = useState('')
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [treeWidth, setTreeWidth] = useState(260)
|
||||
const [treeCollapsed, setTreeCollapsed] = useState(false)
|
||||
const [mobilePane, setMobilePane] = useState<'tree' | 'editor'>('tree')
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; document: DocumentDTO } | null>(null)
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([])
|
||||
const tabsRef = useRef<DocumentTab[]>([])
|
||||
const timersRef = useRef(new Map<string, number>())
|
||||
const uploadControlsRef = useRef(new Map<string, UploadControl>())
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
tabsRef.current = tabs
|
||||
|
||||
const activeTab = tabs.find((tab) => tab.key === activeKey)
|
||||
const rootDocuments = useMemo(() => buildTree(documents), [documents])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
const result = await listDocuments(session, projectId)
|
||||
setDocuments(result)
|
||||
return result
|
||||
}, [projectId, session])
|
||||
|
||||
const openDocument = useCallback(async (document: DocumentDTO) => {
|
||||
if (document.kind === 'folder') {
|
||||
setExpanded((current) => toggleSet(current, document.id))
|
||||
return
|
||||
}
|
||||
const existing = tabsRef.current.find((tab) => tab.documentId === document.id)
|
||||
if (existing) {
|
||||
setActiveKey(existing.key)
|
||||
setMobilePane('editor')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detail = await getDocument(session, projectId, document.id)
|
||||
const next = tabFromDocument(detail)
|
||||
setTabs((current) => [...current, next])
|
||||
setActiveKey(next.key)
|
||||
setMobilePane('editor')
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '文档打开失败')
|
||||
}
|
||||
}, [projectId, session])
|
||||
|
||||
const createDraft = useCallback((name = '新建文件1.md', markdown = '', parentId = '') => {
|
||||
const key = `draft:${crypto.randomUUID()}`
|
||||
const tab: DocumentTab = {
|
||||
key,
|
||||
parentId,
|
||||
name,
|
||||
extension: '.md',
|
||||
mimeType: 'text/markdown; charset=utf-8',
|
||||
markdown,
|
||||
revision: 0,
|
||||
status: markdown ? 'dirty' : 'clean',
|
||||
mode: 'edit',
|
||||
}
|
||||
setTabs((current) => [...current, tab])
|
||||
setActiveKey(key)
|
||||
setMobilePane('editor')
|
||||
return key
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const timers = timersRef.current
|
||||
setTabs([])
|
||||
setActiveKey('')
|
||||
setLoading(true)
|
||||
void refreshTree().then(async (items) => {
|
||||
if (cancelled) return
|
||||
const stored = readStoredTabs(projectId)
|
||||
const drafts = readStoredDrafts(projectId)
|
||||
const draftByDocumentID = new Map(drafts.filter((draft) => draft.documentId).map((draft) => [draft.documentId!, draft]))
|
||||
const restored: DocumentTab[] = []
|
||||
for (const id of stored) {
|
||||
const item = items.find((document) => document.id === id && document.kind === 'file')
|
||||
if (!item) continue
|
||||
try {
|
||||
const detail = await getDocument(session, projectId, id)
|
||||
if (!cancelled) restored.push(restoreDocumentDraft(tabFromDocument(detail), draftByDocumentID.get(id)))
|
||||
} catch {
|
||||
// Missing tabs are discarded.
|
||||
}
|
||||
}
|
||||
restored.push(...drafts.filter((draft) => !draft.documentId))
|
||||
if (cancelled) return
|
||||
if (restored.length > 0) {
|
||||
setTabs(restored)
|
||||
setActiveKey(restored[0].key)
|
||||
setMobilePane('editor')
|
||||
} else {
|
||||
createDraft()
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!cancelled) Message.error(error instanceof Error ? error.message : '文档树加载失败')
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
for (const timer of timers.values()) window.clearTimeout(timer)
|
||||
timers.clear()
|
||||
}
|
||||
}, [createDraft, projectId, refreshTree, session])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : [])
|
||||
localStorage.setItem(storageKey(projectId), JSON.stringify(ids))
|
||||
const drafts = tabs.filter((tab) => tab.status === 'dirty' || tab.status === 'saving' || tab.status === 'error')
|
||||
localStorage.setItem(draftStorageKey(projectId), JSON.stringify(drafts))
|
||||
}, [projectId, tabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!intent || intent.projectId !== projectId) return
|
||||
if (intent.mode === 'open' && intent.documentId) {
|
||||
const document = documents.find((item) => item.id === intent.documentId)
|
||||
if (document) void openDocument(document)
|
||||
} else {
|
||||
createDraft(intent.suggestedName || '新建文件1.md', intent.initialMarkdown || '', intent.targetDirectoryId || '')
|
||||
}
|
||||
onIntentConsumed(intent.intentId)
|
||||
}, [createDraft, documents, intent, onIntentConsumed, openDocument, projectId])
|
||||
|
||||
const persistTab = useCallback(async (key: string, forcedMarkdown?: string) => {
|
||||
const tab = tabsRef.current.find((item) => item.key === key)
|
||||
if (!tab || tab.extension !== '.md' || tab.status === 'saving') return
|
||||
const markdown = forcedMarkdown ?? tab.markdown
|
||||
setTabs((current) => patchTab(current, key, { status: 'saving' }))
|
||||
try {
|
||||
if (!tab.documentId) {
|
||||
const created = await createMarkdownDocument(session, projectId, { name: tab.name, parentId: tab.parentId, markdown })
|
||||
setDocuments((current) => [...current, created])
|
||||
setTabs((current) => patchTab(current, key, {
|
||||
documentId: created.id,
|
||||
key: created.id,
|
||||
name: created.name,
|
||||
revision: created.revision,
|
||||
status: 'saved',
|
||||
}))
|
||||
setActiveKey((current) => current === key ? created.id : current)
|
||||
} else {
|
||||
const updated = await updateDocument(session, projectId, tab.documentId, {
|
||||
markdown,
|
||||
revision: tab.revision,
|
||||
})
|
||||
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
|
||||
setTabs((current) => patchTab(current, key, {
|
||||
markdown: updated.markdown ?? markdown,
|
||||
revision: updated.revision,
|
||||
status: 'saved',
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
setTabs((current) => patchTab(current, key, { status: 'error' }))
|
||||
Message.error(error instanceof Error ? error.message : '自动保存失败,本地草稿已保留')
|
||||
}
|
||||
}, [projectId, session])
|
||||
|
||||
function changeMarkdown(key: string, markdown: string) {
|
||||
setTabs((current) => patchTab(current, key, { markdown, status: 'dirty' }))
|
||||
const previous = timersRef.current.get(key)
|
||||
if (previous) window.clearTimeout(previous)
|
||||
timersRef.current.set(key, window.setTimeout(() => {
|
||||
timersRef.current.delete(key)
|
||||
void persistTab(key, markdown)
|
||||
}, 800))
|
||||
}
|
||||
|
||||
async function addFolder(parentId = '') {
|
||||
const name = window.prompt('目录名称')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
const created = await createDocumentFolder(session, projectId, name.trim(), parentId)
|
||||
setDocuments((current) => [...current, created])
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '目录创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function renameNode(document: DocumentDTO) {
|
||||
const name = window.prompt('重命名', document.name)
|
||||
if (!name?.trim() || name.trim() === document.name) return
|
||||
try {
|
||||
const updated = await updateDocument(session, projectId, document.id, { name: name.trim() })
|
||||
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
|
||||
setTabs((current) => current.map((tab) => tab.documentId === updated.id ? { ...tab, name: updated.name } : tab))
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '重命名失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNode(document: DocumentDTO) {
|
||||
if (!window.confirm(`确定删除“${document.name}”${document.kind === 'folder' ? '及其全部内容' : ''}吗?`)) return
|
||||
try {
|
||||
await deleteDocument(session, projectId, document.id)
|
||||
const removed = descendantIds(documents, document.id)
|
||||
setDocuments((current) => current.filter((item) => !removed.has(item.id)))
|
||||
setTabs((current) => current.filter((tab) => !tab.documentId || !removed.has(tab.documentId)))
|
||||
if (activeTab?.documentId && removed.has(activeTab.documentId)) setActiveKey('')
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function moveNode(documentId: string, parentId: string) {
|
||||
const document = documents.find((item) => item.id === documentId)
|
||||
if (!document || document.parentId === (parentId || null)) return
|
||||
try {
|
||||
const updated = await updateDocument(session, projectId, documentId, { parentId })
|
||||
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '移动失败')
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueFiles(files: FileList | File[]) {
|
||||
const accepted = Array.from(files).filter((file) => {
|
||||
if (file.size <= 50 * 1024 * 1024) return true
|
||||
Message.error(`${file.name} 超过 50 MB,未加入上传队列`)
|
||||
return false
|
||||
})
|
||||
setUploads((current) => [
|
||||
...current,
|
||||
...accepted.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
progress: 0,
|
||||
status: 'queued' as const,
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const running = uploads.filter((item) => item.status === 'uploading').length
|
||||
const queued = uploads.filter((item) => item.status === 'queued').slice(0, Math.max(0, 3 - running))
|
||||
for (const item of queued) {
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, status: 'uploading' } : candidate))
|
||||
const control = uploadDocument(session, projectId, item.file, '', (progress) => {
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress } : candidate))
|
||||
})
|
||||
uploadControlsRef.current.set(item.id, control)
|
||||
void control.promise.then((document) => {
|
||||
setDocuments((current) => [...current, document])
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress: 100, status: 'done' } : candidate))
|
||||
void openDocument(document)
|
||||
}).catch((error) => {
|
||||
setUploads((current) => current.map((candidate) => candidate.id === item.id
|
||||
? { ...candidate, status: 'error', error: error instanceof Error ? error.message : '上传失败' }
|
||||
: candidate))
|
||||
}).finally(() => {
|
||||
uploadControlsRef.current.delete(item.id)
|
||||
window.setTimeout(() => {
|
||||
setUploads((current) => current.filter((candidate) => candidate.id !== item.id || candidate.status !== 'done'))
|
||||
}, 1800)
|
||||
})
|
||||
}
|
||||
}, [openDocument, projectId, session, uploads])
|
||||
|
||||
function closeTab(tab: DocumentTab) {
|
||||
if ((tab.status === 'dirty' || tab.status === 'error') && !window.confirm(`“${tab.name}”尚未成功保存,仍要关闭吗?`)) return
|
||||
const index = tabs.findIndex((item) => item.key === tab.key)
|
||||
const next = tabs.filter((item) => item.key !== tab.key)
|
||||
if (tab.key !== activeKey) {
|
||||
setTabs(next)
|
||||
return
|
||||
}
|
||||
const replacement = next[Math.max(0, index - 1)]
|
||||
if (replacement) {
|
||||
setTabs(next)
|
||||
setActiveKey(replacement.key)
|
||||
return
|
||||
}
|
||||
const key = `draft:${crypto.randomUUID()}`
|
||||
setTabs([{
|
||||
key,
|
||||
name: '新建文件1.md',
|
||||
extension: '.md',
|
||||
mimeType: 'text/markdown; charset=utf-8',
|
||||
markdown: '',
|
||||
revision: 0,
|
||||
status: 'clean',
|
||||
mode: 'edit',
|
||||
}])
|
||||
setActiveKey(key)
|
||||
}
|
||||
|
||||
async function downloadActive() {
|
||||
if (!activeTab?.documentId) return
|
||||
try {
|
||||
const blob = await fetchDocumentBlob(session, projectId, activeTab.documentId, true)
|
||||
downloadBlob(blob, activeTab.name)
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function shareActive(days: 1 | 7 | 30) {
|
||||
if (!activeTab?.documentId) {
|
||||
Message.warning('请先保存文档')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const share = await createDocumentShare(session, projectId, activeTab.documentId, days)
|
||||
if (share.url) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(share.url)
|
||||
Message.success(`${days} 天有效的预览链接已复制`)
|
||||
} catch {
|
||||
window.prompt('复制分享链接', share.url)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '分享链接创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function beginResize(event: React.MouseEvent) {
|
||||
const startX = event.clientX
|
||||
const startWidth = treeWidth
|
||||
function move(moveEvent: MouseEvent) {
|
||||
setTreeWidth(Math.min(420, Math.max(210, startWidth + moveEvent.clientX - startX)))
|
||||
}
|
||||
function finish() {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('mouseup', finish)
|
||||
}
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('mouseup', finish)
|
||||
}
|
||||
|
||||
const uploadProgress = uploads.length === 0
|
||||
? 0
|
||||
: Math.round(uploads.reduce((sum, item) => sum + item.progress, 0) / uploads.length)
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`documents-canvas mobile-${mobilePane}`}
|
||||
onClick={() => setContextMenu(null)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer.files.length > 0) enqueueFiles(event.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
{!treeCollapsed && (
|
||||
<aside className="documents-tree-pane" style={{ width: treeWidth }}>
|
||||
<div className="documents-tree-tools">
|
||||
<Tooltip content="新建目录"><Button type="text" icon={<IconFolderAdd />} onClick={() => void addFolder()} /></Tooltip>
|
||||
<Tooltip content="新建 Markdown"><Button type="text" icon={<IconPlus />} onClick={() => createDraft()} /></Tooltip>
|
||||
<span className="documents-tree-spacer" />
|
||||
<Tooltip content="上传文件"><Button type="text" icon={<IconUpload />} onClick={() => fileInputRef.current?.click()} /></Tooltip>
|
||||
<Tooltip content="折叠文件树"><Button type="text" icon={<IconLeft />} onClick={() => setTreeCollapsed(true)} /></Tooltip>
|
||||
<input ref={fileInputRef} hidden multiple type="file" onChange={(event) => event.target.files && enqueueFiles(event.target.files)} />
|
||||
</div>
|
||||
<div
|
||||
className="documents-tree"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
const id = event.dataTransfer.getData('application/x-senlin-document')
|
||||
if (id) {
|
||||
event.stopPropagation()
|
||||
void moveNode(id, '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? <Spin /> : rootDocuments.length === 0 ? <Empty description="暂无文件" /> : rootDocuments.map((node) => (
|
||||
<TreeNode
|
||||
key={node.document.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
activeDocumentId={activeTab?.documentId}
|
||||
onToggle={(id) => setExpanded((current) => toggleSet(current, id))}
|
||||
onOpen={(document) => void openDocument(document)}
|
||||
onMove={(id, parent) => void moveNode(id, parent)}
|
||||
onContextMenu={(event, document) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, document })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{uploads.length > 0 && (
|
||||
<div className="document-upload-summary">
|
||||
<div><span>上传 {uploads.filter((item) => item.status === 'done').length}/{uploads.length}</span><span>{uploadProgress}%</span></div>
|
||||
<Progress percent={uploadProgress} showText={false} size="small" />
|
||||
{uploads.filter((item) => item.status === 'uploading' || item.status === 'error').map((item) => (
|
||||
<div className="document-upload-row" key={item.id}>
|
||||
<span title={item.error}>{item.file.name}</span>
|
||||
<span>{item.status === 'error' ? '失败' : `${item.progress}%`}</span>
|
||||
{item.status === 'uploading' ? (
|
||||
<button onClick={() => uploadControlsRef.current.get(item.id)?.cancel()}>取消</button>
|
||||
) : (
|
||||
<button onClick={() => setUploads((current) => current.map((candidate) => candidate.id === item.id
|
||||
? { ...candidate, progress: 0, status: 'queued', error: undefined }
|
||||
: candidate))}>重试</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{!treeCollapsed && <div className="documents-resizer" onMouseDown={beginResize} />}
|
||||
|
||||
<main className="documents-editor-pane">
|
||||
<div className="documents-tabs-bar">
|
||||
{treeCollapsed && <Button type="text" icon={<IconRight />} onClick={() => setTreeCollapsed(false)} />}
|
||||
<div className="documents-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button className={tab.key === activeKey ? 'document-tab active' : 'document-tab'} key={tab.key} onClick={() => setActiveKey(tab.key)}>
|
||||
{fileIcon(tab.extension, tab.mimeType)}
|
||||
<span>{tab.name}</span>
|
||||
<i className={`save-dot ${tab.status}`} title={saveLabel(tab.status)} />
|
||||
<IconClose onClick={(event) => { event.stopPropagation(); closeTab(tab) }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeTab && (
|
||||
<div className="documents-tab-actions">
|
||||
{activeTab.extension === '.md' && (
|
||||
<Tooltip content={activeTab.mode === 'edit' ? '预览' : '编辑'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={activeTab.mode === 'edit' ? <IconEye /> : <IconEdit />}
|
||||
onClick={() => setTabs((current) => patchTab(current, activeTab.key, { mode: activeTab.mode === 'edit' ? 'preview' : 'edit' }))}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{activeTab.extension === '.md' && (
|
||||
<Tooltip content="保存"><Button type="text" icon={<IconSave />} onClick={() => void persistTab(activeTab.key)} /></Tooltip>
|
||||
)}
|
||||
{activeTab.documentId && activeTab.extension === '.md' ? (
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
droplist={<Menu onClickMenuItem={(format: string) => void exportDocument(session, projectId, activeTab.documentId!, format as 'md' | 'pdf' | 'docx')}>
|
||||
<Menu.Item key="md">导出 Markdown</Menu.Item>
|
||||
<Menu.Item key="pdf">导出 PDF</Menu.Item>
|
||||
<Menu.Item key="docx">导出 Word</Menu.Item>
|
||||
</Menu>}
|
||||
>
|
||||
<Button type="text" icon={<IconDownload />} />
|
||||
</Dropdown>
|
||||
) : activeTab.documentId ? (
|
||||
<Tooltip content="下载原文件"><Button type="text" icon={<IconDownload />} onClick={() => void downloadActive()} /></Tooltip>
|
||||
) : null}
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
droplist={<Menu onClickMenuItem={(days) => void shareActive(Number(days) as 1 | 7 | 30)}>
|
||||
<Menu.Item key="1">有效期 1 天</Menu.Item>
|
||||
<Menu.Item key="7">有效期 7 天</Menu.Item>
|
||||
<Menu.Item key="30">有效期 30 天</Menu.Item>
|
||||
</Menu>}
|
||||
>
|
||||
<Tooltip content="分享预览"><Button type="text" icon={<IconShareAlt />} /></Tooltip>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="documents-mobile-switch">
|
||||
<Button icon={<IconLeft />} onClick={() => setMobilePane('tree')}>文件</Button>
|
||||
</div>
|
||||
|
||||
<div className="document-editor-body">
|
||||
{!activeTab ? <Empty description="没有打开的文件" /> : activeTab.extension === '.md' && activeTab.mode === 'edit' ? (
|
||||
<DocumentMarkdownEditor
|
||||
value={activeTab.markdown}
|
||||
dark={theme === 'dark'}
|
||||
onChange={(value) => changeMarkdown(activeTab.key, value)}
|
||||
onSave={() => void persistTab(activeTab.key)}
|
||||
/>
|
||||
) : activeTab.extension === '.md' ? (
|
||||
<MarkdownPreview markdown={activeTab.markdown} />
|
||||
) : activeTab.documentId ? (
|
||||
<SmartPreview session={session} projectId={projectId} tab={activeTab} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{contextMenu && (
|
||||
<div className="document-context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
|
||||
{contextMenu.document.kind === 'folder' && (
|
||||
<>
|
||||
<button onClick={() => void addFolder(contextMenu.document.id)}><IconFolderAdd />新建子目录</button>
|
||||
<button onClick={() => createDraft('新建文件1.md', '', contextMenu.document.id)}><IconPlus />新建 Markdown</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => void renameNode(contextMenu.document)}><IconEdit />重命名</button>
|
||||
<button className="danger" onClick={() => void removeNode(contextMenu.document)}><IconDelete />删除</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type TreeEntry = { document: DocumentDTO; children: TreeEntry[] }
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
activeDocumentId,
|
||||
onToggle,
|
||||
onOpen,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: {
|
||||
node: TreeEntry
|
||||
depth: number
|
||||
expanded: Set<string>
|
||||
activeDocumentId?: string
|
||||
onToggle: (id: string) => void
|
||||
onOpen: (document: DocumentDTO) => void
|
||||
onMove: (documentId: string, parentId: string) => void
|
||||
onContextMenu: (event: React.MouseEvent, document: DocumentDTO) => void
|
||||
}) {
|
||||
const { document } = node
|
||||
const open = expanded.has(document.id)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={document.id === activeDocumentId ? 'document-tree-row active' : 'document-tree-row'}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
draggable
|
||||
onDragStart={(event) => event.dataTransfer.setData('application/x-senlin-document', document.id)}
|
||||
onDragOver={(event) => { if (document.kind === 'folder') event.preventDefault() }}
|
||||
onDrop={(event) => {
|
||||
if (document.kind !== 'folder') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const id = event.dataTransfer.getData('application/x-senlin-document')
|
||||
if (id) onMove(id, document.id)
|
||||
}}
|
||||
onContextMenu={(event) => onContextMenu(event, document)}
|
||||
onClick={() => document.kind === 'folder' ? onToggle(document.id) : onOpen(document)}
|
||||
onDoubleClick={() => onOpen(document)}
|
||||
>
|
||||
{document.kind === 'folder' ? <span className="tree-chevron">{open ? '⌄' : '›'}</span> : <span className="tree-chevron" />}
|
||||
{document.kind === 'folder' ? <IconFolder /> : fileIcon(document.extension, document.mimeType)}
|
||||
<span>{document.name}</span>
|
||||
</button>
|
||||
{document.kind === 'folder' && open && node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.document.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
activeDocumentId={activeDocumentId}
|
||||
onToggle={onToggle}
|
||||
onOpen={onOpen}
|
||||
onMove={onMove}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownPreview({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<article className="document-markdown-preview">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>{markdown}</ReactMarkdown>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SmartPreview({ session, projectId, tab }: { session: ApiSession; projectId: string; tab: DocumentTab }) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [html, setHtml] = useState('')
|
||||
const [rows, setRows] = useState<unknown[][]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let objectURL = ''
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const extension = tab.extension.toLowerCase()
|
||||
if (extension === '.xls') {
|
||||
void fetchLegacySheetPreview(session, projectId, tab.documentId!).then((preview) => {
|
||||
if (active) setRows(preview.rows)
|
||||
}).catch((reason) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : '预览加载失败')
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
void fetchDocumentBlob(session, projectId, tab.documentId!).then(async (blob) => {
|
||||
if (!active) return
|
||||
if (isText(extension, tab.mimeType)) {
|
||||
setText(await blob.text())
|
||||
} else if (extension === '.docx') {
|
||||
const mammoth = await import('mammoth')
|
||||
const result = await mammoth.default.convertToHtml({ arrayBuffer: await blob.arrayBuffer() })
|
||||
if (active) setHtml(DOMPurify.sanitize(result.value))
|
||||
} else if (extension === '.xlsx') {
|
||||
const { readSheet } = await import('read-excel-file/browser')
|
||||
const parsed = await readSheet(blob)
|
||||
if (active) setRows(parsed as unknown[][])
|
||||
} else {
|
||||
const previewBlob = extension === '.svg'
|
||||
? new Blob([DOMPurify.sanitize(await blob.text(), { USE_PROFILES: { svg: true, svgFilters: true } })], { type: 'image/svg+xml' })
|
||||
: blob
|
||||
objectURL = URL.createObjectURL(previewBlob)
|
||||
setUrl(objectURL)
|
||||
}
|
||||
}).catch((reason) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : '预览加载失败')
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
if (objectURL) URL.revokeObjectURL(objectURL)
|
||||
}
|
||||
}, [projectId, session, tab.documentId, tab.extension, tab.mimeType])
|
||||
|
||||
if (loading) return <Spin />
|
||||
if (error) return <Empty description={error} />
|
||||
if (tab.mimeType.startsWith('image/')) return <div className="document-media-preview"><img src={url} alt={tab.name} /></div>
|
||||
if (tab.mimeType.startsWith('audio/')) return <div className="document-media-preview"><audio src={url} controls /></div>
|
||||
if (tab.mimeType.startsWith('video/')) return <div className="document-media-preview"><video src={url} controls /></div>
|
||||
if (tab.extension === '.pdf') return <iframe className="document-pdf-preview" src={url} title={tab.name} />
|
||||
if (tab.extension === '.docx') return <article className="document-office-preview" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
if (tab.extension === '.xlsx' || tab.extension === '.xls') return (
|
||||
<div className="document-sheet-preview"><table><tbody>{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}>{String(cell ?? '')}</td>)}</tr>
|
||||
))}</tbody></table></div>
|
||||
)
|
||||
if (text) return <pre className="document-text-preview">{text}</pre>
|
||||
return <Empty description="此格式暂不支持在线预览,可使用右上角下载" />
|
||||
}
|
||||
|
||||
function buildTree(documents: DocumentDTO[]) {
|
||||
const entries = new Map<string, TreeEntry>()
|
||||
for (const document of documents) entries.set(document.id, { document, children: [] })
|
||||
const roots: TreeEntry[] = []
|
||||
for (const entry of entries.values()) {
|
||||
const parent = entry.document.parentId ? entries.get(entry.document.parentId) : null
|
||||
if (parent) parent.children.push(entry)
|
||||
else roots.push(entry)
|
||||
}
|
||||
const sort = (items: TreeEntry[]) => {
|
||||
items.sort((left, right) => {
|
||||
if (left.document.kind !== right.document.kind) return left.document.kind === 'folder' ? -1 : 1
|
||||
return left.document.name.localeCompare(right.document.name, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
items.forEach((item) => sort(item.children))
|
||||
}
|
||||
sort(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
function tabFromDocument(document: DocumentDTO): DocumentTab {
|
||||
return {
|
||||
key: document.id,
|
||||
documentId: document.id,
|
||||
parentId: document.parentId ?? '',
|
||||
name: document.name,
|
||||
extension: document.extension.toLowerCase(),
|
||||
mimeType: document.mimeType,
|
||||
markdown: document.markdown ?? '',
|
||||
revision: document.revision,
|
||||
status: 'clean',
|
||||
mode: document.extension.toLowerCase() === '.md' ? 'edit' : 'preview',
|
||||
}
|
||||
}
|
||||
|
||||
function patchTab(tabs: DocumentTab[], key: string, patch: Partial<DocumentTab>) {
|
||||
return tabs.map((tab) => tab.key === key ? { ...tab, ...patch } : tab)
|
||||
}
|
||||
|
||||
function toggleSet(current: Set<string>, value: string) {
|
||||
const next = new Set(current)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
}
|
||||
|
||||
function descendantIds(documents: DocumentDTO[], root: string) {
|
||||
const ids = new Set([root])
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const document of documents) {
|
||||
if (document.parentId && ids.has(document.parentId) && !ids.has(document.id)) {
|
||||
ids.add(document.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function storageKey(projectId: string) {
|
||||
return `senlin:documents:tabs:${projectId}`
|
||||
}
|
||||
|
||||
function draftStorageKey(projectId: string) {
|
||||
return `senlin:documents:drafts:${projectId}`
|
||||
}
|
||||
|
||||
function readStoredTabs(projectId: string): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey(projectId)) ?? '[]')
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredDrafts(projectId: string): PersistedDocumentDraft[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(draftStorageKey(projectId)) ?? '[]')
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPersistedDocumentDraft)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isPersistedDocumentDraft(value: unknown): value is PersistedDocumentDraft {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const draft = value as Partial<PersistedDocumentDraft>
|
||||
return typeof draft.key === 'string' && typeof draft.name === 'string' && typeof draft.extension === 'string' &&
|
||||
typeof draft.mimeType === 'string' && typeof draft.markdown === 'string' && typeof draft.revision === 'number' &&
|
||||
(draft.status === 'dirty' || draft.status === 'saving' || draft.status === 'error') &&
|
||||
(draft.mode === 'edit' || draft.mode === 'preview') &&
|
||||
(draft.documentId === undefined || typeof draft.documentId === 'string') &&
|
||||
(draft.parentId === undefined || typeof draft.parentId === 'string')
|
||||
}
|
||||
|
||||
function restoreDocumentDraft(tab: DocumentTab, draft?: PersistedDocumentDraft): DocumentTab {
|
||||
if (!draft) return tab
|
||||
return { ...tab, markdown: draft.markdown, revision: draft.revision, status: draft.status === 'saving' ? 'dirty' : draft.status, mode: draft.mode }
|
||||
}
|
||||
|
||||
function saveLabel(status: SaveStatus) {
|
||||
return { clean: '已保存', dirty: '未保存', saving: '保存中', saved: '已保存', error: '保存失败' }[status]
|
||||
}
|
||||
|
||||
function fileIcon(extension: string, mimeType: string) {
|
||||
if (extension === '.pdf') return <IconFilePdf />
|
||||
if (mimeType.startsWith('image/')) return <IconFileImage />
|
||||
if (mimeType.startsWith('audio/')) return <IconFileAudio />
|
||||
if (mimeType.startsWith('video/')) return <IconFileVideo />
|
||||
return <IconFile />
|
||||
}
|
||||
|
||||
function isText(extension: string, mimeType: string) {
|
||||
return mimeType.startsWith('text/') || [
|
||||
'.txt', '.json', '.yaml', '.yml', '.xml', '.csv', '.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.css', '.html',
|
||||
].includes(extension)
|
||||
}
|
||||
67
apps/web_v1/src/pages/projects/project-file-grid.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
|
||||
import type { DocumentSummary } from './project-types'
|
||||
|
||||
export type ProjectFileItem = DocumentSummary | {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
updated: string
|
||||
owner: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export function ProjectFileGrid({
|
||||
items,
|
||||
onSelectItem,
|
||||
compact = false,
|
||||
}: {
|
||||
items: ProjectFileItem[]
|
||||
onSelectItem: (title: string) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
|
||||
{items.map((item) => {
|
||||
const fileKind = inferFileKind(item)
|
||||
const name = 'name' in item ? item.name : item.title
|
||||
return (
|
||||
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(name)}>
|
||||
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
|
||||
<span className="project-file-copy">
|
||||
<span className="project-file-name">{name}</span>
|
||||
<span className="project-file-type">{fileKind.label}</span>
|
||||
<span className="project-file-meta">{fileKind.meta}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function inferFileKind(item: ProjectFileItem) {
|
||||
if ('name' in item) {
|
||||
if (item.kind === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
|
||||
const value = `${item.name} ${item.extension} ${item.mimeType}`.toLowerCase()
|
||||
if (item.extension === '.md') return { kind: 'document', label: 'Markdown 文档', meta: item.updated }
|
||||
if (item.extension === '.pdf') return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
|
||||
if (item.mimeType.startsWith('image/')) return { kind: 'image', label: '图片文件', meta: item.updated }
|
||||
if (value.includes('sheet') || value.includes('excel')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
|
||||
return { kind: 'file', label: item.extension || '文件', meta: item.updated }
|
||||
}
|
||||
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
|
||||
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
|
||||
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
|
||||
if (value.includes('png') || value.includes('jpg') || value.includes('jpeg') || value.includes('图片')) return { kind: 'image', label: '图片文件', meta: item.updated }
|
||||
if (value.includes('excel') || value.includes('xlsx') || value.includes('表')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
|
||||
if (value.includes('word') || value.includes('doc') || value.includes('文档')) return { kind: 'doc', label: '文档', meta: item.updated }
|
||||
if (value.includes('markdown') || value.includes('笔记')) return { kind: 'note', label: 'Markdown 笔记', meta: item.updated }
|
||||
return { kind: 'file', label: item.type || '文件', meta: item.updated }
|
||||
}
|
||||
|
||||
function fileIcon(kind: string) {
|
||||
if (kind === 'folder') return <IconFolder />
|
||||
if (kind === 'pdf') return <IconFilePdf />
|
||||
if (kind === 'image') return <IconFileImage />
|
||||
return <IconFile />
|
||||
}
|
||||
11
apps/web_v1/src/pages/projects/project-icon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IconFolder } from '@arco-design/web-react/icon'
|
||||
|
||||
export function ProjectIcon({ icon, className }: { icon: string; className: string }) {
|
||||
const value = icon.trim()
|
||||
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{value || <IconFolder />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
247
apps/web_v1/src/pages/projects/project-inbox.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon'
|
||||
import { ApiError } from '../../api/client'
|
||||
import type { InboxSuggestionDTO } from '../../api/inbox'
|
||||
import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
type ProjectInboxProps = {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onAnalyze: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
}
|
||||
|
||||
type UncertainConfirmation = {
|
||||
inboxItemId: string
|
||||
suggestionIds: string[]
|
||||
}
|
||||
|
||||
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) {
|
||||
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
|
||||
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
|
||||
const [selectedSuggestionIds, setSelectedSuggestionIds] = useState<string[]>([])
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [refreshWarning, setRefreshWarning] = useState('')
|
||||
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
|
||||
const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null)
|
||||
const analysisGeneration = useRef(0)
|
||||
const selectedItem = useMemo(
|
||||
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
|
||||
[activeWorkspace.inbox, selectedItemId],
|
||||
)
|
||||
|
||||
function selectItem(item: InboxItem) {
|
||||
if (confirmationUncertain) return
|
||||
analysisGeneration.current += 1
|
||||
setSelectedItemId(item.id)
|
||||
setDraftSuggestions([])
|
||||
setSelectedSuggestionIds([])
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setRefreshWarning('')
|
||||
setAnalyzing(false)
|
||||
}
|
||||
|
||||
async function analyzeSelectedItem() {
|
||||
if (!selectedItem || selectedItem.status !== 'open' || confirmationUncertain) return
|
||||
setAnalyzing(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setRefreshWarning('')
|
||||
const generation = analysisGeneration.current + 1
|
||||
analysisGeneration.current = generation
|
||||
const analyzedItemId = selectedItem.id
|
||||
try {
|
||||
const suggestions = await onAnalyze(analyzedItemId)
|
||||
if (analysisGeneration.current !== generation || selectedItemId !== analyzedItemId) return
|
||||
setDraftSuggestions(suggestions)
|
||||
setSelectedSuggestionIds(suggestions.map((suggestion) => suggestion.id))
|
||||
} catch (reason) {
|
||||
if (analysisGeneration.current !== generation) return
|
||||
setError(reason instanceof Error ? reason.message : '分析失败,请稍后重试')
|
||||
} finally {
|
||||
if (analysisGeneration.current === generation) setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSelectedSuggestions() {
|
||||
if (!selectedItem) return
|
||||
const confirmation = confirmationUncertain ?? {
|
||||
inboxItemId: selectedItem.id,
|
||||
suggestionIds: [...selectedSuggestionIds],
|
||||
}
|
||||
if (confirmation.suggestionIds.length === 0) {
|
||||
setError('请至少勾选一条建议')
|
||||
return
|
||||
}
|
||||
setConfirming(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
try {
|
||||
const outcome = await onConfirm(confirmation.inboxItemId, confirmation.suggestionIds)
|
||||
setConfirmationUncertain(null)
|
||||
setLocallyConfirmedItemIds((current) => current.includes(confirmation.inboxItemId) ? current : [...current, confirmation.inboxItemId])
|
||||
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
|
||||
setRefreshWarning(outcome.refreshError ?? '')
|
||||
} catch (reason) {
|
||||
setConfirmationUncertain(isUncertainConfirmationError(reason) ? confirmation : null)
|
||||
setError(confirmationErrorMessage(reason))
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSuggestion(identity: string, checked: boolean) {
|
||||
if (confirmationUncertain) return
|
||||
setSelectedSuggestionIds((current) => checked
|
||||
? current.includes(identity) ? current : [...current, identity]
|
||||
: current.filter((value) => value !== identity))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-inbox-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Inbox 消息流</Title>
|
||||
<Text type="secondary">查看原始内容,分析为候选草稿,并在确认后创建正式对象。</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeWorkspace.inbox.length === 0 ? (
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="当前没有待处理的 Inbox 内容" />
|
||||
</Card>
|
||||
) : (
|
||||
<div className="mail-layout inbox-workspace">
|
||||
<Card className="queue-section mail-list" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>收集内容({activeWorkspace.inbox.length})</Title>
|
||||
</div>
|
||||
{activeWorkspace.inbox.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={selectedItem?.id === item.id ? 'mail-item active' : 'mail-item'}
|
||||
disabled={confirming || confirmationUncertain !== null}
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<Text type="secondary" ellipsis={{ showTooltip: true }}>{item.summary || '暂无内容'}</Text>
|
||||
<div>
|
||||
<Tag color={isInboxItemProcessed(item, locallyConfirmedItemIds) ? 'green' : 'orange'}>{isInboxItemProcessed(item, locallyConfirmedItemIds) ? '已处理' : '待处理'}</Tag>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card className="queue-section mail-detail inbox-detail" bordered>
|
||||
{selectedItem ? (
|
||||
<>
|
||||
<div className="inbox-detail-head">
|
||||
<div>
|
||||
<Space size={8}>
|
||||
<Title heading={5}>{selectedItem.title}</Title>
|
||||
<Tag color={isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) ? 'green' : 'orange'}>
|
||||
{isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) ? '已处理' : '待处理'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary">{selectedItem.meta} · {selectedItem.time}</Text>
|
||||
</div>
|
||||
<Button
|
||||
icon={<IconRobot />}
|
||||
loading={analyzing}
|
||||
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
|
||||
onClick={() => void analyzeSelectedItem()}
|
||||
>
|
||||
分析内容
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="inbox-content" aria-label="Inbox 原始内容">
|
||||
<Text className="inbox-section-label">原始内容</Text>
|
||||
<Paragraph>{selectedItem.summary || '暂无内容'}</Paragraph>
|
||||
</section>
|
||||
|
||||
<section className="inbox-draft-section" aria-label="分析建议">
|
||||
<div className="section-header">
|
||||
<Title heading={6}>分析建议</Title>
|
||||
<Text type="secondary">仅勾选内容会在确认后创建</Text>
|
||||
</div>
|
||||
{draftSuggestions.length === 0 ? (
|
||||
<Text type="secondary">点击“分析内容”生成候选草稿,分析不会创建正式对象。</Text>
|
||||
) : (
|
||||
<div className="inbox-suggestion-list">
|
||||
{draftSuggestions.map((suggestion) => (
|
||||
<Checkbox
|
||||
key={suggestion.id}
|
||||
checked={selectedSuggestionIds.includes(suggestion.id)}
|
||||
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
|
||||
onChange={(checked) => toggleSuggestion(suggestion.id, checked)}
|
||||
>
|
||||
<span className="inbox-suggestion-copy">
|
||||
<span>
|
||||
<Tag color={suggestionColor(suggestion.kind)}>{suggestionLabel(suggestion.kind)}</Tag>
|
||||
<strong>{suggestion.title}</strong>
|
||||
</span>
|
||||
<Text type="secondary">{suggestion.body || '暂无补充内容'}</Text>
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && <Alert className="inbox-feedback" type="error" content={error} />}
|
||||
{success && <Alert className="inbox-feedback" type="success" content={success} icon={<IconCheckCircle />} />}
|
||||
{refreshWarning && <Alert className="inbox-feedback" type="warning" content={refreshWarning} />}
|
||||
|
||||
<div className="inbox-confirm-bar">
|
||||
<Text type="secondary">正式写入前请核对勾选项</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={confirming}
|
||||
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
|
||||
onClick={() => void confirmSelectedSuggestions()}
|
||||
>
|
||||
{confirmationUncertain ? '重试确认' : '确认创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isUncertainConfirmationError(reason: unknown) {
|
||||
return reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')
|
||||
}
|
||||
|
||||
function confirmationErrorMessage(reason: unknown) {
|
||||
if (isUncertainConfirmationError(reason)) {
|
||||
return '确认结果未知,服务端支持幂等处理,可安全重试'
|
||||
}
|
||||
return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试'
|
||||
}
|
||||
|
||||
function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]) {
|
||||
return item.status !== 'open' || locallyConfirmedItemIds.includes(item.id)
|
||||
}
|
||||
|
||||
function suggestionLabel(kind: InboxSuggestionDTO['kind']) {
|
||||
if (kind === 'task') return '任务'
|
||||
return 'Markdown 文档'
|
||||
}
|
||||
|
||||
function suggestionColor(kind: InboxSuggestionDTO['kind']) {
|
||||
if (kind === 'task') return 'arcoblue'
|
||||
return 'green'
|
||||
}
|
||||