Compare commits
148 Commits
1fcec3e0b5
...
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 |
2
.gitignore
vendored
@@ -9,3 +9,5 @@ coverage/
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
apps/desktop/release/
|
||||
.builds/
|
||||
|
||||
13
AGENTS.md
@@ -2,14 +2,14 @@
|
||||
|
||||
## 项目
|
||||
|
||||
本仓库包含 SenlinAI 项目工作台 MVP。
|
||||
本仓库包含森林AI项目工作台 MVP。
|
||||
|
||||
该产品是一个以项目为中心、面向私有化部署的工作台,服务于个人知识工作者、团队负责人和可信的内部用户。它用于组织项目任务、inbox 条目、Markdown 笔记、文件、AI 会话、项目内标签,以及轻量级任务分发。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
- `backend`: Go Gin + Gorm + PostgreSQL API。
|
||||
- `apps/web`: 响应式 Svelte + TypeScript Web 客户端。
|
||||
- `apps/web_v1`: 当前主 Web 客户端,使用 React + TypeScript + Arco Design。
|
||||
- `apps/desktop`: Web 客户端的 Tauri 桌面壳。
|
||||
- `infra`: 本地开发基础设施,例如 Docker Compose。
|
||||
- `docs/superpowers/specs`: 产品和技术规格文档。
|
||||
@@ -22,7 +22,7 @@
|
||||
- 使用模块路径 `senlinai-agent/backend`。
|
||||
- 业务逻辑包统一放在 `backend/internal/logic` 下,例如 `ai`、`auth`、`files`、`inbox`、`notes`、`projects`、`search`、`tasks`。
|
||||
- Gorm 模型和数据库连接入口统一放在 `backend/internal/models`,不要恢复独立的 `domain` 或 `db` 包。
|
||||
- 模型结构体使用 `SenlinAgent` 前缀,数据库表名使用 `senlin_agent_` 前缀。
|
||||
- 模型结构体使用 `Sa` 前缀,数据库表名使用 `sa_` 前缀。
|
||||
- 所有服务端本地文件路径构造必须集中在文件服务中。
|
||||
- 不允许 HTTP handler 直接构造存储路径。
|
||||
- 项目标签必须限定在项目内;MVP 阶段不要引入全局标签体系。
|
||||
@@ -37,9 +37,8 @@
|
||||
|
||||
## 前端规则
|
||||
|
||||
- 前端只使用 Svelte。不要添加 React 依赖或 React 组件。
|
||||
- 当前主前端使用 React、TypeScript、Vite 和 Arco Design,位于 `apps/web_v1`。
|
||||
- Web 应用优先面向桌面端构建,同时保持移动浏览器可用。
|
||||
- 功能代码放在 `apps/web/src/features` 下。
|
||||
- 登录界面必须允许用户输入服务器 IP 地址或域名。
|
||||
- 页面级工作台状态使用浏览器式标签页。
|
||||
- 编辑器草稿和 AI 输入草稿要与标签页状态分开保存。
|
||||
@@ -47,7 +46,7 @@
|
||||
|
||||
## Tauri 规则
|
||||
|
||||
- Tauri 用于包装 Web 客户端。
|
||||
- Tauri 用于包装当前主 Web 客户端。
|
||||
- MVP 桌面端功能包括登录持久化、文件拖拽上传、通知,以及全局快捷键快速收集。
|
||||
- MVP 阶段不要添加离线编辑、本地优先同步、后台剪贴板监控或文件系统索引。
|
||||
|
||||
@@ -67,6 +66,6 @@
|
||||
|
||||
- 后端变更应在 `backend` 目录运行 `go test ./...`。
|
||||
- PostgreSQL 集成检查应从 `DATABASE_URL` 读取实时测试数据库 URL;不要提交真实凭据。
|
||||
- Web 变更应在 `apps/web` 目录运行测试和构建。
|
||||
- Web 变更应在 `apps/web_v1` 目录运行 `node scripts/visual-check.mjs`、`npm run build` 和 `npm run lint`。
|
||||
- Tauri 变更应先构建 Web 应用,然后在 `apps/desktop` 目录运行 Tauri 验证命令。
|
||||
- 每个实施任务结束时都应创建一个聚焦的提交。
|
||||
|
||||
137
README.md
@@ -1,101 +1,100 @@
|
||||
# SenlinAI Agent Workbench
|
||||
<p align="center">
|
||||
<img src="docs/senlinai-icon.svg" width="88" alt="森林AI Logo" />
|
||||
</p>
|
||||
|
||||
SenlinAI Agent Workbench 是一个以项目为中心的私有化工作台 MVP。
|
||||
<h1 align="center">森林AI</h1>
|
||||
|
||||
## 本地开发
|
||||
<p align="center">以项目为中心的私有化智能工作台,让任务、资料、信息收集和 AI 协作在同一个空间自然生长。</p>
|
||||
|
||||
启动本地 PostgreSQL:
|
||||
## 产品简介
|
||||
|
||||
```powershell
|
||||
docker compose -f infra/docker-compose.yml up -d
|
||||
```
|
||||
森林AI 面向个人知识工作者、团队负责人和可信的内部协作成员,帮助用户把分散的事项、资料与沟通内容整理到清晰的项目空间中。
|
||||
|
||||
启动后端 API:
|
||||
每个项目都有独立的概况、工作计划、AI 会话、笔记资料和计划任务。用户既可以在工作台查看所有项目的整体进展,也可以进入具体项目,围绕目标持续收集信息、安排工作、沉淀知识并复盘成果。
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
go run ./cmd/api
|
||||
```
|
||||
## 适用场景
|
||||
|
||||
启动 Web 客户端:
|
||||
- 个人同时推进多个长期项目,需要统一查看待办和知识资料。
|
||||
- 团队负责人希望快速了解各项目状态、未完成计划和重点事项。
|
||||
- 内部团队需要在可控环境中使用 AI,并保留人工确认环节。
|
||||
- 研究、咨询、产品、运营等工作需要把零散输入逐步转化为任务、笔记和资料。
|
||||
- 项目文件、讨论背景和执行计划分散在不同工具中,希望集中管理。
|
||||
|
||||
```powershell
|
||||
Set-Location apps/web
|
||||
npm run dev
|
||||
```
|
||||
## 核心功能
|
||||
|
||||
## 后端配置
|
||||
### 统一工作台
|
||||
|
||||
后端配置不再从多个环境变量直接读取,而是从 `backend/etc/agent.<mode>.yaml` 读取。
|
||||
工作台汇总所有项目的数量、未完成计划、AI 会话和知识资料,并集中展示跨项目待办。用户可以直接查看和编辑任务,也可以调整任务所属项目、标签与完成状态。
|
||||
|
||||
配置模式由环境变量 `SENLIN_APP_MODE` 决定:
|
||||
### 独立项目空间
|
||||
|
||||
- 未设置 `SENLIN_APP_MODE` 时,默认读取 `backend/etc/agent.dev.yaml`。
|
||||
- 设置 `SENLIN_APP_MODE=prod` 时,读取 `backend/etc/agent.prod.yaml`。
|
||||
- 设置其他值时,按同样规则读取 `backend/etc/agent.<value>.yaml`。
|
||||
每个项目拥有自己的名称、图标、背景、简介和访问标识。项目概况页集中呈现进行中任务、已完成任务、笔记资料和计划任务,让项目当前状态一目了然。
|
||||
|
||||
默认开发配置文件:
|
||||
### Inbox 信息收集与整理
|
||||
|
||||
```text
|
||||
backend/etc/agent.dev.yaml
|
||||
```
|
||||
Inbox 用于承接尚未归类的想法、消息和材料。用户可以查看原始内容,让 AI 提供任务、笔记或资料建议,再自行勾选并确认创建。分析结果只是候选草稿,不会未经确认直接写入项目。
|
||||
|
||||
配置字段:
|
||||
### 工作计划与项目标签
|
||||
|
||||
- `env`: 运行环境,例如 `development`。
|
||||
- `port`: API 监听端口,默认开发值为 `8080`。
|
||||
- `dsn`: PostgreSQL 连接串。不要提交真实生产或测试凭据。
|
||||
- `storage_dir`: 服务端本地文件存储目录。
|
||||
- `auth_secret`: 邀请 token 和 session token 的 HMAC 密钥。
|
||||
- `system_ai_key`: 可选的系统级 AI provider fallback key。
|
||||
- `ai_key_encryption_secret`: 用户 AI key 静态加密密钥。
|
||||
任务按项目组织,可记录标题、说明、标签、负责人、创建时间和完成状态。项目内标签帮助用户按主题或优先级整理计划,同时保留进行中与已完成事项,方便跟进和复盘。
|
||||
|
||||
## 前端
|
||||
### AI 会话
|
||||
|
||||
Web 客户端只使用 Svelte 和 TypeScript。登录界面允许用户输入服务器 IP 地址或域名,并将其保存为 API base URL。
|
||||
用户可以从本地专家库中选择适合的专业角色,再创建带有项目背景的 AI 会话。专家库支持按分类浏览和关键词搜索,每个会话都会保留所选专家与项目上下文。AI 输出与正式项目内容相互区分,需要用户确认后才能转化为任务、笔记或资料。
|
||||
|
||||
## 桌面端
|
||||
内置专家资料基于 [agency-agents-zh](https://github.com/jnMetaCode/agency-agents-zh) 本地化,许可说明见 [docs/licenses/agency-agents-zh-MIT.txt](docs/licenses/agency-agents-zh-MIT.txt)。运行时不会依赖外部专家站点。
|
||||
|
||||
桌面端使用 Tauri 包装 Web 客户端。
|
||||
### 笔记与资料管理
|
||||
|
||||
构建桌面可执行文件:
|
||||
项目中的笔记、文档、图片、表格、PDF 和其他附件集中展示,并按更新时间浏览。资料与项目保持关联,减少文件散落和反复查找。
|
||||
|
||||
```powershell
|
||||
Set-Location apps/desktop
|
||||
npm run build
|
||||
```
|
||||
### 计划任务
|
||||
|
||||
生成安装包:
|
||||
用户可以为项目登记周期性计划和提醒安排,查看启用状态、最近时间与下次时间。计划任务用于帮助团队建立稳定节奏,不会自行替用户执行 AI 工作。
|
||||
|
||||
```powershell
|
||||
Set-Location apps/desktop
|
||||
npm run bundle
|
||||
```
|
||||
### 项目内搜索
|
||||
|
||||
## 验证
|
||||
通过顶部搜索框快速查找项目、任务和笔记,并在结果中确认内容及所属项目,缩短在多个项目之间定位信息的时间。
|
||||
|
||||
后端:
|
||||
## 典型使用流程
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
go test ./... -v
|
||||
```
|
||||
1. 创建项目,补充项目名称、图标、背景和目标说明。
|
||||
2. 将临时想法、会议内容或待整理材料放入 Inbox。
|
||||
3. 使用 AI 分析内容,选择合适的任务、笔记或资料建议。
|
||||
4. 人工核对并确认,将候选内容转为正式项目对象。
|
||||
5. 在工作计划中安排和更新任务,在笔记资料中持续沉淀成果。
|
||||
6. 通过项目概况和统一工作台掌握进展,及时处理跨项目待办。
|
||||
|
||||
Web:
|
||||
## 产品亮点
|
||||
|
||||
```powershell
|
||||
Set-Location apps/web
|
||||
npx tsc --noEmit -p tsconfig.app.json
|
||||
npm test -- --run
|
||||
npm run build
|
||||
npx playwright test
|
||||
```
|
||||
### 项目即上下文
|
||||
|
||||
桌面端:
|
||||
任务、资料、AI 会话与收集内容都围绕项目组织。用户无需在多个孤立工具之间重复解释背景,项目本身就是持续积累的工作上下文。
|
||||
|
||||
```powershell
|
||||
Set-Location apps/desktop
|
||||
npm run build
|
||||
```
|
||||
### 从零散输入到正式成果
|
||||
|
||||
更多验证说明见 `docs/mvp-verification.md`。
|
||||
森林AI 不只保存信息,还提供从 Inbox 收集、AI 分析、人工筛选到正式创建的完整整理路径,让模糊想法逐步变成可执行计划和可复用知识。
|
||||
|
||||
### AI 辅助,用户决策
|
||||
|
||||
AI 负责提供建议,用户保留最终决定权。任何正式任务、笔记或资料都需要确认后创建,既获得智能辅助,也避免自动化越权和错误沉淀。
|
||||
|
||||
### 全局视角与项目专注并存
|
||||
|
||||
工作台适合掌握全局,项目空间适合深入执行。用户可以在跨项目统筹和单项目专注之间快速切换,不会失去上下文。
|
||||
|
||||
### 数据边界清晰
|
||||
|
||||
森林AI 面向私有化使用场景,强调数据自有、访问可控和项目范围内的信息组织,适合重视资料安全与内部协作边界的用户和团队。
|
||||
|
||||
### 轻量但完整的工作闭环
|
||||
|
||||
从收集、整理、计划、执行到知识沉淀,核心能力集中在一个清晰的工作台中。产品保持轻量,不以复杂流程增加使用负担。
|
||||
|
||||
## 产品原则
|
||||
|
||||
- 以项目为中心,让信息始终有明确归属。
|
||||
- AI 未经用户确认,不创建正式内容。
|
||||
- 项目标签和资料保持在各自项目范围内。
|
||||
- 优先支持可信内部协作,不追求开放式公开分享。
|
||||
- 保持清晰、克制和可掌控,让用户专注于真正的项目成果。
|
||||
|
||||
19
apps/desktop/README.md
Normal file
@@ -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,15 +1,32 @@
|
||||
{
|
||||
"name": "desktop",
|
||||
"name": "senlinai-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"visual-check": "node scripts/visual-check.mjs",
|
||||
"tauri": "tauri",
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build --no-bundle",
|
||||
"bundle": "tauri build --ci --no-sign"
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.16",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.3"
|
||||
"@tauri-apps/cli": "^2.9.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
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'))
|
||||
672
apps/desktop/src-tauri/Cargo.lock
generated
@@ -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
|
Before Width: | Height: | Size: 15 KiB 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,29 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SenlinAI Workbench",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.senlin.workbench",
|
||||
"productName": "森林AI App",
|
||||
"version": "1.0.0",
|
||||
"identifier": "ai.senlin.client",
|
||||
"build": {
|
||||
"frontendDist": "../../web/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd ../web && npm run dev",
|
||||
"beforeBuildCommand": "cd ../web && npm run build"
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5180",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "项目工作台",
|
||||
"width": 1280,
|
||||
"height": 820
|
||||
"label": "main",
|
||||
"title": "森林AI App",
|
||||
"width": 420,
|
||||
"height": 820,
|
||||
"minWidth": 360,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"maximizable": false,
|
||||
"alwaysOnTop": true,
|
||||
"center": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http://* https://*"
|
||||
"csp": "default-src 'self' blob:; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http: https:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/icon.ico"]
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
316
apps/desktop/src/App.tsx
Normal file
@@ -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,79 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('login and project channel workbench flow', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
||||
await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
||||
await page.getByLabel('Email or username').fill('david@example.com');
|
||||
await page.getByLabel('Password').fill('secret');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(page.getByLabel('Project list')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Work Plan 8' }).click();
|
||||
await expect(page.getByText('Tasks')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Project A2' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByText('Ops Dashboard')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Ops Dashboard' }).click();
|
||||
await expect(page.getByText('Open external channel')).toBeVisible();
|
||||
});
|
||||
|
||||
for (const server of [
|
||||
'https://user:secret@example.com',
|
||||
'http:////example.com',
|
||||
'http//example.com',
|
||||
'https//example.com',
|
||||
'http:/example.com',
|
||||
'https:/example.com',
|
||||
'http://example.com\\path',
|
||||
'http://example..com',
|
||||
'http://-bad.com',
|
||||
]) {
|
||||
test(`rejects malformed server address: ${server}`, async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Server IP or domain').fill(server);
|
||||
await page.getByLabel('Email or username').fill('david@example.com');
|
||||
await page.getByLabel('Password').fill('secret');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
|
||||
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
||||
await page.getByLabel('Email or username').fill('david@example.com');
|
||||
await page.getByLabel('Password').fill('secret');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
const rail = page.getByLabel('Project list');
|
||||
const sidebar = page.getByLabel('Project channels');
|
||||
const stage = page.getByLabel('Channel content');
|
||||
const inspector = page.getByLabel('Object inspector');
|
||||
|
||||
await expect(rail).toHaveCSS('grid-auto-flow', 'column');
|
||||
await expect(rail).toHaveCSS('max-height', '76px');
|
||||
await expect(inspector).toHaveCSS('max-height', '288px');
|
||||
|
||||
const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
|
||||
await expect(workPlan).toBeVisible();
|
||||
await expect(workPlan).toBeInViewport();
|
||||
await workPlan.click();
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
|
||||
|
||||
const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
|
||||
expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
|
||||
expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
|
||||
});
|
||||
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,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
|
||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
||||
|
||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
||||
let currentUser: { account: string } | null = null;
|
||||
|
||||
function handleLogin(detail: { apiBase: string; account: string }) {
|
||||
apiBase = detail.apiBase;
|
||||
localStorage.setItem('apiBase', apiBase);
|
||||
currentUser = { account: detail.account };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if currentUser}
|
||||
<ProjectWorkbench {currentUser} />
|
||||
{:else}
|
||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
||||
{/if}
|
||||
@@ -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,134 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let apiBase: string;
|
||||
export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
|
||||
|
||||
let server = apiBase;
|
||||
let account = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let connectionStatus = 'Not connected. Enter a server address to continue.';
|
||||
|
||||
function normalizeServer(value: string): { apiBase?: string; error?: string } {
|
||||
if (!value.trim()) {
|
||||
return { error: 'Enter a server address.' };
|
||||
}
|
||||
|
||||
if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
|
||||
const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`;
|
||||
if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (
|
||||
!parsed.hostname ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
!['http:', 'https:'].includes(parsed.protocol) ||
|
||||
!isValidHostname(parsed.hostname)
|
||||
) {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
return { apiBase: candidate };
|
||||
} catch {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
}
|
||||
|
||||
function hasWhitespace(value: string): boolean {
|
||||
if (/\s/u.test(value)) return true;
|
||||
|
||||
try {
|
||||
return /\s/u.test(decodeURIComponent(value));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsupportedProtocol(value: string): boolean {
|
||||
const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i);
|
||||
if (explicitProtocol) {
|
||||
return !['http', 'https'].includes(explicitProtocol[1].toLowerCase());
|
||||
}
|
||||
|
||||
return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
|
||||
}
|
||||
|
||||
function hasMalformedHttpProtocol(value: string): boolean {
|
||||
return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value);
|
||||
}
|
||||
|
||||
function hasCredentials(value: string): boolean {
|
||||
const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
|
||||
return authority.includes('@');
|
||||
}
|
||||
|
||||
function isValidHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost') return true;
|
||||
if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true;
|
||||
if (/^\d+(?:\.\d+){3}$/.test(hostname)) {
|
||||
return hostname.split('.').every((segment) => Number(segment) <= 255);
|
||||
}
|
||||
|
||||
return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label));
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
error = '';
|
||||
const normalized = normalizeServer(server);
|
||||
if (normalized.error) {
|
||||
error = normalized.error;
|
||||
connectionStatus = 'Check the server address before signing in.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.trim() || !password.trim()) {
|
||||
error = 'Enter an account and password.';
|
||||
return;
|
||||
}
|
||||
|
||||
server = normalized.apiBase ?? server;
|
||||
connectionStatus = 'Connection settings are ready.';
|
||||
onLogin({ apiBase: server, account: account.trim() });
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="login-page">
|
||||
<section class="login-panel" aria-labelledby="login-title">
|
||||
<div class="login-brand" aria-hidden="true">SA</div>
|
||||
<div class="login-heading">
|
||||
<p>Private workbench</p>
|
||||
<h1 id="login-title">SenlinAI Workbench</h1>
|
||||
</div>
|
||||
|
||||
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
|
||||
<label for="server-address">Server IP or domain</label>
|
||||
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
||||
|
||||
<label for="login-account">Email or username</label>
|
||||
<input id="login-account" name="account" bind:value={account} autocomplete="username" />
|
||||
|
||||
<label for="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="form-error" aria-live="polite">{error}</p>
|
||||
{/if}
|
||||
|
||||
<p class="connection-status" role="status">{connectionStatus}</p>
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
<p class="login-note">The server address is remembered on this device.</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
@@ -1,102 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ServerLogin from './ServerLogin.svelte';
|
||||
|
||||
describe('ServerLogin', () => {
|
||||
it('renders server, account, and password fields', () => {
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
||||
|
||||
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
|
||||
});
|
||||
|
||||
it('normalizes server address and emits login details', async () => {
|
||||
const onLogin = vi.fn();
|
||||
render(ServerLogin, {
|
||||
props: {
|
||||
apiBase: 'localhost:8080',
|
||||
onLogin,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
|
||||
target: { value: '10.0.0.12:8080' },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText('Email or username'), {
|
||||
target: { value: 'david@example.com' },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText('Password'), {
|
||||
target: { value: 'secret' },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(onLogin).toHaveBeenCalledWith({
|
||||
apiBase: 'http://10.0.0.12:8080',
|
||||
account: 'david@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['localhost', 'http://localhost'],
|
||||
['localhost:8080', 'http://localhost:8080'],
|
||||
['192.168.1.20:3000', 'http://192.168.1.20:3000'],
|
||||
['example.com:8443', 'http://example.com:8443'],
|
||||
['https://example.com', 'https://example.com'],
|
||||
])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
|
||||
const onLogin = vi.fn();
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
|
||||
|
||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
|
||||
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
|
||||
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' });
|
||||
});
|
||||
|
||||
it('shows an error when account or password is missing', async () => {
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'Enter a server address.'],
|
||||
['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
[' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
])('rejects an invalid server value: %s', async (server, error) => {
|
||||
const onLogin = vi.fn();
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
|
||||
|
||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
|
||||
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
|
||||
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Suggestion } from './types';
|
||||
|
||||
export let suggestions: Suggestion[] = [];
|
||||
export let onConfirm: (selected: Suggestion[]) => void;
|
||||
|
||||
let selectedIndexes: number[] = [];
|
||||
|
||||
function toggle(index: number) {
|
||||
selectedIndexes = selectedIndexes.includes(index)
|
||||
? selectedIndexes.filter((item) => item !== index)
|
||||
: [...selectedIndexes, index];
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
onConfirm(selectedIndexes.map((index) => suggestions[index]));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="AI 整理建议" class="suggestions">
|
||||
{#each suggestions as suggestion, index}
|
||||
<label class="suggestion">
|
||||
<input
|
||||
aria-label={`选择 ${suggestion.title}`}
|
||||
type="checkbox"
|
||||
checked={selectedIndexes.includes(index)}
|
||||
on:change={() => toggle(index)}
|
||||
/>
|
||||
<span class="suggestion-body">
|
||||
<strong>{suggestion.title}</strong>
|
||||
<small>{suggestion.kind}</small>
|
||||
<span>{suggestion.body}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
<button type="button" on:click={confirm}>创建选中项</button>
|
||||
</section>
|
||||
@@ -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,5 +0,0 @@
|
||||
export type Suggestion = {
|
||||
kind: 'task' | 'note' | 'source';
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
@@ -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,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types';
|
||||
import OverviewChannel from './channels/OverviewChannel.svelte';
|
||||
import InboxChannel from './channels/InboxChannel.svelte';
|
||||
import TasksChannel from './channels/TasksChannel.svelte';
|
||||
import AISessionsChannel from './channels/AISessionsChannel.svelte';
|
||||
import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
|
||||
import CronChannel from './channels/CronChannel.svelte';
|
||||
import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
|
||||
|
||||
export let workspace: ProjectWorkspace;
|
||||
export let tasks: WorkTask[] = workspace.tasks;
|
||||
export let channel: WorkbenchChannel;
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
export let onToggleTask: (taskID: string) => void = () => {};
|
||||
</script>
|
||||
|
||||
{#if channel.type === 'overview'}
|
||||
<OverviewChannel {workspace} {onInspect} />
|
||||
{:else if channel.type === 'inbox'}
|
||||
<InboxChannel messages={workspace.inbox} {onInspect} />
|
||||
{:else if channel.type === 'tasks'}
|
||||
<TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} />
|
||||
{:else if channel.type === 'ai_sessions'}
|
||||
<AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
|
||||
{:else if channel.type === 'notes_sources'}
|
||||
<NotesSourcesChannel items={workspace.notesSources} {onInspect} />
|
||||
{:else if channel.type === 'cron'}
|
||||
<CronChannel plans={workspace.cronPlans} {onInspect} />
|
||||
{:else}
|
||||
<CustomLinkChannel {channel} />
|
||||
{/if}
|
||||
@@ -1,64 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ChannelContent from './ChannelContent.svelte';
|
||||
import { getProjectWorkspace } from './mockData';
|
||||
|
||||
describe('ChannelContent', () => {
|
||||
const workspace = getProjectWorkspace(1);
|
||||
|
||||
it('renders a distinctive heading or control for every channel type', () => {
|
||||
const expectedContent = {
|
||||
overview: { role: 'heading', name: 'Overview' },
|
||||
inbox: { role: 'heading', name: 'Inbox' },
|
||||
tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
|
||||
ai_sessions: { role: 'heading', name: 'AI Sessions' },
|
||||
notes_sources: { role: 'heading', name: 'Notes & Sources' },
|
||||
cron: { role: 'heading', name: 'Cron Plans' },
|
||||
custom_link: { role: 'link', name: 'Open external channel' },
|
||||
} as const;
|
||||
|
||||
for (const type of Object.keys(expectedContent) as Array<keyof typeof expectedContent>) {
|
||||
const channel = workspace.channels.find((item) => item.type === type);
|
||||
if (!channel) throw new Error(`missing ${type}`);
|
||||
|
||||
const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
|
||||
expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends selected task details to inspector', async () => {
|
||||
let inspectedTitle = '';
|
||||
const channel = workspace.channels.find((item) => item.type === 'tasks');
|
||||
if (!channel) throw new Error('missing task channel');
|
||||
|
||||
render(ChannelContent, {
|
||||
props: {
|
||||
workspace,
|
||||
channel,
|
||||
onInspect: (item) => {
|
||||
inspectedTitle = item.title;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
expect(inspectedTitle).toBe('Confirm homepage information architecture');
|
||||
});
|
||||
|
||||
it('reports task completion changes to its owner', async () => {
|
||||
const channel = workspace.channels.find((item) => item.type === 'tasks');
|
||||
if (!channel) throw new Error('missing task channel');
|
||||
const onToggleTask = vi.fn();
|
||||
|
||||
render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
|
||||
|
||||
const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
|
||||
expect(completion).not.toBeChecked();
|
||||
|
||||
await fireEvent.click(completion);
|
||||
|
||||
expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem } from './types';
|
||||
|
||||
export let item: InspectorItem | null;
|
||||
let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
|
||||
let copyFeedback = '';
|
||||
let copyFailed = false;
|
||||
let currentItem: InspectorItem | null = null;
|
||||
|
||||
$: if (item !== currentItem) {
|
||||
currentItem = item;
|
||||
copyFeedback = '';
|
||||
copyFailed = false;
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString();
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
copyFailed = true;
|
||||
copyFeedback = 'Clipboard access is unavailable.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
copyFailed = false;
|
||||
copyFeedback = 'Link copied to clipboard.';
|
||||
} catch {
|
||||
copyFailed = true;
|
||||
copyFeedback = 'Unable to copy link.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="object-inspector" aria-label="Object inspector">
|
||||
<div class="inspector-tabs">
|
||||
<button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
|
||||
<button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
|
||||
<button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
|
||||
</div>
|
||||
|
||||
{#if item}
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description}</p>
|
||||
{#if activeTab === 'discussion'}
|
||||
<p>No comments yet. Add project discussion here later.</p>
|
||||
{:else if activeTab === 'properties'}
|
||||
<dl>
|
||||
{#each item.properties as property}
|
||||
<div>
|
||||
<dt>{property.label}</dt>
|
||||
<dd>{property.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{:else}
|
||||
<button type="button" on:click={copyLink}>Copy link</button>
|
||||
{#if copyFeedback}
|
||||
<p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p>
|
||||
{/if}
|
||||
<button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<h2>Inspector</h2>
|
||||
<p>Select an item to inspect discussion, properties, and actions.</p>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -1,73 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import ObjectInspector from './ObjectInspector.svelte';
|
||||
|
||||
const item = {
|
||||
title: 'Review navigation',
|
||||
type: 'Task',
|
||||
description: 'Check the project navigation flow.',
|
||||
properties: [],
|
||||
};
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
function setClipboard(writeText: Clipboard['writeText']) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText } as Clipboard,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
describe('ObjectInspector', () => {
|
||||
async function openMoreActions() {
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'More' }));
|
||||
}
|
||||
|
||||
it('shows copy success only after clipboard writing resolves', async () => {
|
||||
let resolveWrite: (() => void) | undefined;
|
||||
const writeText = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
}),
|
||||
);
|
||||
setClipboard(writeText);
|
||||
render(ObjectInspector, { props: { item } });
|
||||
|
||||
await openMoreActions();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
resolveWrite?.();
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
|
||||
});
|
||||
|
||||
it('reports a copy failure when clipboard writing rejects', async () => {
|
||||
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
|
||||
render(ObjectInspector, { props: { item } });
|
||||
|
||||
await openMoreActions();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
|
||||
});
|
||||
|
||||
it('reports unavailable clipboard access without showing success', async () => {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
render(ObjectInspector, { props: { item } });
|
||||
|
||||
await openMoreActions();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
|
||||
|
||||
export let project: WorkbenchProject;
|
||||
export let channels: WorkbenchChannel[];
|
||||
export let selectedChannelID: string;
|
||||
export let tags: string[];
|
||||
export let recentSessions: AISessionItem[];
|
||||
export let onSelectChannel: (channelID: string) => void;
|
||||
|
||||
const channelIcons: Record<WorkbenchChannel['icon'], string> = {
|
||||
home: '\u2302',
|
||||
mail: '\u2709',
|
||||
list: '\u2630',
|
||||
sparkles: '\u2726',
|
||||
file: '\u25A4',
|
||||
clock: '\u25F7',
|
||||
link: '\u21AA',
|
||||
};
|
||||
|
||||
$: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder);
|
||||
</script>
|
||||
|
||||
<aside class="channel-sidebar" aria-label="Project channels">
|
||||
<header>
|
||||
<div>
|
||||
<p>Current project</p>
|
||||
<h2>{project.name}</h2>
|
||||
</div>
|
||||
<button aria-label="Project settings" disabled>⚙</button>
|
||||
</header>
|
||||
|
||||
<ul class="tag-row" aria-label="Project tags">
|
||||
{#each tags as tag}
|
||||
<li>#{tag}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<section class="channel-group">
|
||||
{#each sortedChannels as channel}
|
||||
<button
|
||||
class:active={channel.id === selectedChannelID}
|
||||
aria-pressed={channel.id === selectedChannelID}
|
||||
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
||||
on:click={() => onSelectChannel(channel.id)}
|
||||
>
|
||||
<span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span>
|
||||
<span>{channel.title}</span>
|
||||
{#if channel.count !== undefined}
|
||||
<small>{channel.count}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recent-sessions" aria-label="Recent sessions">
|
||||
<h3>Recent sessions</h3>
|
||||
<ul>
|
||||
{#each recentSessions as session}
|
||||
<li>
|
||||
<span>{session.title}</span>
|
||||
<small>{session.updatedAt}</small>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchProject } from './types';
|
||||
|
||||
export let projects: WorkbenchProject[];
|
||||
export let selectedProjectID: number;
|
||||
export let onSelectProject: (projectID: number) => void;
|
||||
</script>
|
||||
|
||||
<nav class="project-rail" aria-label="Project list">
|
||||
<button class="rail-logo" aria-label="Dashboard" disabled>SA</button>
|
||||
{#each projects as project}
|
||||
<button
|
||||
class:active={project.id === selectedProjectID}
|
||||
aria-pressed={project.id === selectedProjectID}
|
||||
aria-label={project.name}
|
||||
title={project.name}
|
||||
on:click={() => onSelectProject(project.id)}
|
||||
>
|
||||
<span>{project.initials}</span>
|
||||
{#if project.unreadCount}
|
||||
<small>{project.unreadCount}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="add-project" aria-label="Create project" disabled>+</button>
|
||||
</nav>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
import ChannelContent from './ChannelContent.svelte';
|
||||
import ObjectInspector from './ObjectInspector.svelte';
|
||||
import ProjectRail from './ProjectRail.svelte';
|
||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
||||
import type { InspectorItem, WorkTask } from './types';
|
||||
|
||||
export let currentUser: { account: string };
|
||||
|
||||
let selectedProjectID = workbenchProjects[0].id;
|
||||
let selectedItem: InspectorItem | null = null;
|
||||
let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries(
|
||||
workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]),
|
||||
);
|
||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
||||
$: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks;
|
||||
$: selectedChannelID = workspace.channels[0].id;
|
||||
$: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
|
||||
|
||||
function selectProject(projectID: number) {
|
||||
selectedItem = null;
|
||||
selectedProjectID = projectID;
|
||||
}
|
||||
|
||||
function selectChannel(channelID: string) {
|
||||
selectedItem = null;
|
||||
selectedChannelID = channelID;
|
||||
}
|
||||
|
||||
function inspectItem(item: InspectorItem) {
|
||||
selectedItem = item;
|
||||
}
|
||||
|
||||
function toggleTask(taskID: string) {
|
||||
tasksByProjectID = {
|
||||
...tasksByProjectID,
|
||||
[selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="workbench-shell">
|
||||
<WorkspaceTopbar account={currentUser.account} />
|
||||
<div class="workbench-body">
|
||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
||||
<ProjectChannelSidebar
|
||||
project={workspace.project}
|
||||
channels={workspace.channels}
|
||||
{selectedChannelID}
|
||||
tags={workspace.tags}
|
||||
recentSessions={workspace.recentSessions}
|
||||
onSelectChannel={selectChannel}
|
||||
/>
|
||||
<section class="channel-stage" aria-label="Channel content">
|
||||
<ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
|
||||
</section>
|
||||
<ObjectInspector item={selectedItem} />
|
||||
</div>
|
||||
</main>
|
||||
@@ -1,113 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ProjectWorkbench from './ProjectWorkbench.svelte';
|
||||
|
||||
describe('ProjectWorkbench', () => {
|
||||
it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Global search')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes channels when switching projects', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks active channel with aria-pressed', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('shows inspected task details in the inspector', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const inspector = screen.getByLabelText('Object inspector');
|
||||
|
||||
expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
|
||||
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
|
||||
|
||||
expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(inspector).getByText('Status')).toBeInTheDocument();
|
||||
expect(within(inspector).getByText('Open')).toBeInTheDocument();
|
||||
expect(within(inspector).getByText('Owner')).toBeInTheDocument();
|
||||
expect(within(inspector).getByText('David')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the inspector when the project or channel changes', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const inspector = screen.getByLabelText('Object inspector');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
|
||||
expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
|
||||
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves task completion when switching channels within a project', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
|
||||
await fireEvent.click(task);
|
||||
expect(task).toBeChecked();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('provides Copy link feedback and disables unavailable archive actions', async () => {
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard,
|
||||
});
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const inspector = screen.getByLabelText('Object inspector');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
|
||||
await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
|
||||
expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
|
||||
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let account: string;
|
||||
</script>
|
||||
|
||||
<header class="workspace-topbar">
|
||||
<div class="brand-mark">SenlinAI</div>
|
||||
<label class="search-box">
|
||||
<span>Search</span>
|
||||
<input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled />
|
||||
</label>
|
||||
<div class="topbar-actions">
|
||||
<button aria-label="Back" disabled>←</button>
|
||||
<button aria-label="Forward" disabled>→</button>
|
||||
<button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AISessionItem, InspectorItem } from '../types';
|
||||
|
||||
export let sessions: AISessionItem[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
let selectedSessionID = sessions[0]?.id;
|
||||
|
||||
$: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
|
||||
|
||||
function inspectSession(session: AISessionItem) {
|
||||
onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page ai-sessions-channel">
|
||||
<header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
|
||||
<div class="session-layout">
|
||||
<div class="session-list">
|
||||
{#each sessions as session}
|
||||
<button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedSession}
|
||||
<article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { CronPlan, InspectorItem } from '../types';
|
||||
|
||||
export let plans: CronPlan[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
function inspectPlan(plan: CronPlan) {
|
||||
onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page cron-channel">
|
||||
<header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
|
||||
<div class="cron-list">
|
||||
{#each plans as plan}
|
||||
<article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchChannel } from '../types';
|
||||
|
||||
export let channel: WorkbenchChannel;
|
||||
let copyStatus = '';
|
||||
let copyFailed = false;
|
||||
|
||||
async function copyUrl() {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
copyFailed = true;
|
||||
copyStatus = 'Copy unavailable in this browser.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(channel.url ?? '');
|
||||
copyFailed = false;
|
||||
copyStatus = 'URL copied.';
|
||||
} catch {
|
||||
copyFailed = true;
|
||||
copyStatus = 'Unable to copy URL.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page custom-link-channel">
|
||||
<header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
|
||||
<p>{channel.url}</p>
|
||||
<a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
|
||||
<button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button>
|
||||
{#if copyStatus}
|
||||
<p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,70 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import CustomLinkChannel from './CustomLinkChannel.svelte';
|
||||
|
||||
const channel = {
|
||||
id: 'project-custom-link',
|
||||
projectID: 1,
|
||||
type: 'custom_link' as const,
|
||||
title: 'Roadmap Board',
|
||||
icon: 'link',
|
||||
url: 'https://example.com/roadmap',
|
||||
sortOrder: 1,
|
||||
};
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
function setClipboard(writeText: Clipboard['writeText']) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText } as Clipboard,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
describe('CustomLinkChannel', () => {
|
||||
it('shows copy success only after clipboard writing resolves', async () => {
|
||||
let resolveWrite: (() => void) | undefined;
|
||||
const writeText = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
}),
|
||||
);
|
||||
setClipboard(writeText);
|
||||
render(CustomLinkChannel, { props: { channel } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||
|
||||
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
|
||||
resolveWrite?.();
|
||||
await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('reports a copy failure when clipboard writing rejects', async () => {
|
||||
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
|
||||
render(CustomLinkChannel, { props: { channel } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('reports unavailable clipboard access without showing URL copied', async () => {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
render(CustomLinkChannel, { props: { channel } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
|
||||
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InboxMessage, InspectorItem } from '../types';
|
||||
|
||||
export let messages: InboxMessage[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
function inspectMessage(message: InboxMessage) {
|
||||
onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page inbox-channel">
|
||||
<header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
|
||||
<div class="message-list">
|
||||
{#each messages as message}
|
||||
<article class="message-row">
|
||||
<div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
|
||||
<button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, NoteSourceItem } from '../types';
|
||||
|
||||
export let items: NoteSourceItem[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
function inspectItem(item: NoteSourceItem) {
|
||||
onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page notes-sources-channel">
|
||||
<header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
|
||||
<div class="file-list">
|
||||
{#each items as item}
|
||||
<article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, ProjectWorkspace } from '../types';
|
||||
|
||||
export let workspace: ProjectWorkspace;
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
const metrics = (workspace: ProjectWorkspace) => [
|
||||
{ label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
|
||||
{ label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
|
||||
{ label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
|
||||
{ label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
|
||||
{ label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="channel-page overview-channel">
|
||||
<header class="channel-header">
|
||||
<p>{workspace.project.name}</p>
|
||||
<h1>Overview</h1>
|
||||
</header>
|
||||
<div class="metric-list">
|
||||
{#each metrics(workspace) as metric}
|
||||
<button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
|
||||
<strong>{metric.value}</strong>
|
||||
<span>{metric.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, WorkTask } from '../types';
|
||||
|
||||
export let tasks: WorkTask[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
export let onToggleTask: (taskID: string) => void = () => {};
|
||||
|
||||
function inspectTask(task: WorkTask) {
|
||||
onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<section class="channel-page tasks-channel">
|
||||
<header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
|
||||
<div class="task-list">
|
||||
{#each tasks as task}
|
||||
<article class:completed={task.completed} class="task-card">
|
||||
<input
|
||||
class="task-check"
|
||||
type="checkbox"
|
||||
aria-label={`Mark ${task.title} complete`}
|
||||
checked={task.completed}
|
||||
on:change={() => onToggleTask(task.id)}
|
||||
/>
|
||||
<div>
|
||||
<h2>{task.title}</h2>
|
||||
<p>{task.summary}</p>
|
||||
<small>{task.owner} / {task.due} / #{task.tag}</small>
|
||||
<span class="task-status">{task.completed ? 'Completed' : 'Open'}</span>
|
||||
</div>
|
||||
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
|
||||
describe('workbench mock data', () => {
|
||||
it('provides dynamic projects', () => {
|
||||
expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
|
||||
expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
|
||||
});
|
||||
|
||||
it('loads project-specific channels and counts', () => {
|
||||
const first = getProjectWorkspace(workbenchProjects[0].id);
|
||||
const second = getProjectWorkspace(workbenchProjects[1].id);
|
||||
|
||||
expect(first.project.id).not.toBe(second.project.id);
|
||||
expect(first.channels.map((channel) => channel.type)).toEqual([
|
||||
'overview',
|
||||
'inbox',
|
||||
'tasks',
|
||||
'ai_sessions',
|
||||
'notes_sources',
|
||||
'cron',
|
||||
'custom_link',
|
||||
]);
|
||||
expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
|
||||
title: expect.any(String),
|
||||
url: expect.stringMatching(/^https?:\/\//),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
|
||||
|
||||
export const workbenchProjects: WorkbenchProject[] = [
|
||||
{ id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
|
||||
{ id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
|
||||
];
|
||||
|
||||
function systemChannels(projectID: number): WorkbenchChannel[] {
|
||||
return [
|
||||
{ id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
|
||||
{ id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
|
||||
{ id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
|
||||
{ id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
|
||||
{ id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
|
||||
{ id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
|
||||
{
|
||||
id: `${projectID}-custom-roadmap`,
|
||||
projectID,
|
||||
type: 'custom_link',
|
||||
title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
|
||||
icon: 'link',
|
||||
url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
|
||||
sortOrder: 7,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
|
||||
project,
|
||||
channels: systemChannels(project.id),
|
||||
tags: ['all', 'tag1', 'UI', 'knowledge'],
|
||||
recentSessions: [
|
||||
{ id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
|
||||
{ id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
|
||||
],
|
||||
inbox: [
|
||||
{ id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
|
||||
{ id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
|
||||
{ id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
|
||||
],
|
||||
aiSessions: [
|
||||
{ id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
|
||||
{ id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
|
||||
],
|
||||
notesSources: [
|
||||
{ id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
|
||||
{ id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
|
||||
{ id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
|
||||
],
|
||||
cronPlans: [
|
||||
{ id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
|
||||
{ id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
|
||||
],
|
||||
}));
|
||||
|
||||
export function getProjectWorkspace(projectID: number): ProjectWorkspace {
|
||||
return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
export type ChannelType =
|
||||
| 'overview'
|
||||
| 'inbox'
|
||||
| 'tasks'
|
||||
| 'ai_sessions'
|
||||
| 'notes_sources'
|
||||
| 'cron'
|
||||
| 'custom_link';
|
||||
|
||||
export type WorkbenchProject = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
initials: string;
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
export type WorkbenchChannel = {
|
||||
id: string;
|
||||
projectID: number;
|
||||
type: ChannelType;
|
||||
title: string;
|
||||
icon: string;
|
||||
count?: number;
|
||||
url?: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type InboxMessage = {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: 'open' | 'processed' | 'archived';
|
||||
tag: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
export type WorkTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
completed: boolean;
|
||||
owner: string;
|
||||
due: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type AISessionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
references: string[];
|
||||
};
|
||||
|
||||
export type NoteSourceItem = {
|
||||
id: string;
|
||||
kind: 'note' | 'file' | 'link';
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
tag: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CronPlan = {
|
||||
id: string;
|
||||
title: string;
|
||||
schedule: string;
|
||||
nextRun: string;
|
||||
enabled: boolean;
|
||||
lastResult: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export type InspectorItem = {
|
||||
title: string;
|
||||
type: string;
|
||||
description: string;
|
||||
properties: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
export type ProjectWorkspace = {
|
||||
project: WorkbenchProject;
|
||||
channels: WorkbenchChannel[];
|
||||
tags: string[];
|
||||
recentSessions: AISessionItem[];
|
||||
inbox: InboxMessage[];
|
||||
tasks: WorkTask[];
|
||||
aiSessions: AISessionItem[];
|
||||
notesSources: NoteSourceItem[];
|
||||
cronPlans: CronPlan[];
|
||||
};
|
||||
@@ -1,357 +0,0 @@
|
||||
:root {
|
||||
color: #18181b;
|
||||
background: #f4f4f5;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
--background: #f4f4f5;
|
||||
--panel: #ffffff;
|
||||
--panel-muted: #fafafa;
|
||||
--border: #d4d4d8;
|
||||
--text: #18181b;
|
||||
--muted: #71717a;
|
||||
--primary: #0f7ae5;
|
||||
--primary-strong: #0969c8;
|
||||
--success: #15803d;
|
||||
--danger: #b91c1c;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(420px, 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.server-login,
|
||||
.login-heading,
|
||||
.channel-page,
|
||||
.object-inspector,
|
||||
.channel-sidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-login input,
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.server-login button,
|
||||
.primary-action {
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.connection-status,
|
||||
.login-note,
|
||||
.form-error,
|
||||
.channel-header p,
|
||||
.channel-page small,
|
||||
.channel-page > p,
|
||||
.object-inspector > p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-error { color: var(--danger); }
|
||||
|
||||
.workbench-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 64px 1fr;
|
||||
}
|
||||
|
||||
.workspace-topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(240px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.brand-mark { font-weight: 700; }
|
||||
.search-box { display: grid; gap: 4px; min-width: 0; }
|
||||
.search-box span { color: var(--muted); font-size: 0.75rem; }
|
||||
.topbar-actions { display: flex; gap: 8px; }
|
||||
.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
|
||||
|
||||
.workbench-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.project-rail,
|
||||
.channel-sidebar,
|
||||
.object-inspector {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.project-rail {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.project-rail button {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-muted);
|
||||
}
|
||||
|
||||
.project-rail button.active,
|
||||
.channel-group button.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.project-rail small { color: var(--muted); font-size: 0.6875rem; }
|
||||
|
||||
.channel-sidebar,
|
||||
.object-inspector,
|
||||
.channel-stage {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.channel-sidebar {
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.channel-sidebar > header,
|
||||
.channel-sidebar > .channel-group,
|
||||
.channel-sidebar > .recent-sessions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-group,
|
||||
.task-list,
|
||||
.record-list,
|
||||
.message-list,
|
||||
.file-list,
|
||||
.cron-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channel-group button,
|
||||
.task-card,
|
||||
.record-row,
|
||||
.cron-row,
|
||||
.message-row,
|
||||
.file-row,
|
||||
.conversation-summary,
|
||||
.metric-card {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.task-card,
|
||||
.record-row,
|
||||
.cron-row,
|
||||
.message-row,
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-card.completed {
|
||||
color: var(--muted);
|
||||
background: var(--panel-muted);
|
||||
}
|
||||
|
||||
.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
|
||||
.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
|
||||
|
||||
.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
||||
.channel-sidebar header p,
|
||||
.channel-sidebar h2,
|
||||
.channel-sidebar h3 { margin: 0; }
|
||||
.channel-sidebar h2 { font-size: 1rem; }
|
||||
.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
|
||||
.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
|
||||
.tag-row,
|
||||
.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
|
||||
.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
|
||||
.channel-group small { color: var(--muted); }
|
||||
.channel-icon { color: var(--muted); text-align: center; }
|
||||
.recent-sessions { display: grid; gap: 8px; }
|
||||
.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
|
||||
.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
|
||||
.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
|
||||
|
||||
.channel-stage { background: var(--background); }
|
||||
.channel-page { align-content: start; }
|
||||
.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.channel-header h1,
|
||||
.channel-page h2 { margin: 0; }
|
||||
.channel-header h1 { font-size: 1.25rem; }
|
||||
.channel-page h2 { font-size: 0.9375rem; }
|
||||
.channel-page h2 + p { margin: 4px 0; }
|
||||
.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
|
||||
.metric-card { display: grid; gap: 4px; text-align: left; }
|
||||
.metric-card strong { font-size: 1.5rem; }
|
||||
.metric-card span { color: var(--muted); font-size: 0.8125rem; }
|
||||
.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
|
||||
.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
|
||||
.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
|
||||
.message-row p,
|
||||
.cron-row p,
|
||||
.conversation-summary p { margin: 4px 0; }
|
||||
.message-row button,
|
||||
.file-row button,
|
||||
.task-card > button,
|
||||
.cron-row button,
|
||||
.conversation-summary button,
|
||||
.copy-url-button,
|
||||
.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
|
||||
.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
|
||||
.session-list { display: grid; align-content: start; gap: 6px; }
|
||||
.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
|
||||
.session-list button.active { border-color: var(--primary); background: #eff6ff; }
|
||||
.session-list small { color: var(--muted); }
|
||||
.conversation-summary { align-content: start; }
|
||||
.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
|
||||
.copy-url-button { width: max-content; }
|
||||
.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
|
||||
.copy-feedback.copy-error { color: var(--danger); }
|
||||
|
||||
.object-inspector {
|
||||
align-content: start;
|
||||
grid-auto-rows: min-content;
|
||||
}
|
||||
|
||||
.inspector-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-content: start;
|
||||
grid-auto-rows: min-content;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inspector-tabs button[aria-pressed='true'] {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.workspace-topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.search-box { grid-column: 1 / -1; grid-row: 2; }
|
||||
.topbar-actions { grid-column: 2; grid-row: 1; }
|
||||
|
||||
.workbench-shell {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.workbench-body {
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.project-rail,
|
||||
.channel-sidebar,
|
||||
.object-inspector {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-rail {
|
||||
grid-template-columns: none;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 48px;
|
||||
align-items: center;
|
||||
max-height: 76px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.project-rail button { min-width: 48px; min-height: 48px; }
|
||||
|
||||
.channel-sidebar {
|
||||
align-content: start;
|
||||
max-height: 220px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.channel-group,
|
||||
.tag-row,
|
||||
.recent-sessions ul {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(132px, max-content);
|
||||
grid-template-columns: none;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.channel-group button { min-width: 160px; }
|
||||
.channel-group {
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: auto;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.channel-group button {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.channel-group button > span:last-of-type {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.channel-group small { display: none; }
|
||||
.recent-sessions li { min-width: 160px; }
|
||||
.channel-stage { max-height: 36vh; overflow: auto; }
|
||||
.object-inspector { max-height: 288px; overflow: auto; }
|
||||
.session-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export type Project = { id: number; name: string; description: string };
|
||||
|
||||
export type ProjectDashboardSummary = {
|
||||
project_id: number;
|
||||
pending_inbox_count: number;
|
||||
open_task_count: number;
|
||||
recent_note_count: number;
|
||||
recent_session_count: number;
|
||||
};
|
||||
|
||||
export function createApi(apiBase: string, token?: string) {
|
||||
async function request<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${apiBase}/api${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
return {
|
||||
getProjects: () => request<Project[]>('/projects'),
|
||||
getProjectDashboard: (projectID: number) =>
|
||||
request<ProjectDashboardSummary>(`/projects/${projectID}/dashboard`),
|
||||
};
|
||||
}
|
||||
@@ -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,17 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"types": ["svelte", "vite/client", "vitest/globals"],
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||